├── src └── meshcat │ ├── servers │ ├── __init__.py │ ├── tree.py │ └── zmqserver.py │ ├── tests │ ├── __init__.py │ ├── data │ │ ├── mesh_0_convex_piece_0.stl_binary │ │ ├── mesh_0_convex_piece_0.obj │ │ ├── mesh_0_convex_piece_0.dae │ │ └── mesh_0_convex_piece_0.stl_ascii │ ├── test_start_server.py │ ├── test_ports.py │ ├── dummy_websocket_client.py │ └── test_drawing.py │ ├── __init__.py │ ├── path.py │ ├── commands.py │ ├── animation.py │ ├── visualizer.py │ ├── geometry.py │ └── transformations.py ├── .codecov.yml ├── .gitmodules ├── MANIFEST.in ├── .gitignore ├── publishing_notes.md ├── examples ├── points.py ├── box.py ├── lines.ipynb ├── animation_demo.ipynb └── demo.ipynb ├── utils ├── zmqclient.py ├── socket_timing.py └── zmqrelay.py ├── setup.py ├── LICENSE.txt ├── .github └── workflows │ └── CI.yaml └── Readme.rst /src/meshcat/servers/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /src/meshcat/tests/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /.codecov.yml: -------------------------------------------------------------------------------- 1 | ignore: 2 | - "src/meshcat/transformations.py" 3 | - "setup.py" 4 | -------------------------------------------------------------------------------- /.gitmodules: -------------------------------------------------------------------------------- 1 | [submodule "meshcat/viewer"] 2 | path = src/meshcat/viewer 3 | url = https://github.com/rdeits/meshcat.git 4 | -------------------------------------------------------------------------------- /MANIFEST.in: -------------------------------------------------------------------------------- 1 | graft src/meshcat/viewer 2 | prune src/meshcat/viewer/node_modules 3 | graft src/meshcat/examples 4 | graft src/meshcat/tests/data 5 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | __pycache__ 2 | *.pyc 3 | venv 4 | *.egg-info 5 | *.eggs/ 6 | .ipynb_checkpoints 7 | build 8 | dist 9 | 10 | venv* 11 | .pytest_cache 12 | .vscode 13 | -------------------------------------------------------------------------------- /src/meshcat/tests/data/mesh_0_convex_piece_0.stl_binary: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/meshcat-dev/meshcat-python/HEAD/src/meshcat/tests/data/mesh_0_convex_piece_0.stl_binary -------------------------------------------------------------------------------- /publishing_notes.md: -------------------------------------------------------------------------------- 1 | 1. Update version and URL in setup.py 2 | 2. Create a new tag: 3 | 4 | git tag -a "v1.2.3" 5 | git push 6 | git push --tags 7 | 8 | 3. Upload 9 | 10 | python3 -m pip install --upgrade setuptools wheel 11 | rm dist/* 12 | python3 setup.py sdist bdist_wheel 13 | python3 -m pip install --upgrade twine 14 | twine upload dist/* 15 | -------------------------------------------------------------------------------- /examples/points.py: -------------------------------------------------------------------------------- 1 | from __future__ import absolute_import, division, print_function 2 | 3 | import time 4 | import numpy as np 5 | 6 | import meshcat 7 | import meshcat.geometry as g 8 | 9 | verts = np.random.random((3, 100000)).astype(np.float32) 10 | 11 | vis = meshcat.Visualizer().open() 12 | vis.set_object(g.Points( 13 | g.PointsGeometry(verts, color=verts), 14 | g.PointsMaterial() 15 | )) 16 | -------------------------------------------------------------------------------- /src/meshcat/__init__.py: -------------------------------------------------------------------------------- 1 | import os 2 | 3 | from . import commands 4 | from . import geometry 5 | from . import visualizer 6 | from . import servers 7 | from . import transformations 8 | from . import animation 9 | from .visualizer import Visualizer 10 | 11 | 12 | def viewer_assets_path(): 13 | return os.path.abspath( 14 | os.path.join( 15 | os.path.dirname(__file__), 16 | "viewer" 17 | ) 18 | ) 19 | -------------------------------------------------------------------------------- /utils/zmqclient.py: -------------------------------------------------------------------------------- 1 | import zmq 2 | 3 | context = zmq.Context() 4 | 5 | # Socket to talk to server 6 | print("Connecting to hello world server…") 7 | socket = context.socket(zmq.REQ) 8 | socket.connect("tcp://localhost:5555") 9 | 10 | # Do 10 requests, waiting each time for a response 11 | for request in range(10): 12 | print("Sending request %s …" % request) 13 | socket.send(b"Hello") 14 | 15 | # Get the reply. 16 | message = socket.recv() 17 | print("Received reply %s [ %s ]" % (request, message)) 18 | -------------------------------------------------------------------------------- /src/meshcat/tests/test_start_server.py: -------------------------------------------------------------------------------- 1 | import unittest 2 | 3 | from meshcat.servers.zmqserver import start_zmq_server_as_subprocess 4 | 5 | class TestStartZmqServer(unittest.TestCase): 6 | """ 7 | Test the StartZmqServerAsSubprocess method. 8 | """ 9 | 10 | def test_default_args(self): 11 | proc, zmq_url, web_url = start_zmq_server_as_subprocess() 12 | self.assertIn("127.0.0.1", web_url) 13 | 14 | def test_ngrok(self): 15 | proc, zmq_url, web_url = start_zmq_server_as_subprocess( server_args=["--ngrok_http_tunnel"]) 16 | self.assertIsNotNone(web_url) 17 | self.assertNotIn("127.0.0.1", web_url) 18 | -------------------------------------------------------------------------------- /examples/box.py: -------------------------------------------------------------------------------- 1 | from __future__ import absolute_import, division, print_function 2 | 3 | import math 4 | import time 5 | 6 | import meshcat 7 | 8 | vis = meshcat.Visualizer().open() 9 | 10 | box = meshcat.geometry.Box([0.5, 0.5, 0.5]) 11 | vis.set_object(box) 12 | 13 | draw_times = [] 14 | 15 | vis["/Background"].set_property("top_color", [1, 0, 0]) 16 | 17 | for i in range(200): 18 | theta = (i + 1) / 100 * 2 * math.pi 19 | now = time.time() 20 | vis.set_transform(meshcat.transformations.rotation_matrix(theta, [0, 0, 1])) 21 | draw_times.append(time.time() - now) 22 | time.sleep(0.01) 23 | 24 | print(sum(draw_times) / len(draw_times)) 25 | 26 | 27 | -------------------------------------------------------------------------------- /utils/socket_timing.py: -------------------------------------------------------------------------------- 1 | import asyncio 2 | import time 3 | 4 | import websockets 5 | import numpy as np 6 | 7 | 8 | async def handle_new_connection(websocket, path): 9 | print("connected", websocket) 10 | data = np.random.bytes(100000 * 3 * 4 * 2) 11 | N = 100 12 | 13 | now = time.time() 14 | for i in range(N): 15 | await websocket.send(data) 16 | duration = time.time() - now 17 | print(duration / N) 18 | print(len(data) / (duration / N) * 8) 19 | 20 | start_server = websockets.serve(handle_new_connection, '127.0.0.1', 8765) 21 | asyncio.get_event_loop().run_until_complete(start_server) 22 | asyncio.get_event_loop().run_forever() 23 | -------------------------------------------------------------------------------- /src/meshcat/path.py: -------------------------------------------------------------------------------- 1 | class Path(object): 2 | __slots__ = ["entries"] 3 | 4 | def __init__(self, entries=tuple()): 5 | self.entries = entries 6 | 7 | def append(self, other): 8 | new_path = self.entries 9 | for element in other.split('/'): 10 | if len(element) == 0: 11 | new_path = tuple() 12 | else: 13 | new_path = new_path + (element,) 14 | return Path(new_path) 15 | 16 | def lower(self): 17 | return "/" + "/".join(self.entries) 18 | 19 | def __hash__(self): 20 | return hash(self.entries) 21 | 22 | def __eq__(self, other): 23 | return self.entries == other.entries 24 | -------------------------------------------------------------------------------- /src/meshcat/servers/tree.py: -------------------------------------------------------------------------------- 1 | from __future__ import absolute_import, division, print_function 2 | 3 | from collections import defaultdict 4 | 5 | class TreeNode(defaultdict): 6 | __slots__ = ["object", "transform", "properties", "animation"] 7 | 8 | def __init__(self, *args, **kwargs): 9 | super(TreeNode, self).__init__(*args, **kwargs) 10 | self.object = None 11 | self.properties = [] 12 | self.transform = None 13 | self.animation = None 14 | 15 | SceneTree = lambda: TreeNode(SceneTree) 16 | 17 | def walk(tree): 18 | yield tree 19 | for v in tree.values(): 20 | for t in walk(v): # could use `yield from` if we didn't need python2 21 | yield t 22 | 23 | def find_node(tree, path): 24 | if len(path) == 0: 25 | return tree 26 | else: 27 | return find_node(tree[path[0]], path[1:]) 28 | -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | import sys 2 | from setuptools import setup, find_packages 3 | 4 | setup(name="meshcat", 5 | version="0.3.2", 6 | description="WebGL-based visualizer for 3D geometries and scenes", 7 | url="https://github.com/rdeits/meshcat-python", 8 | download_url="https://github.com/rdeits/meshcat-python/archive/v0.3.2.tar.gz", 9 | author="Robin Deits", 10 | author_email="mail@robindeits.com", 11 | license="MIT", 12 | packages=find_packages("src"), 13 | package_dir={"": "src"}, 14 | test_suite="meshcat", 15 | entry_points={ 16 | "console_scripts": [ 17 | "meshcat-server=meshcat.servers.zmqserver:main" 18 | ] 19 | }, 20 | install_requires=[ 21 | "ipython >= 5", 22 | "u-msgpack-python >= 2.4.1", 23 | "numpy >= 1.14.0", 24 | "tornado >= 4.0.0", 25 | "pyzmq >= 17.0.0", 26 | "pyngrok >= 4.1.6", 27 | "pillow >= 7.0.0" 28 | ], 29 | zip_safe=False, 30 | include_package_data=True 31 | ) 32 | -------------------------------------------------------------------------------- /LICENSE.txt: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2018 Robin Deits 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /src/meshcat/tests/test_ports.py: -------------------------------------------------------------------------------- 1 | import unittest 2 | import os 3 | import subprocess 4 | import sys 5 | 6 | import meshcat 7 | import meshcat.geometry as g 8 | 9 | 10 | class TestPortScan(unittest.TestCase): 11 | """ 12 | Test that the ZMQ server can correctly handle its default ports 13 | already being in use. 14 | """ 15 | 16 | def setUp(self): 17 | 18 | # the blocking_vis will take up the default fileserver and ZMQ ports 19 | self.blocking_vis = meshcat.Visualizer() 20 | 21 | # this should still work, by chosing a new port 22 | self.vis = meshcat.Visualizer() 23 | 24 | if "CI" in os.environ: 25 | port = self.vis.url().split(":")[-1].split("/")[0] 26 | self.dummy_proc = subprocess.Popen([sys.executable, "-m", "meshcat.tests.dummy_websocket_client", str(port)]) 27 | else: 28 | self.vis.open() 29 | self.dummy_proc = None 30 | 31 | self.vis.wait() 32 | 33 | def runTest(self): 34 | v = self.vis["shapes"] 35 | v["cube"].set_object(g.Box([0.1, 0.2, 0.3])) 36 | 37 | def tearDown(self): 38 | if self.dummy_proc is not None: 39 | self.dummy_proc.kill() 40 | -------------------------------------------------------------------------------- /utils/zmqrelay.py: -------------------------------------------------------------------------------- 1 | import asyncio 2 | import threading 3 | 4 | import websockets 5 | import zmq 6 | import zmq.asyncio 7 | 8 | # Tell asyncio to use zmq's eventloop. Future 9 | # versions may not need this. See: 10 | # https://github.com/zeromq/pyzmq/issues/1034#issuecomment-315113731 11 | zmq.asyncio.install() 12 | 13 | queues = set() 14 | 15 | async def handle_new_connection(websocket, path): 16 | print("connected", websocket) 17 | my_queue = asyncio.Queue() 18 | queues.add(my_queue) 19 | try: 20 | while True: 21 | msg = await my_queue.get() 22 | await websocket.send(msg) 23 | except websockets.ConnectionClosed as e: 24 | queues.remove(my_queue) 25 | 26 | start_server = websockets.serve(handle_new_connection, '127.0.0.1', 8765) 27 | asyncio.get_event_loop().run_until_complete(start_server) 28 | 29 | context = zmq.asyncio.Context() 30 | socket = context.socket(zmq.REP) 31 | socket.bind("tcp://*:5555") 32 | 33 | async def run_zmq(): 34 | while True: 35 | message = await socket.recv() 36 | for q in queues: 37 | await q.put(message) 38 | await socket.send(b"ok") 39 | 40 | asyncio.get_event_loop().run_until_complete(run_zmq()) 41 | -------------------------------------------------------------------------------- /.github/workflows/CI.yaml: -------------------------------------------------------------------------------- 1 | name: CI 2 | 3 | on: 4 | push: 5 | branches: 6 | - master 7 | pull_request: 8 | 9 | jobs: 10 | build: 11 | 12 | runs-on: ${{ matrix.os }} 13 | strategy: 14 | matrix: 15 | python-version: ["3.7", "3.8", "3.9", "3.10"] 16 | os: [ubuntu-latest, windows-latest] 17 | 18 | steps: 19 | - uses: actions/checkout@v3 20 | - name: Set up Python ${{ matrix.python-version }} 21 | uses: actions/setup-python@v4 22 | with: 23 | python-version: ${{ matrix.python-version }} 24 | - name: Install dependencies 25 | run: | 26 | python -m pip install --upgrade pip 27 | pip install flake8 pytest pytest-cov 28 | git submodule update --init --recursive 29 | pip install -e . 30 | - name: Lint with flake8 31 | run: | 32 | # stop the build if there are Python syntax errors or undefined names 33 | flake8 . --count --select=E9,F63,F7,F82 --show-source --statistics 34 | # exit-zero treats all errors as warnings. The GitHub editor is 127 chars wide 35 | flake8 . --count --exit-zero --max-complexity=10 --max-line-length=127 --statistics 36 | - name: Test with pytest 37 | run: | 38 | pytest --cov=./ 39 | - name: "Upload coverage to Codecov" 40 | uses: codecov/codecov-action@v3 41 | with: 42 | fail_ci_if_error: true 43 | -------------------------------------------------------------------------------- /src/meshcat/tests/data/mesh_0_convex_piece_0.obj: -------------------------------------------------------------------------------- 1 | v -0.06470900 0.00000000 0.11895900 2 | v -0.06470900 0.00000000 -0.11895900 3 | v 0.04741900 0.04865000 0.11499900 4 | v 0.04741900 0.04865000 -0.11499900 5 | v -0.04312000 0.05389400 -0.11652800 6 | v -0.04312000 0.05389400 0.11652800 7 | v 0.06642500 0.01573400 -0.11764700 8 | v 0.06642500 0.01573400 0.11764700 9 | v 0.00481100 0.06783700 -0.11221400 10 | v 0.00481100 0.06783700 0.11221400 11 | v 0.04304500 0.05392100 -0.11669400 12 | v 0.04304500 0.05392100 0.11669400 13 | v -0.03730800 0.05600900 -0.11424400 14 | v -0.04582000 0.05142100 0.11693600 15 | v -0.04582000 0.05142100 -0.11693600 16 | v -0.06639700 0.01578100 -0.11735700 17 | v -0.06639700 0.01578100 0.11735700 18 | v 0.06688200 0.00272700 -0.11845600 19 | v 0.06688200 0.00272700 0.11845600 20 | v 0.06457300 0.00000000 0.11895900 21 | v 0.06457300 0.00000000 -0.11895900 22 | v -0.06731300 0.00516900 0.11897700 23 | v -0.06731300 0.00516900 -0.11897700 24 | v -0.00494100 0.06779000 -0.11205300 25 | v -0.00494100 0.06779000 0.11205300 26 | f 1 21 20 27 | f 15 6 5 28 | f 11 10 12 29 | f 14 22 12 30 | f 22 2 1 31 | f 2 21 1 32 | f 8 19 7 33 | f 21 11 7 34 | f 19 8 20 35 | f 21 19 20 36 | f 8 12 20 37 | f 12 22 20 38 | f 22 1 20 39 | f 16 14 15 40 | f 10 24 25 41 | f 24 13 25 42 | f 22 14 17 43 | f 16 22 17 44 | f 14 16 17 45 | f 10 11 9 46 | f 24 10 9 47 | f 12 8 3 48 | f 11 12 3 49 | f 8 7 3 50 | f 12 10 6 51 | f 14 12 6 52 | f 15 14 6 53 | f 10 25 6 54 | f 25 13 6 55 | f 7 11 4 56 | f 11 3 4 57 | f 3 7 4 58 | f 2 22 23 59 | f 21 2 23 60 | f 11 21 23 61 | f 22 16 23 62 | f 15 11 23 63 | f 16 15 23 64 | f 19 21 18 65 | f 7 19 18 66 | f 21 7 18 67 | f 13 24 5 68 | f 11 15 5 69 | f 9 11 5 70 | f 24 9 5 71 | f 6 13 5 -------------------------------------------------------------------------------- /src/meshcat/tests/dummy_websocket_client.py: -------------------------------------------------------------------------------- 1 | # The MIT License (MIT) 2 | 3 | # Copyright (c) 2015 İlker Kesen 4 | 5 | # Permission is hereby granted, free of charge, to any person obtaining a copy 6 | # of this software and associated documentation files (the "Software"), to deal 7 | # in the Software without restriction, including without limitation the rights 8 | # to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | # copies of the Software, and to permit persons to whom the Software is 10 | # furnished to do so, subject to the following conditions: 11 | 12 | # The above copyright notice and this permission notice shall be included in all 13 | # copies or substantial portions of the Software. 14 | 15 | # THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | # IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | # FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | # AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | # LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | # OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | # SOFTWARE. 22 | 23 | 24 | 25 | # Based on https://github.com/ilkerkesen/tornado-websocket-client-example 26 | 27 | from __future__ import absolute_import, division, print_function 28 | import argparse 29 | 30 | from tornado.ioloop import IOLoop, PeriodicCallback 31 | from tornado import gen 32 | from tornado.websocket import websocket_connect 33 | 34 | 35 | class Client(object): 36 | def __init__(self, url, timeout): 37 | self.url = url 38 | self.timeout = timeout 39 | self.ioloop = IOLoop.instance() 40 | self.ws = None 41 | self.connect() 42 | self.ioloop.start() 43 | 44 | @gen.coroutine 45 | def connect(self): 46 | self.ws = yield websocket_connect(self.url) 47 | self.run() 48 | 49 | @gen.coroutine 50 | def run(self): 51 | while True: 52 | msg = yield self.ws.read_message() 53 | if msg is None: 54 | self.ws = None 55 | break 56 | 57 | 58 | if __name__ == '__main__': 59 | parser = argparse.ArgumentParser() 60 | parser.add_argument("port", type=int) 61 | result = parser.parse_args() 62 | url = "ws://localhost:{:d}".format(result.port) 63 | client = Client(url, 5) 64 | -------------------------------------------------------------------------------- /src/meshcat/commands.py: -------------------------------------------------------------------------------- 1 | from .geometry import Geometry, Object, Mesh, MeshPhongMaterial, OrthographicCamera, PerspectiveCamera, PointsMaterial, Points, TextTexture 2 | from .path import Path 3 | 4 | 5 | class SetObject: 6 | __slots__ = ["object", "path"] 7 | def __init__(self, geometry_or_object, material=None, path=None): 8 | if isinstance(geometry_or_object, Object): 9 | if material is not None: 10 | raise(ValueError("Please supply either an Object OR a Geometry and a Material")) 11 | self.object = geometry_or_object 12 | elif isinstance(geometry_or_object, (OrthographicCamera, PerspectiveCamera)): 13 | self.object = geometry_or_object 14 | else: 15 | if material is None: 16 | material = MeshPhongMaterial() 17 | if isinstance(material, PointsMaterial): 18 | self.object = Points(geometry_or_object, material) 19 | else: 20 | self.object = Mesh(geometry_or_object, material) 21 | if path is not None: 22 | self.path = path 23 | else: 24 | self.path = Path() 25 | 26 | def lower(self): 27 | return { 28 | u"type": u"set_object", 29 | u"object": self.object.lower(), 30 | u"path": self.path.lower() 31 | } 32 | 33 | 34 | class SetTransform: 35 | __slots__ = ["matrix", "path"] 36 | def __init__(self, matrix, path): 37 | self.matrix = matrix 38 | self.path = path 39 | 40 | def lower(self): 41 | return { 42 | u"type": u"set_transform", 43 | u"path": self.path.lower(), 44 | u"matrix": list(self.matrix.T.flatten()) 45 | } 46 | 47 | 48 | class SetCamTarget: 49 | """Set the camera target point.""" 50 | __slots__ = ["value"] 51 | def __init__(self, pos): 52 | self.value = pos 53 | 54 | def lower(self): 55 | return { 56 | u"type": "set_target", 57 | u"path": "", 58 | u"value": list(self.value) 59 | } 60 | 61 | 62 | class CaptureImage: 63 | 64 | def __init__(self, xres=None, yres=None): 65 | self.xres = xres 66 | self.yres = yres 67 | 68 | def lower(self): 69 | data = { 70 | u"type": u"capture_image" 71 | } 72 | if self.xres: 73 | data[u"xres"] = self.xres 74 | if self.yres: 75 | data[u"yres"] = self.yres 76 | return data 77 | 78 | 79 | class Delete: 80 | __slots__ = ["path"] 81 | def __init__(self, path): 82 | self.path = path 83 | 84 | def lower(self): 85 | return { 86 | u"type": u"delete", 87 | u"path": self.path.lower() 88 | } 89 | 90 | class SetProperty: 91 | __slots__ = ["path", "key", "value"] 92 | def __init__(self, key, value, path): 93 | self.key = key 94 | self.value = value 95 | self.path = path 96 | 97 | def lower(self): 98 | return { 99 | u"type": u"set_property", 100 | u"path": self.path.lower(), 101 | u"property": self.key.lower(), 102 | u"value": self.value 103 | } 104 | 105 | class SetAnimation: 106 | __slots__ = ["animation", "play", "repetitions"] 107 | 108 | def __init__(self, animation, play=True, repetitions=1): 109 | self.animation = animation 110 | self.play = play 111 | self.repetitions = repetitions 112 | 113 | def lower(self): 114 | return { 115 | u"type": u"set_animation", 116 | u"animations": self.animation.lower(), 117 | u"options": { 118 | u"play": self.play, 119 | u"repetitions": self.repetitions 120 | }, 121 | u"path": "" 122 | } 123 | -------------------------------------------------------------------------------- /examples/lines.ipynb: -------------------------------------------------------------------------------- 1 | { 2 | "cells": [ 3 | { 4 | "cell_type": "code", 5 | "execution_count": 1, 6 | "metadata": {}, 7 | "outputs": [ 8 | { 9 | "name": "stdout", 10 | "output_type": "stream", 11 | "text": [ 12 | "You can open the visualizer by visiting the following URL:\n", 13 | "http://127.0.0.1:7004/static/\n" 14 | ] 15 | }, 16 | { 17 | "data": { 18 | "text/html": [ 19 | "\n", 20 | "
\n", 21 | "\n", 22 | "
\n" 23 | ], 24 | "text/plain": [ 25 | "" 26 | ] 27 | }, 28 | "execution_count": 1, 29 | "metadata": {}, 30 | "output_type": "execute_result" 31 | } 32 | ], 33 | "source": [ 34 | "import meshcat\n", 35 | "import meshcat.geometry as g\n", 36 | "import numpy as np\n", 37 | "import time\n", 38 | "\n", 39 | "vis = meshcat.Visualizer()\n", 40 | "vis.jupyter_cell()" 41 | ] 42 | }, 43 | { 44 | "cell_type": "markdown", 45 | "metadata": {}, 46 | "source": [ 47 | "Create some random vertices and render them as individual, nonconnected, [LineSegments](https://threejs.org/docs/index.html#api/en/objects/LineSegments)." 48 | ] 49 | }, 50 | { 51 | "cell_type": "code", 52 | "execution_count": 2, 53 | "metadata": {}, 54 | "outputs": [], 55 | "source": [ 56 | "vis.delete()\n", 57 | "vertices = np.random.random((3, 10)).astype(np.float32)\n", 58 | "vis['lines_segments'].set_object(g.LineSegments(g.PointsGeometry(vertices)))" 59 | ] 60 | }, 61 | { 62 | "cell_type": "markdown", 63 | "metadata": {}, 64 | "source": [ 65 | "Render as a single connected [Line](https://threejs.org/docs/index.html#api/en/objects/Line)." 66 | ] 67 | }, 68 | { 69 | "cell_type": "code", 70 | "execution_count": 3, 71 | "metadata": {}, 72 | "outputs": [], 73 | "source": [ 74 | "line_vertices = np.array(vertices)\n", 75 | "line_vertices[1, :] += 1\n", 76 | "vis['line'].set_object(g.Line(g.PointsGeometry(line_vertices)))" 77 | ] 78 | }, 79 | { 80 | "cell_type": "markdown", 81 | "metadata": {}, 82 | "source": [ 83 | "Render as a [LineLoop](https://threejs.org/docs/index.html#api/en/objects/LineLoop)." 84 | ] 85 | }, 86 | { 87 | "cell_type": "code", 88 | "execution_count": 4, 89 | "metadata": {}, 90 | "outputs": [], 91 | "source": [ 92 | "line_loop = np.array(vertices)\n", 93 | "line_loop[1, :] += 2\n", 94 | "vis['line_loop'].set_object(g.LineLoop(g.PointsGeometry(line_loop)))" 95 | ] 96 | }, 97 | { 98 | "cell_type": "markdown", 99 | "metadata": {}, 100 | "source": [ 101 | "Line can have mesh materials." 102 | ] 103 | }, 104 | { 105 | "cell_type": "code", 106 | "execution_count": 5, 107 | "metadata": {}, 108 | "outputs": [], 109 | "source": [ 110 | "vis.delete()\n", 111 | "vertices = np.random.random((3, 10)).astype(np.float32)\n", 112 | "vis['basic'].set_object(g.Line(g.PointsGeometry(vertices), g.MeshBasicMaterial(color=0xff0000)))" 113 | ] 114 | }, 115 | { 116 | "cell_type": "code", 117 | "execution_count": 6, 118 | "metadata": {}, 119 | "outputs": [], 120 | "source": [ 121 | "vphong = np.array(vertices)\n", 122 | "vphong[1, :] += 1\n", 123 | "vis['phong'].set_object(g.Line(g.PointsGeometry(vphong), g.MeshPhongMaterial(color=0xff0000)))" 124 | ] 125 | }, 126 | { 127 | "cell_type": "code", 128 | "execution_count": 7, 129 | "metadata": {}, 130 | "outputs": [], 131 | "source": [ 132 | "vlamb = np.array(vertices)\n", 133 | "vlamb[1, :] += 2\n", 134 | "vis['lambert'].set_object(g.Line(g.PointsGeometry(vlamb), g.MeshLambertMaterial(color=0xff0000)))" 135 | ] 136 | }, 137 | { 138 | "cell_type": "code", 139 | "execution_count": 8, 140 | "metadata": {}, 141 | "outputs": [], 142 | "source": [ 143 | "vtoon = np.array(vertices)\n", 144 | "vtoon[1, :] += 3\n", 145 | "vis['toon'].set_object(g.Line(g.PointsGeometry(vtoon), g.MeshToonMaterial(color=0xff0000)))" 146 | ] 147 | }, 148 | { 149 | "cell_type": "markdown", 150 | "metadata": {}, 151 | "source": [ 152 | "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." 153 | ] 154 | } 155 | ], 156 | "metadata": { 157 | "kernelspec": { 158 | "display_name": "Python 3", 159 | "language": "python", 160 | "name": "python3" 161 | }, 162 | "language_info": { 163 | "codemirror_mode": { 164 | "name": "ipython", 165 | "version": 3 166 | }, 167 | "file_extension": ".py", 168 | "mimetype": "text/x-python", 169 | "name": "python", 170 | "nbconvert_exporter": "python", 171 | "pygments_lexer": "ipython3", 172 | "version": "3.7.2" 173 | } 174 | }, 175 | "nbformat": 4, 176 | "nbformat_minor": 2 177 | } 178 | -------------------------------------------------------------------------------- /src/meshcat/animation.py: -------------------------------------------------------------------------------- 1 | import tempfile 2 | import tarfile 3 | import os.path 4 | import subprocess 5 | 6 | import bisect 7 | from . import transformations as tf 8 | 9 | 10 | class AnimationTrack(object): 11 | __slots__ = ["name", "jstype", "frames", "values"] 12 | 13 | def __init__(self, name, jstype, frames=None, values=None): 14 | self.name = name 15 | self.jstype = jstype 16 | if frames is None: 17 | self.frames = [] 18 | else: 19 | self.frames = frames 20 | if values is None: 21 | self.values = [] 22 | else: 23 | self.values = values 24 | 25 | def set_property(self, frame, value): 26 | i = bisect.bisect(self.frames, frame) 27 | self.frames.insert(i, frame) 28 | self.values.insert(i, value) 29 | 30 | def lower(self): 31 | return { 32 | u"name": str("." + self.name), 33 | u"type": str(self.jstype), 34 | u"keys": [{ 35 | u"time": self.frames[i], 36 | u"value": self.values[i] 37 | } for i in range(len(self.frames))] 38 | } 39 | 40 | 41 | class AnimationClip(object): 42 | __slots__ = ["tracks", "fps", "name"] 43 | 44 | def __init__(self, tracks=None, fps=30, name=u"default"): 45 | if tracks is None: 46 | self.tracks = {} 47 | else: 48 | self.tracks = tracks 49 | self.fps = fps 50 | self.name = name 51 | 52 | def set_property(self, frame, property, jstype, value): 53 | if property not in self.tracks: 54 | self.tracks[property] = AnimationTrack(property, jstype) 55 | track = self.tracks[property] 56 | track.set_property(frame, value) 57 | 58 | def lower(self): 59 | return { 60 | u"fps": self.fps, 61 | u"name": str(self.name), 62 | u"tracks": [t.lower() for t in self.tracks.values()] 63 | } 64 | 65 | 66 | class Animation(object): 67 | __slots__ = ["clips", "default_framerate"] 68 | 69 | def __init__(self, clips=None, default_framerate=30): 70 | if clips is None: 71 | self.clips = {} 72 | else: 73 | self.clips = clips 74 | self.default_framerate = default_framerate 75 | 76 | def lower(self): 77 | return [{ 78 | u"path": path.lower(), 79 | u"clip": clip.lower() 80 | } for (path, clip) in self.clips.items()] 81 | 82 | def at_frame(self, visualizer, frame): 83 | return AnimationFrameVisualizer(self, visualizer.path, frame) 84 | 85 | 86 | def js_position(matrix): 87 | return list(matrix[:3, 3]) 88 | 89 | 90 | def js_quaternion(matrix): 91 | quat = tf.quaternion_from_matrix(matrix) 92 | return [quat[1], quat[2], quat[3], quat[0]] 93 | 94 | 95 | class AnimationFrameVisualizer(object): 96 | __slots__ = ["animation", "path", "current_frame"] 97 | 98 | def __init__(self, animation, path, current_frame): 99 | self.animation = animation 100 | self.path = path 101 | self.current_frame = current_frame 102 | 103 | def get_clip(self): 104 | if self.path not in self.animation.clips: 105 | self.animation.clips[self.path] = AnimationClip(fps=self.animation.default_framerate) 106 | return self.animation.clips[self.path] 107 | 108 | def set_transform(self, matrix): 109 | clip = self.get_clip() 110 | clip.set_property(self.current_frame, u"position", u"vector3", js_position(matrix)) 111 | clip.set_property(self.current_frame, u"quaternion", u"quaternion", js_quaternion(matrix)) 112 | 113 | def set_property(self, prop, jstype, value): 114 | clip = self.get_clip() 115 | clip.set_property(self.current_frame, prop, jstype, value) 116 | 117 | def __getitem__(self, path): 118 | return AnimationFrameVisualizer(self.animation, self.path.append(path), self.current_frame) 119 | 120 | def __enter__(self): 121 | return self 122 | 123 | def __exit__(self, *arg): 124 | pass 125 | 126 | 127 | def convert_frames_to_video(tar_file_path, output_path="output.mp4", framerate=60, overwrite=False): 128 | """ 129 | Try to convert a tar file containing a sequence of frames saved by the 130 | meshcat viewer into a single video file. 131 | 132 | This relies on having `ffmpeg` installed on your system. 133 | """ 134 | output_path = os.path.abspath(output_path) 135 | if os.path.isfile(output_path) and not overwrite: 136 | raise ValueError("The output path {:s} already exists. To overwrite that file, you can pass overwrite=True to this function.".format(output_path)) 137 | with tempfile.TemporaryDirectory() as tmp_dir: 138 | with tarfile.open(tar_file_path) as tar: 139 | def is_within_directory(directory, target): 140 | 141 | abs_directory = os.path.abspath(directory) 142 | abs_target = os.path.abspath(target) 143 | 144 | prefix = os.path.commonprefix([abs_directory, abs_target]) 145 | 146 | return prefix == abs_directory 147 | 148 | def safe_extract(tar, path=".", members=None, *, numeric_owner=False): 149 | 150 | for member in tar.getmembers(): 151 | member_path = os.path.join(path, member.name) 152 | if not is_within_directory(path, member_path): 153 | raise Exception("Attempted Path Traversal in Tar File") 154 | 155 | tar.extractall(path, members, numeric_owner=numeric_owner) 156 | 157 | 158 | safe_extract(tar, tmp_dir) 159 | args = ["ffmpeg", 160 | "-r", str(framerate), 161 | "-i", r"%07d.png", 162 | "-vcodec", "libx264", 163 | "-preset", "slow", 164 | "-pix_fmt", "yuv420p", 165 | "-crf", "18"] 166 | if overwrite: 167 | args.append("-y") 168 | args.append(output_path) 169 | try: 170 | subprocess.check_call(args, cwd=tmp_dir) 171 | except subprocess.CalledProcessError as e: 172 | print(""" 173 | Could not call `ffmpeg` to convert your frames into a video. 174 | If you want to convert the frames manually, you can extract the 175 | .tar archive into a directory, cd to that directory, and run: 176 | ffmpeg -r 60 -i %07d.png -vcodec libx264 -preset slow -pix_fmt yuv420p -crf 18 output.mp4 177 | """) 178 | raise 179 | print("Saved output as {:s}".format(output_path)) 180 | return output_path 181 | 182 | 183 | 184 | -------------------------------------------------------------------------------- /src/meshcat/tests/data/mesh_0_convex_piece_0.dae: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | VCGLab 6 | VCGLib | MeshLab 7 | 8 | Y_UP 9 | Sun Feb 24 17:28:46 2019 10 | Sun Feb 24 17:28:46 2019 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | -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 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 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 30 | 31 | 32 | 33 | 34 | 35 | 36 | 37 | 38 | 39 | 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 40 | 41 | 42 | 43 | 44 | 45 | 46 | 47 | 48 | 49 | 50 | 51 | 52 | 53 | 54 | 55 |

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

56 |
57 |
58 |
59 |
60 | 61 | 62 | 63 | 64 | 65 | 66 | 67 | 68 | 69 | 70 | 71 | 72 | 73 | 74 |
75 | -------------------------------------------------------------------------------- /Readme.rst: -------------------------------------------------------------------------------- 1 | meshcat-python: Python Bindings to the MeshCat WebGL viewer 2 | =========================================================== 3 | 4 | .. image:: https://github.com/meshcat-dev/meshcat-python/workflows/CI/badge.svg?branch=master 5 | :target: https://github.com/meshcat-dev/meshcat-python/actions?query=workflow%3ACI 6 | .. image:: https://codecov.io/gh/meshcat-dev/meshcat-python/branch/master/graph/badge.svg 7 | :target: https://codecov.io/gh/meshcat-dev/meshcat-python 8 | 9 | 10 | 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. 11 | 12 | The MeshCat architecture is based on the model used by Jupyter_: 13 | 14 | - The viewer itself runs entirely in the browser, with no external dependencies 15 | - The MeshCat server communicates with the viewer via WebSockets 16 | - Your code can use the meshcat python libraries or communicate directly with the server through its ZeroMQ_ socket. 17 | 18 | .. _ZeroMQ: http://zguide.zeromq.org/ 19 | .. _Jupyter: http://jupyter.org/ 20 | .. _MeshCat: https://github.com/meshcat-dev/meshcat 21 | .. _three.js: https://threejs.org/ 22 | 23 | Installation 24 | ------------ 25 | 26 | The latest version of MeshCat requires Python 3.6 or above. 27 | 28 | Using pip: 29 | 30 | :: 31 | 32 | pip install meshcat 33 | 34 | From source: 35 | 36 | :: 37 | 38 | git clone https://github.com/meshcat-dev/meshcat-python 39 | git submodule update --init --recursive 40 | cd meshcat-python 41 | python setup.py install 42 | 43 | You will need the ZeroMQ libraries installed on your system: 44 | 45 | Ubuntu/Debian: 46 | 47 | :: 48 | 49 | apt install libzmq3-dev 50 | 51 | Homebrew: 52 | 53 | :: 54 | 55 | brew install zmq 56 | 57 | Windows: 58 | 59 | Download the official installer from zeromq.org_. 60 | 61 | .. _zeromq.org: https://zeromq.org/download/ 62 | 63 | Usage 64 | ===== 65 | 66 | For examples of interactive usage, see demo.ipynb_ 67 | 68 | .. _demo.ipynb: examples/demo.ipynb 69 | 70 | Under the Hood 71 | ============== 72 | 73 | Starting a Server 74 | ----------------- 75 | 76 | 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: 77 | 78 | :: 79 | 80 | meshcat-server 81 | 82 | The server will choose an available ZeroMQ URL and print that URL over stdout. If you want to specify a URL, just do: 83 | 84 | :: 85 | 86 | meshcat-server --zmq-url= 87 | 88 | You can also instruct the server to open a browser window with: 89 | 90 | :: 91 | 92 | meshcat-server --open 93 | 94 | Protocol 95 | -------- 96 | 97 | All communication with the meshcat server happens over the ZMQ socket. Some commands consist of multiple ZMQ frames. 98 | 99 | :ZMQ frames: 100 | ``["url"]`` 101 | :Action: 102 | Request URL 103 | :Response: 104 | The web URL for the server. Open this URL in your browser to see the 3D scene. 105 | 106 | | 107 | 108 | :ZMQ frames: 109 | ``["wait"]`` 110 | :Action: 111 | Wait for a browser to connect 112 | :Response: 113 | "ok" when a brower has connected to the server. This is useful in scripts to block execution until geometry can actually be displayed. 114 | 115 | | 116 | 117 | :ZMQ frames: 118 | ``["set_object", "/slash/separated/path", data]`` 119 | :Action: 120 | Set the object at the given path. ``data`` is a ``MsgPack``-encoded dictionary, described below. 121 | :Response: 122 | "ok" 123 | 124 | | 125 | 126 | :ZMQ frames: 127 | ``["set_transform", "/slash/separated/path", data]`` 128 | :Action: 129 | 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. 130 | :Response: 131 | "ok" 132 | 133 | | 134 | 135 | :ZMQ frames: 136 | ``["delete", "/slash/separated/path", data]`` 137 | :Action: 138 | Delete the object at the given path. ``data`` is a ``MsgPack``-encoded dictionary, described below. 139 | :Response: 140 | "ok" 141 | 142 | | 143 | 144 | ``set_object`` data format 145 | ^^^^^^^^^^^^^^^^^^^^^^^^^^ 146 | :: 147 | 148 | { 149 | "type": "set_object", 150 | "path": "/slash/separated/path", // the path of the object 151 | "object": 152 | } 153 | 154 | 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_ . 155 | 156 | Note on redundancy 157 | 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. 158 | 159 | .. _wiki: https://github.com/mrdoob/three.js/wiki/JSON-Geometry-format-4 160 | .. _msgpack: https://msgpack.org/index.html 161 | 162 | ``set_transform`` data format 163 | ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ 164 | :: 165 | 166 | { 167 | "type": "set_transform", 168 | "path": "/slash/separated/path", 169 | "matrix": [1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1] 170 | } 171 | 172 | The format of the ``matrix`` in a ``set_transform`` command is a column-major homogeneous transformation matrix. 173 | 174 | ``delete`` data format 175 | ^^^^^^^^^^^^^^^^^^^^^^ 176 | :: 177 | 178 | { 179 | "type": "delete", 180 | "path", "/slash/separated/path" 181 | } 182 | 183 | Examples 184 | -------- 185 | 186 | Creating a box at path ``/meshcat/box`` 187 | 188 | :: 189 | 190 | { 191 | "type": "set_object", 192 | "path": "/meshcat/box", 193 | "object": { 194 | "metadata": {"type": "Object", "version": 4.5}, 195 | "geometries": [{"depth": 0.5, 196 | "height": 0.5, 197 | "type": "BoxGeometry", 198 | "uuid": "fbafc3d6-18f8-11e8-b16e-f8b156fe4628", 199 | "width": 0.5}], 200 | "materials": [{"color": 16777215, 201 | "reflectivity": 0.5, 202 | "type": "MeshPhongMaterial", 203 | "uuid": "e3c21698-18f8-11e8-b16e-f8b156fe4628"}], 204 | "object": {"geometry": "fbafc3d6-18f8-11e8-b16e-f8b156fe4628", 205 | "material": "e3c21698-18f8-11e8-b16e-f8b156fe4628", 206 | "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], 207 | "type": "Mesh", 208 | "uuid": "fbafc3d7-18f8-11e8-b16e-f8b156fe4628"}}, 209 | } 210 | 211 | Translating that box by the vector ``[2, 3, 4]``: 212 | 213 | :: 214 | 215 | { 216 | "type": "set_transform", 217 | "path": "/meshcat/box", 218 | "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] 219 | } 220 | 221 | Packing Arrays 222 | -------------- 223 | 224 | 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. 225 | 226 | 227 | -------------------------------------------------------------------------------- /src/meshcat/visualizer.py: -------------------------------------------------------------------------------- 1 | import webbrowser 2 | import umsgpack 3 | import numpy as np 4 | import zmq 5 | import io 6 | from PIL import Image 7 | from IPython.display import HTML 8 | 9 | 10 | from .path import Path 11 | from .commands import SetObject, SetTransform, Delete, SetProperty, SetAnimation, CaptureImage, SetCamTarget 12 | from .geometry import MeshPhongMaterial 13 | from .servers.zmqserver import start_zmq_server_as_subprocess 14 | 15 | class ViewerWindow: 16 | context = zmq.Context() 17 | 18 | def __init__(self, zmq_url, start_server, server_args): 19 | if start_server: 20 | self.server_proc, self.zmq_url, self.web_url = start_zmq_server_as_subprocess( 21 | zmq_url=zmq_url, server_args=server_args) 22 | 23 | else: 24 | self.server_proc = None 25 | self.zmq_url = zmq_url 26 | 27 | self.connect_zmq() 28 | 29 | if not start_server: 30 | self.web_url = self.request_web_url() 31 | # Not sure why this is necessary, but requesting the web URL before 32 | # the websocket connection is made seems to break the receiver 33 | # callback in the server until we reconnect. 34 | self.connect_zmq() 35 | 36 | print("You can open the visualizer by visiting the following URL:") 37 | print(self.web_url) 38 | 39 | def connect_zmq(self): 40 | self.zmq_socket = self.context.socket(zmq.REQ) 41 | self.zmq_socket.connect(self.zmq_url) 42 | 43 | def request_web_url(self): 44 | self.zmq_socket.send(b"url") 45 | response = self.zmq_socket.recv().decode("utf-8") 46 | return response 47 | 48 | def open(self): 49 | webbrowser.open(self.web_url, new=2) 50 | return self 51 | 52 | def wait(self): 53 | self.zmq_socket.send(b"wait") 54 | return self.zmq_socket.recv().decode("utf-8") 55 | 56 | def send(self, command): 57 | cmd_data = command.lower() 58 | self.zmq_socket.send_multipart([ 59 | cmd_data["type"].encode("utf-8"), 60 | cmd_data["path"].encode("utf-8"), 61 | umsgpack.packb(cmd_data) 62 | ]) 63 | self.zmq_socket.recv() 64 | 65 | def get_scene(self): 66 | """Get the static HTML from the ZMQ server.""" 67 | self.zmq_socket.send(b"get_scene") 68 | # we receive the HTML as utf-8-encoded, so decode here 69 | return self.zmq_socket.recv().decode('utf-8') 70 | 71 | def get_image(self, w, h): 72 | cmd_data = CaptureImage(w, h).lower() 73 | self.zmq_socket.send_multipart([ 74 | cmd_data["type"].encode("utf-8"), 75 | "".encode("utf-8"), 76 | umsgpack.packb(cmd_data) 77 | ]) 78 | img_bytes = self.zmq_socket.recv() 79 | img = Image.open(io.BytesIO(img_bytes)) 80 | return img 81 | 82 | 83 | def srcdoc_escape(x): 84 | return x.replace("&", "&").replace('"', """) 85 | 86 | 87 | class Visualizer: 88 | __slots__ = ["window", "path"] 89 | 90 | def __init__(self, zmq_url=None, window=None, server_args=[]): 91 | if window is None: 92 | self.window = ViewerWindow(zmq_url=zmq_url, start_server=(zmq_url is None), server_args=server_args) 93 | else: 94 | self.window = window 95 | self.path = Path(("meshcat",)) 96 | 97 | @staticmethod 98 | def view_into(window, path): 99 | vis = Visualizer(window=window) 100 | vis.path = path 101 | return vis 102 | 103 | def open(self): 104 | self.window.open() 105 | return self 106 | 107 | def url(self): 108 | return self.window.web_url 109 | 110 | def wait(self): 111 | """ 112 | Block until a browser is connected to the server 113 | """ 114 | return self.window.wait() 115 | 116 | def jupyter_cell(self, height=400): 117 | """ 118 | Render the visualizer in a jupyter notebook or jupyterlab cell. 119 | 120 | For this to work, it should be the very last command in the given jupyter 121 | cell. 122 | """ 123 | return HTML(""" 124 |
125 | 126 |
127 | """.format(url=self.url(), height=height)) 128 | 129 | def render_static(self, height=400): 130 | """ 131 | Render a static snapshot of the visualizer in a jupyter notebook or 132 | jupyterlab cell. The resulting snapshot of the visualizer will still be an 133 | interactive 3D scene, but it won't be affected by any future `set_transform` 134 | or `set_object` calls. 135 | 136 | Note: this method should work well even when your jupyter kernel is running 137 | on a different machine or inside a container. 138 | """ 139 | return HTML(""" 140 |
141 | 142 |
143 | """.format(srcdoc=srcdoc_escape(self.static_html()), height=height)) 144 | 145 | def __getitem__(self, path): 146 | return Visualizer.view_into(self.window, self.path.append(path)) 147 | 148 | def set_object(self, geometry, material=None): 149 | return self.window.send(SetObject(geometry, material, self.path)) 150 | 151 | def set_transform(self, matrix=np.eye(4)): 152 | return self.window.send(SetTransform(matrix, self.path)) 153 | 154 | def set_property(self, key, value): 155 | return self.window.send(SetProperty(key, value, self.path)) 156 | 157 | def set_animation(self, animation, play=True, repetitions=1): 158 | return self.window.send(SetAnimation(animation, play=play, repetitions=repetitions)) 159 | 160 | def set_cam_target(self, value): 161 | """Set camera target (in right-handed coordinates (x,y,z)).""" 162 | v = list(value) 163 | v[1], v[2] = v[2], -v[1] # convert to left-handed (x,z,-y) 164 | return self.window.send(SetCamTarget(v)) 165 | 166 | def set_cam_pos(self, value): 167 | """Set camera position (in right-handed coordinates (x,y,z)).""" 168 | path = "/Cameras/default/rotated/" 169 | v = list(value) 170 | v[1], v[2] = v[2], -v[1] # convert to left-handed (x,z,-y) 171 | return self[path].set_property("position", v) 172 | 173 | def get_image(self, w=None, h=None): 174 | """Save an image""" 175 | return self.window.get_image(w, h) 176 | 177 | def delete(self): 178 | return self.window.send(Delete(self.path)) 179 | 180 | def close(self): 181 | self.window.close() 182 | 183 | def static_html(self): 184 | """ 185 | Generate and save a static HTML file that standalone encompasses the visualizer and contents. 186 | 187 | Ask the server for the scene (since the server knows it), and pack it all into an 188 | HTML blob for future use. 189 | """ 190 | return self.window.get_scene() 191 | 192 | def __repr__(self): 193 | return "".format(window=self.window, path=self.path) 194 | 195 | 196 | if __name__ == '__main__': 197 | import time 198 | import sys 199 | args = [] 200 | if len(sys.argv) > 1: 201 | zmq_url = sys.argv[1] 202 | if len(sys.argv) > 2: 203 | args = sys.argv[2:] 204 | else: 205 | zmq_url = None 206 | 207 | window = ViewerWindow(zmq_url, zmq_url is None, True, args) 208 | 209 | while True: 210 | time.sleep(100) 211 | 212 | -------------------------------------------------------------------------------- /examples/animation_demo.ipynb: -------------------------------------------------------------------------------- 1 | { 2 | "cells": [ 3 | { 4 | "cell_type": "markdown", 5 | "metadata": {}, 6 | "source": [ 7 | "# MeshCat Animations\n", 8 | "\n", 9 | "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", 10 | "\n", 11 | "Let's show off building a simple animation. We first have to create our scene: " 12 | ] 13 | }, 14 | { 15 | "cell_type": "code", 16 | "execution_count": 1, 17 | "metadata": {}, 18 | "outputs": [], 19 | "source": [ 20 | "import meshcat\n", 21 | "from meshcat.geometry import Box" 22 | ] 23 | }, 24 | { 25 | "cell_type": "code", 26 | "execution_count": 2, 27 | "metadata": {}, 28 | "outputs": [ 29 | { 30 | "name": "stdout", 31 | "output_type": "stream", 32 | "text": [ 33 | "You can open the visualizer by visiting the following URL:\n", 34 | "http://127.0.0.1:7000/static/\n" 35 | ] 36 | } 37 | ], 38 | "source": [ 39 | "vis = meshcat.Visualizer()" 40 | ] 41 | }, 42 | { 43 | "cell_type": "code", 44 | "execution_count": 3, 45 | "metadata": {}, 46 | "outputs": [], 47 | "source": [ 48 | "## To open the visualizer in a new browser tab, do: \n", 49 | "# vis.open()\n", 50 | "\n", 51 | "## To open the visualizer inside this jupyter notebook, do: \n", 52 | "# vis.jupyter_cell()" 53 | ] 54 | }, 55 | { 56 | "cell_type": "code", 57 | "execution_count": 4, 58 | "metadata": {}, 59 | "outputs": [], 60 | "source": [ 61 | "vis[\"box1\"].set_object(Box([0.1, 0.2, 0.3]))" 62 | ] 63 | }, 64 | { 65 | "cell_type": "markdown", 66 | "metadata": {}, 67 | "source": [ 68 | "### Building an Animation\n", 69 | "\n", 70 | "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", 71 | "\n", 72 | "For example, let's animate moving the box from [0, 0, 0] to [0, 1, 0]: " 73 | ] 74 | }, 75 | { 76 | "cell_type": "code", 77 | "execution_count": 5, 78 | "metadata": {}, 79 | "outputs": [], 80 | "source": [ 81 | "from meshcat.animation import Animation\n", 82 | "import meshcat.transformations as tf" 83 | ] 84 | }, 85 | { 86 | "cell_type": "code", 87 | "execution_count": 6, 88 | "metadata": {}, 89 | "outputs": [], 90 | "source": [ 91 | "anim = Animation()\n", 92 | "\n", 93 | "with anim.at_frame(vis, 0) as frame:\n", 94 | " # `frame` behaves like a Visualizer, in that we can\n", 95 | " # call `set_transform` and `set_property` on it, but\n", 96 | " # it just stores information inside the animation\n", 97 | " # rather than changing the current visualization\n", 98 | " frame[\"box1\"].set_transform(tf.translation_matrix([0, 0, 0]))\n", 99 | "with anim.at_frame(vis, 30) as frame:\n", 100 | " frame[\"box1\"].set_transform(tf.translation_matrix([0, 1, 0]))\n", 101 | " \n", 102 | "# `set_animation` actually sends the animation to the\n", 103 | "# viewer. By default, the viewer will play the animation\n", 104 | "# right away. To avoid that, you can also pass `play=false`. \n", 105 | "vis.set_animation(anim)" 106 | ] 107 | }, 108 | { 109 | "cell_type": "markdown", 110 | "metadata": {}, 111 | "source": [ 112 | "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\". " 113 | ] 114 | }, 115 | { 116 | "cell_type": "markdown", 117 | "metadata": {}, 118 | "source": [ 119 | "### Animating the Camera\n", 120 | "\n", 121 | "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 `/`):" 122 | ] 123 | }, 124 | { 125 | "cell_type": "code", 126 | "execution_count": 7, 127 | "metadata": {}, 128 | "outputs": [], 129 | "source": [ 130 | "vis[\"/Cameras/default\"].set_transform(tf.translation_matrix([0, 0, 1]))" 131 | ] 132 | }, 133 | { 134 | "cell_type": "markdown", 135 | "metadata": {}, 136 | "source": [ 137 | "To animate the camera, we just have to do that same kind of `settransform!` to individual frames in an animation: " 138 | ] 139 | }, 140 | { 141 | "cell_type": "code", 142 | "execution_count": 8, 143 | "metadata": {}, 144 | "outputs": [], 145 | "source": [ 146 | "anim = Animation()\n", 147 | "\n", 148 | "with anim.at_frame(vis, 0) as frame:\n", 149 | " frame[\"/Cameras/default\"].set_transform(tf.translation_matrix([0, 0, 0]))\n", 150 | "with anim.at_frame(vis, 30) as frame:\n", 151 | " frame[\"/Cameras/default\"].set_transform(tf.translation_matrix([0, 0, 1]))\n", 152 | "\n", 153 | "# we can repeat the animation playback with the \n", 154 | "# repetitions argument:\n", 155 | "vis.set_animation(anim, repetitions=2)" 156 | ] 157 | }, 158 | { 159 | "cell_type": "markdown", 160 | "metadata": {}, 161 | "source": [ 162 | "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" 163 | ] 164 | }, 165 | { 166 | "cell_type": "code", 167 | "execution_count": 11, 168 | "metadata": {}, 169 | "outputs": [], 170 | "source": [ 171 | "anim = Animation()\n", 172 | "\n", 173 | "camera_path = \"/Cameras/default/rotated/\"\n", 174 | "\n", 175 | "with anim.at_frame(vis, 0) as frame:\n", 176 | " frame[camera_path].set_property(\"zoom\", \"number\", 1)\n", 177 | "with anim.at_frame(vis, 30) as frame:\n", 178 | " frame[camera_path].set_property(\"zoom\", \"number\", 0.5)\n", 179 | "with anim.at_frame(vis, 60) as frame:\n", 180 | " frame[camera_path].set_property(\"zoom\", \"number\", 1)\n", 181 | " \n", 182 | "# While we're animating the camera zoom, we can also animate any other\n", 183 | "# properties we want. Let's simultaneously translate the box during \n", 184 | "# the same animation:\n", 185 | "with anim.at_frame(vis, 0) as frame:\n", 186 | " frame[\"box1\"].set_transform(tf.translation_matrix([0, -1, 0]))\n", 187 | "with anim.at_frame(vis, 60) as frame:\n", 188 | " frame[\"box1\"].set_transform(tf.translation_matrix([0, 1, 0]))\n", 189 | "\n", 190 | "vis.set_animation(anim)" 191 | ] 192 | }, 193 | { 194 | "cell_type": "markdown", 195 | "metadata": {}, 196 | "source": [ 197 | "### Recording an Animation\n", 198 | "\n", 199 | "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", 200 | "\n", 201 | "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. " 202 | ] 203 | }, 204 | { 205 | "cell_type": "markdown", 206 | "metadata": {}, 207 | "source": [ 208 | "### Converting the Animation into a Video\n", 209 | "\n", 210 | "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: " 211 | ] 212 | }, 213 | { 214 | "cell_type": "code", 215 | "execution_count": 1, 216 | "metadata": {}, 217 | "outputs": [], 218 | "source": [ 219 | "from meshcat.animation import convert_frames_to_video" 220 | ] 221 | }, 222 | { 223 | "cell_type": "code", 224 | "execution_count": 3, 225 | "metadata": {}, 226 | "outputs": [ 227 | { 228 | "name": "stdout", 229 | "output_type": "stream", 230 | "text": [ 231 | "Saved output as /home/rdeits/locomotion/explorations/meshcat-distro/meshcat-python/output.mp4\n" 232 | ] 233 | } 234 | ], 235 | "source": [ 236 | "convert_frames_to_video(\"/home/rdeits/Downloads/meshcat_1528401494656.tar\", overwrite=True)" 237 | ] 238 | }, 239 | { 240 | "cell_type": "code", 241 | "execution_count": null, 242 | "metadata": {}, 243 | "outputs": [], 244 | "source": [] 245 | } 246 | ], 247 | "metadata": { 248 | "kernelspec": { 249 | "display_name": "meshcat-python", 250 | "language": "python", 251 | "name": "meshcat-python" 252 | }, 253 | "language_info": { 254 | "codemirror_mode": { 255 | "name": "ipython", 256 | "version": 3 257 | }, 258 | "file_extension": ".py", 259 | "mimetype": "text/x-python", 260 | "name": "python", 261 | "nbconvert_exporter": "python", 262 | "pygments_lexer": "ipython3", 263 | "version": "3.5.2" 264 | } 265 | }, 266 | "nbformat": 4, 267 | "nbformat_minor": 2 268 | } 269 | -------------------------------------------------------------------------------- /src/meshcat/tests/data/mesh_0_convex_piece_0.stl_ascii: -------------------------------------------------------------------------------- 1 | solid vcg 2 | facet normal 0.000000e+00 -1.000000e+00 0.000000e+00 3 | outer loop 4 | vertex -6.470900e-02 0.000000e+00 1.189590e-01 5 | vertex 6.457300e-02 0.000000e+00 -1.189590e-01 6 | vertex 6.457300e-02 0.000000e+00 1.189590e-01 7 | endloop 8 | endfacet 9 | facet normal -6.754271e-01 7.374267e-01 0.000000e+00 10 | outer loop 11 | vertex -4.582000e-02 5.142100e-02 -1.169360e-01 12 | vertex -4.312000e-02 5.389400e-02 1.165280e-01 13 | vertex -4.312000e-02 5.389400e-02 -1.165280e-01 14 | endloop 15 | endfacet 16 | facet normal 3.420193e-01 9.396929e-01 -0.000000e+00 17 | outer loop 18 | vertex 4.304500e-02 5.392100e-02 -1.166940e-01 19 | vertex 4.811000e-03 6.783700e-02 1.122140e-01 20 | vertex 4.304500e-02 5.392100e-02 1.166940e-01 21 | endloop 22 | endfacet 23 | facet normal 1.499956e-03 4.338930e-02 9.990572e-01 24 | outer loop 25 | vertex -4.582000e-02 5.142100e-02 1.169360e-01 26 | vertex -6.731300e-02 5.169000e-03 1.189770e-01 27 | vertex 4.304500e-02 5.392100e-02 1.166940e-01 28 | endloop 29 | endfacet 30 | facet normal -8.930755e-01 -4.499069e-01 0.000000e+00 31 | outer loop 32 | vertex -6.731300e-02 5.169000e-03 1.189770e-01 33 | vertex -6.470900e-02 0.000000e+00 -1.189590e-01 34 | vertex -6.470900e-02 0.000000e+00 1.189590e-01 35 | endloop 36 | endfacet 37 | facet normal 0.000000e+00 -1.000000e+00 0.000000e+00 38 | outer loop 39 | vertex -6.470900e-02 0.000000e+00 -1.189590e-01 40 | vertex 6.457300e-02 0.000000e+00 -1.189590e-01 41 | vertex -6.470900e-02 0.000000e+00 1.189590e-01 42 | endloop 43 | endfacet 44 | facet normal 9.993834e-01 3.511298e-02 0.000000e+00 45 | outer loop 46 | vertex 6.642500e-02 1.573400e-02 1.176470e-01 47 | vertex 6.688200e-02 2.727000e-03 1.184560e-01 48 | vertex 6.642500e-02 1.573400e-02 -1.176470e-01 49 | endloop 50 | endfacet 51 | facet normal 7.957511e-02 7.352903e-02 -9.941133e-01 52 | outer loop 53 | vertex 6.457300e-02 0.000000e+00 -1.189590e-01 54 | vertex 4.304500e-02 5.392100e-02 -1.166940e-01 55 | vertex 6.642500e-02 1.573400e-02 -1.176470e-01 56 | endloop 57 | endfacet 58 | facet normal 1.370198e-01 6.628661e-02 9.883479e-01 59 | outer loop 60 | vertex 6.688200e-02 2.727000e-03 1.184560e-01 61 | vertex 6.642500e-02 1.573400e-02 1.176470e-01 62 | vertex 6.457300e-02 0.000000e+00 1.189590e-01 63 | endloop 64 | endfacet 65 | facet normal 7.631737e-01 -6.461934e-01 0.000000e+00 66 | outer loop 67 | vertex 6.457300e-02 0.000000e+00 -1.189590e-01 68 | vertex 6.688200e-02 2.727000e-03 1.184560e-01 69 | vertex 6.457300e-02 0.000000e+00 1.189590e-01 70 | endloop 71 | endfacet 72 | facet normal 7.957510e-02 7.352903e-02 9.941133e-01 73 | outer loop 74 | vertex 6.642500e-02 1.573400e-02 1.176470e-01 75 | vertex 4.304500e-02 5.392100e-02 1.166940e-01 76 | vertex 6.457300e-02 0.000000e+00 1.189590e-01 77 | endloop 78 | endfacet 79 | facet normal 1.809506e-03 4.268996e-02 9.990867e-01 80 | outer loop 81 | vertex 4.304500e-02 5.392100e-02 1.166940e-01 82 | vertex -6.731300e-02 5.169000e-03 1.189770e-01 83 | vertex 6.457300e-02 0.000000e+00 1.189590e-01 84 | endloop 85 | endfacet 86 | facet normal 0.000000e+00 -3.482394e-03 9.999939e-01 87 | outer loop 88 | vertex -6.731300e-02 5.169000e-03 1.189770e-01 89 | vertex -6.470900e-02 0.000000e+00 1.189590e-01 90 | vertex 6.457300e-02 0.000000e+00 1.189590e-01 91 | endloop 92 | endfacet 93 | facet normal -8.660231e-01 5.000042e-01 0.000000e+00 94 | outer loop 95 | vertex -6.639700e-02 1.578100e-02 -1.173570e-01 96 | vertex -4.582000e-02 5.142100e-02 1.169360e-01 97 | vertex -4.582000e-02 5.142100e-02 -1.169360e-01 98 | endloop 99 | endfacet 100 | facet normal -4.819290e-03 9.999884e-01 0.000000e+00 101 | outer loop 102 | vertex 4.811000e-03 6.783700e-02 1.122140e-01 103 | vertex -4.941000e-03 6.779000e-02 -1.120530e-01 104 | vertex -4.941000e-03 6.779000e-02 1.120530e-01 105 | endloop 106 | endfacet 107 | facet normal -3.420299e-01 9.396891e-01 0.000000e+00 108 | outer loop 109 | vertex -4.941000e-03 6.779000e-02 -1.120530e-01 110 | vertex -3.730800e-02 5.600900e-02 -1.142440e-01 111 | vertex -4.941000e-03 6.779000e-02 1.120530e-01 112 | endloop 113 | endfacet 114 | facet normal -2.717889e-01 1.681123e-01 9.475595e-01 115 | outer loop 116 | vertex -6.731300e-02 5.169000e-03 1.189770e-01 117 | vertex -4.582000e-02 5.142100e-02 1.169360e-01 118 | vertex -6.639700e-02 1.578100e-02 1.173570e-01 119 | endloop 120 | endfacet 121 | facet normal -9.962953e-01 8.599798e-02 0.000000e+00 122 | outer loop 123 | vertex -6.639700e-02 1.578100e-02 -1.173570e-01 124 | vertex -6.731300e-02 5.169000e-03 1.189770e-01 125 | vertex -6.639700e-02 1.578100e-02 1.173570e-01 126 | endloop 127 | endfacet 128 | facet normal -8.660229e-01 5.000042e-01 0.000000e+00 129 | outer loop 130 | vertex -4.582000e-02 5.142100e-02 1.169360e-01 131 | vertex -6.639700e-02 1.578100e-02 -1.173570e-01 132 | vertex -6.639700e-02 1.578100e-02 1.173570e-01 133 | endloop 134 | endfacet 135 | facet normal 3.420193e-01 9.396929e-01 0.000000e+00 136 | outer loop 137 | vertex 4.811000e-03 6.783700e-02 1.122140e-01 138 | vertex 4.304500e-02 5.392100e-02 -1.166940e-01 139 | vertex 4.811000e-03 6.783700e-02 -1.122140e-01 140 | endloop 141 | endfacet 142 | facet normal -4.819291e-03 9.999884e-01 0.000000e+00 143 | outer loop 144 | vertex -4.941000e-03 6.779000e-02 -1.120530e-01 145 | vertex 4.811000e-03 6.783700e-02 1.122140e-01 146 | vertex 4.811000e-03 6.783700e-02 -1.122140e-01 147 | endloop 148 | endfacet 149 | facet normal 7.477479e-01 4.695255e-01 4.694878e-01 150 | outer loop 151 | vertex 4.304500e-02 5.392100e-02 1.166940e-01 152 | vertex 6.642500e-02 1.573400e-02 1.176470e-01 153 | vertex 4.741900e-02 4.865000e-02 1.149990e-01 154 | endloop 155 | endfacet 156 | facet normal 7.695478e-01 6.385892e-01 -0.000000e+00 157 | outer loop 158 | vertex 4.304500e-02 5.392100e-02 -1.166940e-01 159 | vertex 4.304500e-02 5.392100e-02 1.166940e-01 160 | vertex 4.741900e-02 4.865000e-02 1.149990e-01 161 | endloop 162 | endfacet 163 | facet normal 8.660033e-01 5.000383e-01 0.000000e+00 164 | outer loop 165 | vertex 6.642500e-02 1.573400e-02 1.176470e-01 166 | vertex 6.642500e-02 1.573400e-02 -1.176470e-01 167 | vertex 4.741900e-02 4.865000e-02 1.149990e-01 168 | endloop 169 | endfacet 170 | facet normal -1.931394e-03 3.016308e-01 9.534228e-01 171 | outer loop 172 | vertex 4.304500e-02 5.392100e-02 1.166940e-01 173 | vertex 4.811000e-03 6.783700e-02 1.122140e-01 174 | vertex -4.312000e-02 5.389400e-02 1.165280e-01 175 | endloop 176 | endfacet 177 | facet normal -1.951906e-03 1.648559e-01 9.863157e-01 178 | outer loop 179 | vertex -4.582000e-02 5.142100e-02 1.169360e-01 180 | vertex 4.304500e-02 5.392100e-02 1.166940e-01 181 | vertex -4.312000e-02 5.389400e-02 1.165280e-01 182 | endloop 183 | endfacet 184 | facet normal -6.754271e-01 7.374268e-01 0.000000e+00 185 | outer loop 186 | vertex -4.582000e-02 5.142100e-02 -1.169360e-01 187 | vertex -4.582000e-02 5.142100e-02 1.169360e-01 188 | vertex -4.312000e-02 5.389400e-02 1.165280e-01 189 | endloop 190 | endfacet 191 | facet normal -1.715086e-02 3.488726e-01 9.370132e-01 192 | outer loop 193 | vertex 4.811000e-03 6.783700e-02 1.122140e-01 194 | vertex -4.941000e-03 6.779000e-02 1.120530e-01 195 | vertex -4.312000e-02 5.389400e-02 1.165280e-01 196 | endloop 197 | endfacet 198 | facet normal -3.420200e-01 9.396927e-01 -1.606431e-06 199 | outer loop 200 | vertex -4.941000e-03 6.779000e-02 1.120530e-01 201 | vertex -3.730800e-02 5.600900e-02 -1.142440e-01 202 | vertex -4.312000e-02 5.389400e-02 1.165280e-01 203 | endloop 204 | endfacet 205 | facet normal 7.477477e-01 4.695254e-01 -4.694884e-01 206 | outer loop 207 | vertex 6.642500e-02 1.573400e-02 -1.176470e-01 208 | vertex 4.304500e-02 5.392100e-02 -1.166940e-01 209 | vertex 4.741900e-02 4.865000e-02 -1.149990e-01 210 | endloop 211 | endfacet 212 | facet normal 7.695478e-01 6.385892e-01 0.000000e+00 213 | outer loop 214 | vertex 4.304500e-02 5.392100e-02 -1.166940e-01 215 | vertex 4.741900e-02 4.865000e-02 1.149990e-01 216 | vertex 4.741900e-02 4.865000e-02 -1.149990e-01 217 | endloop 218 | endfacet 219 | facet normal 8.660033e-01 5.000383e-01 0.000000e+00 220 | outer loop 221 | vertex 4.741900e-02 4.865000e-02 1.149990e-01 222 | vertex 6.642500e-02 1.573400e-02 -1.176470e-01 223 | vertex 4.741900e-02 4.865000e-02 -1.149990e-01 224 | endloop 225 | endfacet 226 | facet normal -8.930755e-01 -4.499069e-01 0.000000e+00 227 | outer loop 228 | vertex -6.470900e-02 0.000000e+00 -1.189590e-01 229 | vertex -6.731300e-02 5.169000e-03 1.189770e-01 230 | vertex -6.731300e-02 5.169000e-03 -1.189770e-01 231 | endloop 232 | endfacet 233 | facet normal -0.000000e+00 -3.482394e-03 -9.999939e-01 234 | outer loop 235 | vertex 6.457300e-02 0.000000e+00 -1.189590e-01 236 | vertex -6.470900e-02 0.000000e+00 -1.189590e-01 237 | vertex -6.731300e-02 5.169000e-03 -1.189770e-01 238 | endloop 239 | endfacet 240 | facet normal 1.809506e-03 4.268996e-02 -9.990867e-01 241 | outer loop 242 | vertex 4.304500e-02 5.392100e-02 -1.166940e-01 243 | vertex 6.457300e-02 0.000000e+00 -1.189590e-01 244 | vertex -6.731300e-02 5.169000e-03 -1.189770e-01 245 | endloop 246 | endfacet 247 | facet normal -9.962953e-01 8.599799e-02 0.000000e+00 248 | outer loop 249 | vertex -6.731300e-02 5.169000e-03 1.189770e-01 250 | vertex -6.639700e-02 1.578100e-02 -1.173570e-01 251 | vertex -6.731300e-02 5.169000e-03 -1.189770e-01 252 | endloop 253 | endfacet 254 | facet normal 1.499956e-03 4.338930e-02 -9.990572e-01 255 | outer loop 256 | vertex -4.582000e-02 5.142100e-02 -1.169360e-01 257 | vertex 4.304500e-02 5.392100e-02 -1.166940e-01 258 | vertex -6.731300e-02 5.169000e-03 -1.189770e-01 259 | endloop 260 | endfacet 261 | facet normal -2.717889e-01 1.681123e-01 -9.475595e-01 262 | outer loop 263 | vertex -6.639700e-02 1.578100e-02 -1.173570e-01 264 | vertex -4.582000e-02 5.142100e-02 -1.169360e-01 265 | vertex -6.731300e-02 5.169000e-03 -1.189770e-01 266 | endloop 267 | endfacet 268 | facet normal 7.631736e-01 -6.461934e-01 0.000000e+00 269 | outer loop 270 | vertex 6.688200e-02 2.727000e-03 1.184560e-01 271 | vertex 6.457300e-02 0.000000e+00 -1.189590e-01 272 | vertex 6.688200e-02 2.727000e-03 -1.184560e-01 273 | endloop 274 | endfacet 275 | facet normal 9.993834e-01 3.511297e-02 0.000000e+00 276 | outer loop 277 | vertex 6.642500e-02 1.573400e-02 -1.176470e-01 278 | vertex 6.688200e-02 2.727000e-03 1.184560e-01 279 | vertex 6.688200e-02 2.727000e-03 -1.184560e-01 280 | endloop 281 | endfacet 282 | facet normal 1.370198e-01 6.628661e-02 -9.883479e-01 283 | outer loop 284 | vertex 6.457300e-02 0.000000e+00 -1.189590e-01 285 | vertex 6.642500e-02 1.573400e-02 -1.176470e-01 286 | vertex 6.688200e-02 2.727000e-03 -1.184560e-01 287 | endloop 288 | endfacet 289 | facet normal -3.420436e-01 9.396840e-01 2.302793e-04 290 | outer loop 291 | vertex -3.730800e-02 5.600900e-02 -1.142440e-01 292 | vertex -4.941000e-03 6.779000e-02 -1.120530e-01 293 | vertex -4.312000e-02 5.389400e-02 -1.165280e-01 294 | endloop 295 | endfacet 296 | facet normal -1.951906e-03 1.648560e-01 -9.863157e-01 297 | outer loop 298 | vertex 4.304500e-02 5.392100e-02 -1.166940e-01 299 | vertex -4.582000e-02 5.142100e-02 -1.169360e-01 300 | vertex -4.312000e-02 5.389400e-02 -1.165280e-01 301 | endloop 302 | endfacet 303 | facet normal -1.931394e-03 3.016307e-01 -9.534229e-01 304 | outer loop 305 | vertex 4.811000e-03 6.783700e-02 -1.122140e-01 306 | vertex 4.304500e-02 5.392100e-02 -1.166940e-01 307 | vertex -4.312000e-02 5.389400e-02 -1.165280e-01 308 | endloop 309 | endfacet 310 | facet normal -1.715086e-02 3.488726e-01 -9.370132e-01 311 | outer loop 312 | vertex -4.941000e-03 6.779000e-02 -1.120530e-01 313 | vertex 4.811000e-03 6.783700e-02 -1.122140e-01 314 | vertex -4.312000e-02 5.389400e-02 -1.165280e-01 315 | endloop 316 | endfacet 317 | facet normal -3.419637e-01 9.397132e-01 0.000000e+00 318 | outer loop 319 | vertex -4.312000e-02 5.389400e-02 1.165280e-01 320 | vertex -3.730800e-02 5.600900e-02 -1.142440e-01 321 | vertex -4.312000e-02 5.389400e-02 -1.165280e-01 322 | endloop 323 | endfacet 324 | endsolid vcg 325 | -------------------------------------------------------------------------------- /src/meshcat/tests/test_drawing.py: -------------------------------------------------------------------------------- 1 | import unittest 2 | import subprocess 3 | import sys 4 | import tempfile 5 | import os 6 | 7 | from io import StringIO, BytesIO 8 | 9 | import io 10 | 11 | import numpy as np 12 | 13 | import meshcat 14 | import meshcat.geometry as g 15 | import meshcat.transformations as tf 16 | 17 | 18 | class VisualizerTest(unittest.TestCase): 19 | def setUp(self): 20 | self.vis = meshcat.Visualizer() 21 | 22 | if "CI" in os.environ: 23 | port = self.vis.url().split(":")[-1].split("/")[0] 24 | self.dummy_proc = subprocess.Popen([sys.executable, "-m", "meshcat.tests.dummy_websocket_client", str(port)]) 25 | else: 26 | self.vis.open() 27 | self.dummy_proc = None 28 | 29 | self.vis.wait() 30 | 31 | def tearDown(self): 32 | if self.dummy_proc is not None: 33 | self.dummy_proc.kill() 34 | 35 | 36 | class TestDrawing(VisualizerTest): 37 | def runTest(self): 38 | self.vis.delete() 39 | v = self.vis["shapes"] 40 | v.set_transform(tf.translation_matrix([1., 0, 0])) 41 | v["box"].set_object(g.Box([1.0, 0.2, 0.3])) 42 | v["box"].delete() 43 | v["box"].set_object(g.Box([0.1, 0.2, 0.3])) 44 | v["box"].set_transform(tf.translation_matrix([0.05, 0.1, 0.15])) 45 | v["cylinder"].set_object(g.Cylinder(0.2, 0.1), g.MeshLambertMaterial(color=0x22dd22)) 46 | v["cylinder"].set_transform(tf.translation_matrix([0, 0.5, 0.1]).dot(tf.rotation_matrix(-np.pi / 2, [1, 0, 0]))) 47 | v["sphere"].set_object(g.Mesh(g.Sphere(0.15), g.MeshLambertMaterial(color=0xff11dd))) 48 | v["sphere"].set_transform(tf.translation_matrix([0, 1, 0.15])) 49 | v["ellipsoid"].set_object(g.Ellipsoid([0.3, 0.1, 0.1])) 50 | v["ellipsoid"].set_transform(tf.translation_matrix([0, 1.5, 0.1])) 51 | 52 | v["transparent_ellipsoid"].set_object(g.Mesh( 53 | g.Ellipsoid([0.3, 0.1, 0.1]), 54 | g.MeshLambertMaterial(color=0xffffff, 55 | opacity=0.5))) 56 | v["transparent_ellipsoid"].set_transform(tf.translation_matrix([0, 2.0, 0.1])) 57 | 58 | v = self.vis["meshes/valkyrie/head"] 59 | v.set_object(g.Mesh( 60 | g.ObjMeshGeometry.from_file(os.path.join(meshcat.viewer_assets_path(), "data/head_multisense.obj")), 61 | g.MeshLambertMaterial( 62 | map=g.ImageTexture( 63 | image=g.PngImage.from_file(os.path.join(meshcat.viewer_assets_path(), "data/HeadTextureMultisense.png")) 64 | ) 65 | ) 66 | )) 67 | v.set_transform(tf.translation_matrix([0, 0.5, 0.5])) 68 | 69 | v = self.vis["meshes/convex"] 70 | 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")))) 71 | 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")))) 72 | v["stl_ascii"].set_transform(tf.translation_matrix([0, -0.5, 0])) 73 | 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")))) 74 | v["stl_binary"].set_transform(tf.translation_matrix([0, -1, 0])) 75 | 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")))) 76 | v["dae"].set_transform(tf.translation_matrix([0, -1.5, 0])) 77 | 78 | 79 | v = self.vis["points"] 80 | v.set_transform(tf.translation_matrix([0, 2, 0])) 81 | verts = np.random.rand(3, 1000000) 82 | colors = verts 83 | v["random"].set_object(g.PointCloud(verts, colors)) 84 | v["random"].set_transform(tf.translation_matrix([-0.5, -0.5, 0])) 85 | 86 | v = self.vis["lines"] 87 | v.set_transform(tf.translation_matrix(([-2, -3, 0]))) 88 | 89 | vertices = np.random.random((3, 10)).astype(np.float32) 90 | v["line_segments"].set_object(g.LineSegments(g.PointsGeometry(vertices))) 91 | 92 | v["line"].set_object(g.Line(g.PointsGeometry(vertices))) 93 | v["line"].set_transform(tf.translation_matrix([0, 1, 0])) 94 | 95 | v["line_loop"].set_object(g.LineLoop(g.PointsGeometry(vertices))) 96 | v["line_loop"].set_transform(tf.translation_matrix([0, 2, 0])) 97 | 98 | v["line_loop_with_material"].set_object(g.LineLoop(g.PointsGeometry(vertices), g.LineBasicMaterial(color=0xff0000))) 99 | v["line_loop_with_material"].set_transform(tf.translation_matrix([0, 3, 0])) 100 | 101 | colors = vertices # Color each line by treating its xyz coordinates as RGB colors 102 | v["line_with_vertex_colors"].set_object(g.Line(g.PointsGeometry(vertices, colors), g.LineBasicMaterial(vertexColors=True))) 103 | v["line_with_vertex_colors"].set_transform(tf.translation_matrix([0, 4, 0])) 104 | 105 | v["triad"].set_object(g.LineSegments( 106 | g.PointsGeometry(position=np.array([ 107 | [0, 0, 0], [1, 0, 0], 108 | [0, 0, 0], [0, 1, 0], 109 | [0, 0, 0], [0, 0, 1]]).astype(np.float32).T, 110 | color=np.array([ 111 | [1, 0, 0], [1, 0.6, 0], 112 | [0, 1, 0], [0.6, 1, 0], 113 | [0, 0, 1], [0, 0.6, 1]]).astype(np.float32).T 114 | ), 115 | g.LineBasicMaterial(vertexColors=True))) 116 | v["triad"].set_transform(tf.translation_matrix(([0, 5, 0]))) 117 | 118 | v["triad_function"].set_object(g.triad(0.5)) 119 | v["triad_function"].set_transform(tf.translation_matrix([0, 6, 0])) 120 | 121 | 122 | class TestMeshStreams(VisualizerTest): 123 | def runTest(self): 124 | """ Applications using meshcat may already have meshes loaded in memory. It is 125 | more efficient to load these meshes with streams rather than going to and then 126 | from a file on disk. To test this we are importing meshes from disk and 127 | converting them into streams so it kind of defeats the intended purpose! But at 128 | least it tests the functionality. 129 | """ 130 | self.vis.delete() 131 | v = self.vis["meshes/convex"] 132 | 133 | # Obj file 134 | filename = os.path.join(meshcat.viewer_assets_path(), 135 | "../tests/data/mesh_0_convex_piece_0.obj") 136 | with open(filename, "r") as f: 137 | fio = StringIO(f.read()) 138 | v["stream_obj"].set_object(g.Mesh(g.ObjMeshGeometry.from_stream(fio))) 139 | v["stream_stl_ascii"].set_transform(tf.translation_matrix([0, 0.0, 0])) 140 | 141 | # STL ASCII 142 | filename = os.path.join(meshcat.viewer_assets_path(), 143 | "../tests/data/mesh_0_convex_piece_0.stl_ascii") 144 | with open(filename, "r") as f: 145 | fio = StringIO(f.read()) 146 | v["stream_stl_ascii"].set_object(g.Mesh(g.StlMeshGeometry.from_stream(fio))) 147 | v["stream_stl_ascii"].set_transform(tf.translation_matrix([0, -0.5, 0])) 148 | 149 | # STL Binary 150 | filename = os.path.join(meshcat.viewer_assets_path(), 151 | "../tests/data/mesh_0_convex_piece_0.stl_binary") 152 | with open(filename, "rb") as f: 153 | fio = BytesIO(f.read()) 154 | v["stream_stl_binary"].set_object(g.Mesh(g.StlMeshGeometry.from_stream(fio))) 155 | v["stream_stl_binary"].set_transform(tf.translation_matrix([0, -1.0, 0])) 156 | 157 | # DAE 158 | filename = os.path.join(meshcat.viewer_assets_path(), 159 | "../tests/data/mesh_0_convex_piece_0.dae") 160 | with open(filename, "r") as f: 161 | fio = StringIO(f.read()) 162 | v["stream_dae"].set_object(g.Mesh(g.DaeMeshGeometry.from_stream(fio))) 163 | v["stream_dae"].set_transform(tf.translation_matrix([0, -1.5, 0])) 164 | 165 | 166 | class TestStandaloneServer(unittest.TestCase): 167 | def setUp(self): 168 | self.zmq_url = "tcp://127.0.0.1:5560" 169 | args = ["meshcat-server", "--zmq-url", self.zmq_url] 170 | 171 | if "CI" not in os.environ: 172 | args.append("--open") 173 | 174 | self.server_proc = subprocess.Popen(args) 175 | self.vis = meshcat.Visualizer(self.zmq_url) 176 | # self.vis = meshcat.Visualizer() 177 | # self.vis.open() 178 | 179 | if "CI" in os.environ: 180 | port = self.vis.url().split(":")[-1].split("/")[0] 181 | self.dummy_proc = subprocess.Popen([sys.executable, "-m", "meshcat.tests.dummy_websocket_client", str(port)]) 182 | else: 183 | # self.vis.open() 184 | self.dummy_proc = None 185 | 186 | self.vis.wait() 187 | 188 | def runTest(self): 189 | v = self.vis["shapes"] 190 | v["cube"].set_object(g.Box([0.1, 0.2, 0.3])) 191 | v.set_transform(tf.translation_matrix([1., 0, 0])) 192 | v.set_transform(tf.translation_matrix([1., 1., 0])) 193 | 194 | def tearDown(self): 195 | if self.dummy_proc is not None: 196 | self.dummy_proc.kill() 197 | self.server_proc.kill() 198 | 199 | 200 | class TestAnimation(VisualizerTest): 201 | def runTest(self): 202 | v = self.vis["shapes"] 203 | v.set_transform(tf.translation_matrix([1., 0, 0])) 204 | v["cube"].set_object(g.Box([0.1, 0.2, 0.3])) 205 | 206 | animation = meshcat.animation.Animation() 207 | with animation.at_frame(v, 0) as frame_vis: 208 | frame_vis.set_transform(tf.translation_matrix([0, 0, 0])) 209 | with animation.at_frame(v, 30) as frame_vis: 210 | frame_vis.set_transform(tf.translation_matrix([2, 0, 0]).dot(tf.rotation_matrix(np.pi/2, [0, 0, 1]))) 211 | v.set_animation(animation) 212 | 213 | 214 | class TestCameraAnimation(VisualizerTest): 215 | def runTest(self): 216 | v = self.vis["shapes"] 217 | v.set_transform(tf.translation_matrix([1., 0, 0])) 218 | v["cube"].set_object(g.Box([0.1, 0.2, 0.3])) 219 | 220 | animation = meshcat.animation.Animation() 221 | with animation.at_frame(v, 0) as frame_vis: 222 | frame_vis.set_transform(tf.translation_matrix([0, 0, 0])) 223 | with animation.at_frame(v, 30) as frame_vis: 224 | frame_vis.set_transform(tf.translation_matrix([2, 0, 0]).dot(tf.rotation_matrix(np.pi/2, [0, 0, 1]))) 225 | with animation.at_frame(v, 0) as frame_vis: 226 | frame_vis["/Cameras/default/rotated/"].set_property("zoom", "number", 1) 227 | with animation.at_frame(v, 30) as frame_vis: 228 | frame_vis["/Cameras/default/rotated/"].set_property("zoom", "number", 0.5) 229 | v.set_animation(animation) 230 | 231 | 232 | class TestStaticHTML(TestDrawing): 233 | def runTest(self): 234 | """Test that we can generate a static HTML file from the Drawing test case and view it.""" 235 | super(TestStaticHTML, self).runTest() 236 | res = self.vis.static_html() 237 | # save to a file 238 | temp = tempfile.mkstemp(suffix=".html") 239 | with open(temp[1], "w") as f: 240 | f.write(res) 241 | 242 | 243 | class TestSetProperty(VisualizerTest): 244 | def runTest(self): 245 | self.vis["/Background"].set_property("top_color", [1, 0, 0]) 246 | 247 | 248 | class TestTriangularMesh(VisualizerTest): 249 | def runTest(self): 250 | """ 251 | Test that we can render meshes from raw vertices and faces as 252 | numpy arrays 253 | """ 254 | v = self.vis["triangular_mesh"] 255 | v.set_transform(tf.rotation_matrix(np.pi/2, [0., 0, 1])) 256 | vertices = np.array([ 257 | [0, 0, 0], 258 | [1, 0, 0], 259 | [1, 0, 1], 260 | [0, 0, 1] 261 | ]) 262 | faces = np.array([ 263 | [0, 1, 2], 264 | [3, 0, 2] 265 | ]) 266 | v.set_object(g.TriangularMeshGeometry(vertices, faces), g.MeshLambertMaterial(color=0xeedd22, wireframe=True)) 267 | 268 | v = self.vis["triangular_mesh_w_vertex_coloring"] 269 | v.set_transform(tf.translation_matrix([1, 0, 0]).dot(tf.rotation_matrix(np.pi/2, [0, 0, 1]))) 270 | colors = vertices 271 | v.set_object(g.TriangularMeshGeometry(vertices, faces, colors), g.MeshLambertMaterial(vertexColors=True, wireframe=True)) 272 | 273 | 274 | class TestOrthographicCamera(VisualizerTest): 275 | def runTest(self): 276 | """ 277 | Test that we can set_object with an OrthographicCamera. 278 | """ 279 | self.vis.set_object(g.Box([0.5, 0.5, 0.5])) 280 | 281 | camera = g.OrthographicCamera( 282 | left=-1, right=1, bottom=-1, top=1, near=-1000, far=1000) 283 | self.vis['/Cameras/default/rotated'].set_object(camera) 284 | self.vis['/Cameras/default'].set_transform( 285 | tf.translation_matrix([0, -1, 0])) 286 | self.vis['/Cameras/default/rotated/'].set_property( 287 | "position", [0, 0, 0]) 288 | self.vis['/Grid'].set_property("visible", False) 289 | 290 | class TestPerspectiveCamera(VisualizerTest): 291 | def runTest(self): 292 | """ 293 | Test that we can set_object with a PerspectiveCamera. 294 | """ 295 | self.vis.set_object(g.Box([0.5, 0.5, 0.5])) 296 | 297 | camera = g.PerspectiveCamera(fov=90) 298 | self.vis['/Cameras/default/rotated'].set_object(camera) 299 | self.vis['/Cameras/default'].set_transform( 300 | tf.translation_matrix([1, -1, 0.5])) 301 | self.vis['/Cameras/default/rotated/'].set_property( 302 | "position", [0, 0, 0]) 303 | -------------------------------------------------------------------------------- /examples/demo.ipynb: -------------------------------------------------------------------------------- 1 | { 2 | "cells": [ 3 | { 4 | "cell_type": "markdown", 5 | "metadata": {}, 6 | "source": [ 7 | "# MeshCat Python" 8 | ] 9 | }, 10 | { 11 | "cell_type": "code", 12 | "execution_count": null, 13 | "metadata": {}, 14 | "outputs": [], 15 | "source": [ 16 | "import numpy as np\n", 17 | "import os\n", 18 | "import time\n", 19 | "\n", 20 | "import meshcat\n", 21 | "import meshcat.geometry as g\n", 22 | "import meshcat.transformations as tf" 23 | ] 24 | }, 25 | { 26 | "cell_type": "code", 27 | "execution_count": null, 28 | "metadata": {}, 29 | "outputs": [], 30 | "source": [ 31 | "# Create a new visualizer\n", 32 | "vis = meshcat.Visualizer()" 33 | ] 34 | }, 35 | { 36 | "cell_type": "markdown", 37 | "metadata": {}, 38 | "source": [ 39 | "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:" 40 | ] 41 | }, 42 | { 43 | "cell_type": "code", 44 | "execution_count": null, 45 | "metadata": {}, 46 | "outputs": [], 47 | "source": [ 48 | "vis.open()" 49 | ] 50 | }, 51 | { 52 | "cell_type": "markdown", 53 | "metadata": {}, 54 | "source": [ 55 | "If ``vis.open()`` does not work for you, you can also point your browser to the server's URL:" 56 | ] 57 | }, 58 | { 59 | "cell_type": "code", 60 | "execution_count": null, 61 | "metadata": {}, 62 | "outputs": [], 63 | "source": [ 64 | "vis.url()" 65 | ] 66 | }, 67 | { 68 | "cell_type": "markdown", 69 | "metadata": {}, 70 | "source": [ 71 | "To create a 3D object, we use the `set_object` method:" 72 | ] 73 | }, 74 | { 75 | "cell_type": "code", 76 | "execution_count": null, 77 | "metadata": {}, 78 | "outputs": [], 79 | "source": [ 80 | "vis.set_object(g.Box([0.2, 0.2, 0.2]))" 81 | ] 82 | }, 83 | { 84 | "cell_type": "markdown", 85 | "metadata": {}, 86 | "source": [ 87 | "And to move that object around, we use `set_transform`:" 88 | ] 89 | }, 90 | { 91 | "cell_type": "code", 92 | "execution_count": null, 93 | "metadata": {}, 94 | "outputs": [], 95 | "source": [ 96 | "for theta in np.linspace(0, 2 * np.pi, 200):\n", 97 | " vis.set_transform(tf.rotation_matrix(theta, [0, 0, 1]))\n", 98 | " time.sleep(0.005)" 99 | ] 100 | }, 101 | { 102 | "cell_type": "markdown", 103 | "metadata": {}, 104 | "source": [ 105 | "MeshCat also supports embedding a 3D view inside a Jupyter notebook cell:" 106 | ] 107 | }, 108 | { 109 | "cell_type": "code", 110 | "execution_count": null, 111 | "metadata": {}, 112 | "outputs": [], 113 | "source": [ 114 | "vis.jupyter_cell()" 115 | ] 116 | }, 117 | { 118 | "cell_type": "markdown", 119 | "metadata": {}, 120 | "source": [ 121 | "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. " 122 | ] 123 | }, 124 | { 125 | "cell_type": "markdown", 126 | "metadata": {}, 127 | "source": [ 128 | "Calling `set_object` again will replace the existing Box:" 129 | ] 130 | }, 131 | { 132 | "cell_type": "code", 133 | "execution_count": null, 134 | "metadata": {}, 135 | "outputs": [], 136 | "source": [ 137 | "vis.set_object(g.Box([0.1, 0.1, 0.2]))" 138 | ] 139 | }, 140 | { 141 | "cell_type": "markdown", 142 | "metadata": {}, 143 | "source": [ 144 | "We can also delete the box:" 145 | ] 146 | }, 147 | { 148 | "cell_type": "code", 149 | "execution_count": null, 150 | "metadata": {}, 151 | "outputs": [], 152 | "source": [ 153 | "vis.delete()" 154 | ] 155 | }, 156 | { 157 | "cell_type": "markdown", 158 | "metadata": {}, 159 | "source": [ 160 | "MeshCat supports simple 2d texts rendering. For example, to write 2d texts onto a geometry:" 161 | ] 162 | }, 163 | { 164 | "cell_type": "code", 165 | "execution_count": null, 166 | "metadata": {}, 167 | "outputs": [], 168 | "source": [ 169 | "vis.set_object(g.Box([1, 1, 2]),g.MeshPhongMaterial(map=g.TextTexture('Hello, world!')))" 170 | ] 171 | }, 172 | { 173 | "cell_type": "markdown", 174 | "metadata": {}, 175 | "source": [ 176 | "It is also possible to simple write 'floating' texts onto a scene without attaching it to an object (e.g., for scene description):" 177 | ] 178 | }, 179 | { 180 | "cell_type": "code", 181 | "execution_count": null, 182 | "metadata": {}, 183 | "outputs": [], 184 | "source": [ 185 | "vis.delete()\n", 186 | "vis.set_object(g.SceneText('Hello, world!',font_size=100))" 187 | ] 188 | }, 189 | { 190 | "cell_type": "markdown", 191 | "metadata": {}, 192 | "source": [ 193 | "and just like the usual geometry/object, the scene texts can be rotated:" 194 | ] 195 | }, 196 | { 197 | "cell_type": "code", 198 | "execution_count": null, 199 | "metadata": {}, 200 | "outputs": [], 201 | "source": [ 202 | "Rz = tf.rotation_matrix(np.pi/2, [0, 0, 1])\n", 203 | "Ry = tf.rotation_matrix(np.pi/2, [0, 1, 0])\n", 204 | "vis.set_transform(Ry.dot(Rz))" 205 | ] 206 | }, 207 | { 208 | "cell_type": "markdown", 209 | "metadata": {}, 210 | "source": [ 211 | "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." 212 | ] 213 | }, 214 | { 215 | "cell_type": "code", 216 | "execution_count": null, 217 | "metadata": {}, 218 | "outputs": [], 219 | "source": [ 220 | "for i in np.linspace(8,2,10):\n", 221 | " vis.set_object(g.SceneText('Hello, world!',width=2*i,height=2*i,font_size=300))\n", 222 | " time.sleep(0.05)" 223 | ] 224 | }, 225 | { 226 | "cell_type": "markdown", 227 | "metadata": {}, 228 | "source": [ 229 | "## The Scene Tree\n", 230 | "\n", 231 | "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", 232 | "\n", 233 | "First, let's create the robot. We access paths in the tree by indexing into the Visualizer:" 234 | ] 235 | }, 236 | { 237 | "cell_type": "code", 238 | "execution_count": null, 239 | "metadata": {}, 240 | "outputs": [], 241 | "source": [ 242 | "vis[\"robot\"].set_object(g.Box([0.15, 0.35, 0.4]))" 243 | ] 244 | }, 245 | { 246 | "cell_type": "markdown", 247 | "metadata": {}, 248 | "source": [ 249 | "Now let's give the robot a head:" 250 | ] 251 | }, 252 | { 253 | "cell_type": "code", 254 | "execution_count": null, 255 | "metadata": {}, 256 | "outputs": [], 257 | "source": [ 258 | "vis[\"robot\"][\"head\"].set_object(g.Box([0.2, 0.2, 0.2]))\n", 259 | "vis[\"robot\"][\"head\"].set_transform(tf.translation_matrix([0, 0, 0.32]))" 260 | ] 261 | }, 262 | { 263 | "cell_type": "markdown", 264 | "metadata": {}, 265 | "source": [ 266 | "We can move the entire robot by setting the transform of the `/robot` path:" 267 | ] 268 | }, 269 | { 270 | "cell_type": "code", 271 | "execution_count": null, 272 | "metadata": {}, 273 | "outputs": [], 274 | "source": [ 275 | "for x in np.linspace(0, np.pi, 100):\n", 276 | " vis[\"robot\"].set_transform(tf.translation_matrix([np.sin(x), 0, 0]))\n", 277 | " time.sleep(0.01)" 278 | ] 279 | }, 280 | { 281 | "cell_type": "markdown", 282 | "metadata": {}, 283 | "source": [ 284 | "And we can move just the head by setting the transform of `/robot/head`:" 285 | ] 286 | }, 287 | { 288 | "cell_type": "code", 289 | "execution_count": null, 290 | "metadata": {}, 291 | "outputs": [], 292 | "source": [ 293 | "for x in np.linspace(0, 2 * np.pi, 100):\n", 294 | " # vis[\"robot/head\"] is a shorthand for vis[\"robot\"][\"head\"]\n", 295 | " vis[\"robot/head\"].set_transform(\n", 296 | " tf.translation_matrix([0, 0, 0.32]).dot(\n", 297 | " tf.rotation_matrix(x, [0, 0, 1])))\n", 298 | " time.sleep(0.01)" 299 | ] 300 | }, 301 | { 302 | "cell_type": "markdown", 303 | "metadata": {}, 304 | "source": [ 305 | "We can delete the head..." 306 | ] 307 | }, 308 | { 309 | "cell_type": "code", 310 | "execution_count": null, 311 | "metadata": {}, 312 | "outputs": [], 313 | "source": [ 314 | "vis[\"robot/head\"].delete()" 315 | ] 316 | }, 317 | { 318 | "cell_type": "markdown", 319 | "metadata": {}, 320 | "source": [ 321 | "...or the entire robot:" 322 | ] 323 | }, 324 | { 325 | "cell_type": "code", 326 | "execution_count": null, 327 | "metadata": {}, 328 | "outputs": [], 329 | "source": [ 330 | "vis[\"robot\"].delete()" 331 | ] 332 | }, 333 | { 334 | "cell_type": "markdown", 335 | "metadata": {}, 336 | "source": [ 337 | "## Other Geometries\n", 338 | "\n", 339 | "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:" 340 | ] 341 | }, 342 | { 343 | "cell_type": "code", 344 | "execution_count": null, 345 | "metadata": {}, 346 | "outputs": [], 347 | "source": [ 348 | "vis[\"sphere\"].set_object(g.Sphere(0.1), \n", 349 | " g.MeshLambertMaterial(\n", 350 | " color=0xff22dd,\n", 351 | " reflectivity=0.8))" 352 | ] 353 | }, 354 | { 355 | "cell_type": "code", 356 | "execution_count": null, 357 | "metadata": {}, 358 | "outputs": [], 359 | "source": [ 360 | "vis[\"sphere\"].delete()" 361 | ] 362 | }, 363 | { 364 | "cell_type": "markdown", 365 | "metadata": {}, 366 | "source": [ 367 | "MeshCat can load `.obj`, `.dae`, and `.stl` meshes via the `ObjMeshGeometry`, `DaeMeshGeometry`, and `StlMeshGeometry` types respectively:" 368 | ] 369 | }, 370 | { 371 | "cell_type": "code", 372 | "execution_count": null, 373 | "metadata": {}, 374 | "outputs": [], 375 | "source": [ 376 | "vis[\"robots/valkyrie/head\"].set_object(\n", 377 | " g.ObjMeshGeometry.from_file(\n", 378 | " os.path.join(meshcat.viewer_assets_path(), \"data/head_multisense.obj\")),\n", 379 | " g.MeshLambertMaterial(\n", 380 | " map=g.ImageTexture(\n", 381 | " image=g.PngImage.from_file(\n", 382 | " os.path.join(meshcat.viewer_assets_path(), \"data/HeadTextureMultisense.png\"))\n", 383 | " )\n", 384 | " )\n", 385 | ")" 386 | ] 387 | }, 388 | { 389 | "cell_type": "markdown", 390 | "metadata": {}, 391 | "source": [ 392 | "The `PointCloud()` function is a helper to create a `Points` object with a `PointsGeometry` and `PointsMaterial`:" 393 | ] 394 | }, 395 | { 396 | "cell_type": "code", 397 | "execution_count": null, 398 | "metadata": {}, 399 | "outputs": [], 400 | "source": [ 401 | "verts = np.random.rand(3, 100000)\n", 402 | "vis[\"perception/pointclouds/random\"].set_object(\n", 403 | " g.PointCloud(position=verts, color=verts))\n", 404 | "vis[\"perception/pointclouds/random\"].set_transform(\n", 405 | " tf.translation_matrix([0, 1, 0]))" 406 | ] 407 | }, 408 | { 409 | "cell_type": "code", 410 | "execution_count": null, 411 | "metadata": {}, 412 | "outputs": [], 413 | "source": [ 414 | "vis[\"robots\"].delete()\n", 415 | "vis[\"perception\"].delete()" 416 | ] 417 | }, 418 | { 419 | "cell_type": "markdown", 420 | "metadata": { 421 | "tags": [] 422 | }, 423 | "source": [ 424 | "## Cart-Pole\n", 425 | "\n", 426 | "Here's a simple example of visualizing a mechanism:" 427 | ] 428 | }, 429 | { 430 | "cell_type": "code", 431 | "execution_count": null, 432 | "metadata": {}, 433 | "outputs": [], 434 | "source": [ 435 | "cart_pole = vis[\"cart_pole\"]\n", 436 | "cart_pole.delete()\n", 437 | "cart = cart_pole[\"cart\"]\n", 438 | "pivot = cart[\"pivot\"]\n", 439 | "pole = pivot[\"pole\"]\n", 440 | "cart.set_object(g.Box([0.5, 0.3, 0.2]))\n", 441 | "pole.set_object(g.Box([1, 0.05, 0.05]))\n", 442 | "pole.set_transform(tf.translation_matrix([0.5, 0, 0]))\n", 443 | "pivot.set_transform(tf.rotation_matrix(-np.pi/2, [0, 1, 0]))" 444 | ] 445 | }, 446 | { 447 | "cell_type": "code", 448 | "execution_count": null, 449 | "metadata": {}, 450 | "outputs": [], 451 | "source": [ 452 | "for x in np.linspace(-np.pi, np.pi, 200):\n", 453 | " cart.set_transform(tf.translation_matrix([np.sin(x), 0, 0]))\n", 454 | " pivot.set_transform(tf.rotation_matrix(x / 4 - np.pi / 2, [0, 1, 0]))\n", 455 | " time.sleep(0.01)" 456 | ] 457 | }, 458 | { 459 | "cell_type": "markdown", 460 | "metadata": {}, 461 | "source": [ 462 | "Let's set the camera position to above left of the cartpole:" 463 | ] 464 | }, 465 | { 466 | "cell_type": "code", 467 | "execution_count": null, 468 | "metadata": {}, 469 | "outputs": [], 470 | "source": [ 471 | "vis.jupyter_cell()" 472 | ] 473 | }, 474 | { 475 | "cell_type": "code", 476 | "execution_count": null, 477 | "metadata": {}, 478 | "outputs": [], 479 | "source": [ 480 | "vis.set_cam_pos([0.6, -1.0, 1.0])" 481 | ] 482 | }, 483 | { 484 | "cell_type": "markdown", 485 | "metadata": {}, 486 | "source": [ 487 | "Let's change the focus to the leftmost 'l' character:" 488 | ] 489 | }, 490 | { 491 | "cell_type": "code", 492 | "execution_count": null, 493 | "metadata": {}, 494 | "outputs": [], 495 | "source": [ 496 | "vis.set_cam_target([0.0, -1.0, 0.0])" 497 | ] 498 | }, 499 | { 500 | "cell_type": "code", 501 | "execution_count": null, 502 | "metadata": {}, 503 | "outputs": [], 504 | "source": [] 505 | } 506 | ], 507 | "metadata": { 508 | "kernelspec": { 509 | "display_name": "Python 3", 510 | "language": "python", 511 | "name": "python3" 512 | }, 513 | "language_info": { 514 | "codemirror_mode": { 515 | "name": "ipython", 516 | "version": 3 517 | }, 518 | "file_extension": ".py", 519 | "mimetype": "text/x-python", 520 | "name": "python", 521 | "nbconvert_exporter": "python", 522 | "pygments_lexer": "ipython3", 523 | "version": "3.9.13" 524 | } 525 | }, 526 | "nbformat": 4, 527 | "nbformat_minor": 4 528 | } 529 | -------------------------------------------------------------------------------- /src/meshcat/servers/zmqserver.py: -------------------------------------------------------------------------------- 1 | from __future__ import absolute_import, division, print_function 2 | 3 | import atexit 4 | import base64 5 | import os 6 | import re 7 | import sys 8 | import subprocess 9 | import multiprocessing 10 | import json 11 | 12 | import tornado.web 13 | import tornado.ioloop 14 | import tornado.websocket 15 | import tornado.gen 16 | 17 | import zmq 18 | import zmq.eventloop.ioloop 19 | from zmq.eventloop.zmqstream import ZMQStream 20 | 21 | from .tree import SceneTree, walk, find_node 22 | 23 | 24 | def capture(pattern, s): 25 | match = re.match(pattern, s) 26 | if not match: 27 | raise ValueError("Could not match {:s} with pattern {:s}".format(s, pattern)) 28 | else: 29 | return match.groups()[0] 30 | 31 | def match_zmq_url(line): 32 | return capture(r"^zmq_url=(.*)$", line) 33 | 34 | def match_web_url(line): 35 | return capture(r"^web_url=(.*)$", line) 36 | 37 | def start_zmq_server_as_subprocess(zmq_url=None, server_args=[]): 38 | """ 39 | Starts the ZMQ server as a subprocess, passing *args through popen. 40 | Optional Keyword Arguments: 41 | zmq_url 42 | """ 43 | # Need -u for unbuffered output: https://stackoverflow.com/a/25572491 44 | args = [sys.executable, "-u", "-m", "meshcat.servers.zmqserver"] 45 | if zmq_url is not None: 46 | args.append("--zmq-url") 47 | args.append(zmq_url) 48 | if server_args: 49 | args.append(*server_args) 50 | # Note: Pass PYTHONPATH to be robust to workflows like Google Colab, 51 | # where meshcat might have been added directly via sys.path.append. 52 | # Copy existing environmental variables as some of them might be needed 53 | # e.g. on Windows SYSTEMROOT and PATH 54 | env = dict(os.environ) 55 | env["PYTHONPATH"] = os.path.dirname(os.path.dirname(os.path.dirname(__file__))) 56 | # Use start_new_session if it's available. Without it, in jupyter the server 57 | # goes down when we cancel execution of any cell in the notebook. 58 | server_proc = subprocess.Popen(args, 59 | stdout=subprocess.PIPE, 60 | stderr=subprocess.PIPE, 61 | env=env, 62 | start_new_session=True) 63 | line = "" 64 | while "zmq_url" not in line: 65 | line = server_proc.stdout.readline().strip().decode("utf-8") 66 | if server_proc.poll() is not None: 67 | outs, errs = server_proc.communicate() 68 | print(outs.decode("utf-8")) 69 | print(errs.decode("utf-8")) 70 | raise RuntimeError("the meshcat server process exited prematurely with exit code " + str(server_proc.poll())) 71 | zmq_url = match_zmq_url(line) 72 | web_url = match_web_url(server_proc.stdout.readline().strip().decode("utf-8")) 73 | 74 | def cleanup(server_proc): 75 | server_proc.kill() 76 | server_proc.wait() 77 | 78 | atexit.register(cleanup, server_proc) 79 | return server_proc, zmq_url, web_url 80 | 81 | 82 | def _zmq_install_ioloop(): 83 | # For pyzmq<17, install ioloop instead of a tornado ioloop 84 | # http://zeromq.github.com/pyzmq/eventloop.html 85 | try: 86 | pyzmq_major = int(zmq.__version__.split(".")[0]) 87 | except ValueError: 88 | # Development version? 89 | return 90 | if pyzmq_major < 17: 91 | zmq.eventloop.ioloop.install() 92 | 93 | 94 | _zmq_install_ioloop() 95 | 96 | VIEWER_ROOT = os.path.join(os.path.dirname(__file__), "..", "viewer", "dist") 97 | VIEWER_HTML = "index.html" 98 | 99 | DEFAULT_FILESERVER_PORT = 7000 100 | MAX_ATTEMPTS = 1000 101 | DEFAULT_ZMQ_METHOD = "tcp" 102 | DEFAULT_ZMQ_PORT = 6000 103 | 104 | MESHCAT_COMMANDS = ["set_transform", "set_object", "delete", "set_property", "set_animation"] 105 | 106 | 107 | def find_available_port(func, default_port, max_attempts=MAX_ATTEMPTS, **kwargs): 108 | for i in range(max_attempts): 109 | port = default_port + i 110 | try: 111 | return func(port, **kwargs), port 112 | except (OSError, zmq.error.ZMQError): 113 | print("Port: {:d} in use, trying another...".format(port), file=sys.stderr) 114 | except Exception as e: 115 | print(type(e)) 116 | raise 117 | else: 118 | raise(Exception("Could not find an available port in the range: [{:d}, {:d})".format(default_port, max_attempts + default_port))) 119 | 120 | 121 | class WebSocketHandler(tornado.websocket.WebSocketHandler): 122 | def __init__(self, *args, **kwargs): 123 | self.bridge = kwargs.pop("bridge") 124 | super(WebSocketHandler, self).__init__(*args, **kwargs) 125 | 126 | def open(self): 127 | self.bridge.websocket_pool.add(self) 128 | print("opened:", self, file=sys.stderr) 129 | self.bridge.send_scene(self) 130 | 131 | def on_message(self, message): 132 | try: 133 | message = json.loads(message) 134 | self.bridge.send_image(message['data']) 135 | return 136 | except Exception as err: 137 | print(err) 138 | raise 139 | 140 | def on_close(self): 141 | self.bridge.websocket_pool.remove(self) 142 | print("closed:", self, file=sys.stderr) 143 | 144 | 145 | def create_command(data): 146 | """Encode the drawing command into a Javascript fetch() command for display.""" 147 | return """ 148 | fetch("data:application/octet-binary;base64,{}") 149 | .then(res => res.arrayBuffer()) 150 | .then(buffer => viewer.handle_command_bytearray(new Uint8Array(buffer))); 151 | """.format(base64.b64encode(data).decode("utf-8")) 152 | 153 | 154 | class StaticFileHandlerNoCache(tornado.web.StaticFileHandler): 155 | """Ensures static files do not get cached. 156 | 157 | Taken from: https://stackoverflow.com/a/18879658/7829525 158 | """ 159 | def set_extra_headers(self, path): 160 | # Disable cache 161 | self.set_header('Cache-Control', 'no-store, no-cache, must-revalidate, max-age=0') 162 | 163 | 164 | class ZMQWebSocketBridge(object): 165 | context = zmq.Context() 166 | 167 | def __init__(self, zmq_url=None, host="127.0.0.1", port=None, 168 | certfile=None, keyfile=None, ngrok_http_tunnel=False): 169 | self.host = host 170 | self.websocket_pool = set() 171 | self.app = self.make_app() 172 | self.ioloop = tornado.ioloop.IOLoop.current() 173 | 174 | if zmq_url is None: 175 | def f(port): 176 | return self.setup_zmq("{:s}://{:s}:{:d}".format(DEFAULT_ZMQ_METHOD, self.host, port)) 177 | (self.zmq_socket, self.zmq_stream, self.zmq_url), _ = find_available_port(f, DEFAULT_ZMQ_PORT) 178 | else: 179 | self.zmq_socket, self.zmq_stream, self.zmq_url = self.setup_zmq(zmq_url) 180 | 181 | protocol = "http:" 182 | listen_kwargs = {} 183 | if certfile is not None or keyfile is not None: 184 | if certfile is None: 185 | raise(Exception("You must supply a certfile if you supply a keyfile")) 186 | if keyfile is None: 187 | raise(Exception("You must supply a keyfile if you supply a certfile")) 188 | 189 | listen_kwargs["ssl_options"] = { "certfile": certfile, 190 | "keyfile": keyfile } 191 | protocol = "https:" 192 | 193 | if port is None: 194 | _, self.fileserver_port = find_available_port(self.app.listen, DEFAULT_FILESERVER_PORT, **listen_kwargs) 195 | else: 196 | self.app.listen(port, **listen_kwargs) 197 | self.fileserver_port = port 198 | self.web_url = "{protocol}//{host}:{port}/static/".format( 199 | protocol=protocol, host=self.host, port=self.fileserver_port) 200 | 201 | # Note: The (significant) advantage of putting this in here is not only 202 | # so that the workflow is convenient, but also so that the server 203 | # administers the public web_url when clients ask for it. 204 | if ngrok_http_tunnel: 205 | if protocol == "https:": 206 | # TODO(russt): Consider plumbing ngrok auth through here for 207 | # someone who has paid for ngrok and wants to use https. 208 | raise(Exception('The free version of ngrok does not support https')) 209 | 210 | # Conditionally import pyngrok 211 | try: 212 | import pyngrok.conf 213 | import pyngrok.ngrok 214 | 215 | # Use start_new_session if it's available. Without it, in 216 | # jupyter the server goes down when we cancel execution of any 217 | # cell in the notebook. 218 | config = pyngrok.conf.PyngrokConfig(start_new_session=True) 219 | self.web_url = pyngrok.ngrok.connect(self.fileserver_port, "http", pyngrok_config=config) 220 | 221 | # pyngrok >= 5.0.0 returns an NgrokTunnel object instead of the string. 222 | if not isinstance(self.web_url, str): 223 | self.web_url = self.web_url.public_url 224 | self.web_url += "/static/" 225 | 226 | print("\n") # ensure any pyngrok output is properly terminated. 227 | def cleanup(): 228 | pyngrok.ngrok.kill() 229 | 230 | atexit.register(cleanup) 231 | 232 | except ImportError as e: 233 | if "pyngrok" in e.__class__.__name__: 234 | raise(Exception("You must install pyngrok (e.g. via `pip install pyngrok`).")) 235 | 236 | self.tree = SceneTree() 237 | 238 | def make_app(self): 239 | return tornado.web.Application([ 240 | (r"/static/(.*)", StaticFileHandlerNoCache, {"path": VIEWER_ROOT, "default_filename": VIEWER_HTML}), 241 | (r"/", WebSocketHandler, {"bridge": self}) 242 | ]) 243 | 244 | def wait_for_websockets(self): 245 | if len(self.websocket_pool) > 0: 246 | self.zmq_socket.send(b"ok") 247 | else: 248 | self.ioloop.call_later(0.1, self.wait_for_websockets) 249 | 250 | def send_image(self, data): 251 | import base64 252 | mime, img_code = data.split(",", 1) 253 | img_bytes = base64.b64decode(img_code) 254 | self.zmq_stream.send(img_bytes) 255 | 256 | def handle_zmq(self, frames): 257 | cmd = frames[0].decode("utf-8") 258 | if cmd == "url": 259 | self.zmq_socket.send(self.web_url.encode("utf-8")) 260 | elif cmd == "wait": 261 | self.ioloop.add_callback(self.wait_for_websockets) 262 | elif cmd == "set_target": 263 | self.forward_to_websockets(frames) 264 | self.zmq_socket.send(b"ok") 265 | elif cmd == "capture_image": 266 | if len(self.websocket_pool) > 0: 267 | self.forward_to_websockets(frames) # on_message callback should handle the pb 268 | else: 269 | self.ioloop.call_later(0.3, lambda: self.handle_zmq(frames)) 270 | elif cmd in MESHCAT_COMMANDS: 271 | if len(frames) != 3: 272 | self.zmq_socket.send(b"error: expected 3 frames") 273 | return 274 | path = list(filter(lambda x: len(x) > 0, frames[1].decode("utf-8").split("/"))) 275 | data = frames[2] 276 | # Support caching of objects (note: even UUIDs have to match). 277 | cache_hit = (cmd == "set_object" and 278 | find_node(self.tree, path).object and 279 | find_node(self.tree, path).object == data) 280 | if not cache_hit: 281 | self.forward_to_websockets(frames) 282 | if cmd == "set_transform": 283 | find_node(self.tree, path).transform = data 284 | elif cmd == "set_object": 285 | find_node(self.tree, path).object = data 286 | find_node(self.tree, path).properties = [] 287 | elif cmd == "set_property": 288 | find_node(self.tree, path).properties.append(data) 289 | elif cmd == "set_animation": 290 | find_node(self.tree, path).animation = data 291 | elif cmd == "delete": 292 | if len(path) > 0: 293 | parent = find_node(self.tree, path[:-1]) 294 | child = path[-1] 295 | if child in parent: 296 | del parent[child] 297 | else: 298 | self.tree = SceneTree() 299 | self.zmq_socket.send(b"ok") 300 | elif cmd == "get_scene": 301 | # when the server gets this command, return the tree 302 | # as a series of msgpack-backed binary blobs 303 | drawing_commands = "" 304 | for node in walk(self.tree): 305 | if node.object is not None: 306 | drawing_commands += create_command(node.object) 307 | for p in node.properties: 308 | drawing_commands += create_command(p) 309 | if node.transform is not None: 310 | drawing_commands += create_command(node.transform) 311 | if node.animation is not None: 312 | drawing_commands += create_command(node.animation) 313 | 314 | # now that we have the drawing commands, generate the full 315 | # HTML that we want to generate, including the javascript assets 316 | mainminjs_path = os.path.join(VIEWER_ROOT, "main.min.js") 317 | mainminjs_src = "" 318 | with open(mainminjs_path, "r") as f: 319 | mainminjs_src = f.readlines() 320 | mainminjs_src = "".join(mainminjs_src) 321 | 322 | html = """ 323 | 324 | 325 | MeshCat 326 | 327 |
328 |
329 | 332 | 336 | 344 | 345 | 346 | 347 | """.format(mainminjs=mainminjs_src, commands=drawing_commands) 348 | self.zmq_socket.send(html.encode('utf-8')) 349 | else: 350 | self.zmq_socket.send(b"error: unrecognized comand") 351 | 352 | def forward_to_websockets(self, frames): 353 | cmd, path, data = frames 354 | for websocket in self.websocket_pool: 355 | websocket.write_message(data, binary=True) 356 | 357 | def setup_zmq(self, url): 358 | zmq_socket = self.context.socket(zmq.REP) 359 | zmq_socket.bind(url) 360 | zmq_stream = ZMQStream(zmq_socket) 361 | zmq_stream.on_recv(self.handle_zmq) 362 | return zmq_socket, zmq_stream, url 363 | 364 | def send_scene(self, websocket): 365 | for node in walk(self.tree): 366 | if node.object is not None: 367 | websocket.write_message(node.object, binary=True) 368 | for p in node.properties: 369 | websocket.write_message(p, binary=True) 370 | if node.transform is not None: 371 | websocket.write_message(node.transform, binary=True) 372 | if node.animation is not None: 373 | websocket.write_message(node.animation, binary=True) 374 | 375 | def run(self): 376 | self.ioloop.start() 377 | 378 | 379 | def main(): 380 | import argparse 381 | import sys 382 | import webbrowser 383 | import platform 384 | import asyncio 385 | 386 | # Fix asyncio configuration on Windows for Python 3.8 and above. 387 | # Workaround for https://github.com/tornadoweb/tornado/issues/2608 388 | if sys.version_info >= (3, 8) and platform.system() == 'Windows': 389 | asyncio.set_event_loop_policy(asyncio.WindowsSelectorEventLoopPolicy()) 390 | 391 | parser = argparse.ArgumentParser(description="Serve the MeshCat HTML files and listen for ZeroMQ commands") 392 | parser.add_argument('--zmq-url', '-z', type=str, nargs="?", default=None) 393 | parser.add_argument('--open', '-o', action="store_true") 394 | parser.add_argument('--certfile', type=str, default=None) 395 | parser.add_argument('--keyfile', type=str, default=None) 396 | parser.add_argument('--ngrok_http_tunnel', action="store_true", help=""" 397 | ngrok is a service for creating a public URL from your local machine, which 398 | is very useful if you would like to make your meshcat server public.""") 399 | results = parser.parse_args() 400 | bridge = ZMQWebSocketBridge(zmq_url=results.zmq_url, 401 | certfile=results.certfile, 402 | keyfile=results.keyfile, 403 | ngrok_http_tunnel=results.ngrok_http_tunnel) 404 | print("zmq_url={:s}".format(bridge.zmq_url)) 405 | print("web_url={:s}".format(bridge.web_url)) 406 | if results.open: 407 | webbrowser.open(bridge.web_url, new=2) 408 | 409 | try: 410 | bridge.run() 411 | except KeyboardInterrupt: 412 | pass 413 | 414 | if __name__ == '__main__': 415 | main() 416 | -------------------------------------------------------------------------------- /src/meshcat/geometry.py: -------------------------------------------------------------------------------- 1 | import base64 2 | import uuid 3 | from io import StringIO, BytesIO 4 | import umsgpack 5 | import numpy as np 6 | 7 | from . import transformations as tf 8 | 9 | 10 | class SceneElement(object): 11 | def __init__(self): 12 | self.uuid = str(uuid.uuid1()) 13 | 14 | 15 | class ReferenceSceneElement(SceneElement): 16 | def lower_in_object(self, object_data): 17 | object_data.setdefault(self.field, []).append(self.lower(object_data)) 18 | return self.uuid 19 | 20 | 21 | class Geometry(ReferenceSceneElement): 22 | field = "geometries" 23 | 24 | def intrinsic_transform(self): 25 | return tf.identity_matrix() 26 | 27 | 28 | class Material(ReferenceSceneElement): 29 | field = "materials" 30 | 31 | 32 | class Texture(ReferenceSceneElement): 33 | field = "textures" 34 | 35 | 36 | class Image(ReferenceSceneElement): 37 | field = "images" 38 | 39 | 40 | class Box(Geometry): 41 | def __init__(self, lengths): 42 | super(Box, self).__init__() 43 | self.lengths = lengths 44 | 45 | def lower(self, object_data): 46 | return { 47 | u"uuid": self.uuid, 48 | u"type": u"BoxGeometry", 49 | u"width": self.lengths[0], 50 | u"height": self.lengths[1], 51 | u"depth": self.lengths[2] 52 | } 53 | 54 | 55 | class Sphere(Geometry): 56 | def __init__(self, radius): 57 | super(Sphere, self).__init__() 58 | self.radius = radius 59 | 60 | def lower(self, object_data): 61 | return { 62 | u"uuid": self.uuid, 63 | u"type": u"SphereGeometry", 64 | u"radius": self.radius, 65 | u"widthSegments" : 20, 66 | u"heightSegments" : 20 67 | } 68 | 69 | 70 | class Ellipsoid(Sphere): 71 | """ 72 | An Ellipsoid is treated as a Sphere of unit radius, with an affine 73 | transformation applied to distort it into the ellipsoidal shape 74 | """ 75 | def __init__(self, radii): 76 | super(Ellipsoid, self).__init__(1.0) 77 | self.radii = radii 78 | 79 | def intrinsic_transform(self): 80 | return np.diag(np.hstack((self.radii, 1.0))) 81 | 82 | 83 | class Plane(Geometry): 84 | 85 | def __init__(self, width=1, height=1, widthSegments=1, heightSegments=1): 86 | super(Plane, self).__init__() 87 | self.width = width 88 | self.height = height 89 | self.widthSegments = widthSegments 90 | self.heightSegments = heightSegments 91 | 92 | def lower(self, object_data): 93 | return { 94 | u"uuid": self.uuid, 95 | u"type": u"PlaneGeometry", 96 | u"width": self.width, 97 | u"height": self.height, 98 | u"widthSegments": self.widthSegments, 99 | u"heightSegments": self.heightSegments, 100 | } 101 | 102 | 103 | """ 104 | A cylinder of the given height and radius. By Three.js convention, the axis of 105 | rotational symmetry is aligned with the y-axis. 106 | """ 107 | class Cylinder(Geometry): 108 | def __init__(self, height, radius=1.0, radiusTop=None, radiusBottom=None): 109 | super(Cylinder, self).__init__() 110 | if radiusTop is not None and radiusBottom is not None: 111 | self.radiusTop = radiusTop 112 | self.radiusBottom = radiusBottom 113 | else: 114 | self.radiusTop = radius 115 | self.radiusBottom = radius 116 | self.height = height 117 | self.radialSegments = 50 118 | 119 | def lower(self, object_data): 120 | return { 121 | u"uuid": self.uuid, 122 | u"type": u"CylinderGeometry", 123 | u"radiusTop": self.radiusTop, 124 | u"radiusBottom": self.radiusBottom, 125 | u"height": self.height, 126 | u"radialSegments": self.radialSegments 127 | } 128 | 129 | 130 | class GenericMaterial(Material): 131 | def __init__(self, color=0xffffff, reflectivity=0.5, map=None, 132 | side = 2, transparent = None, opacity = 1.0, 133 | linewidth = 1.0, 134 | wireframe = False, 135 | wireframeLinewidth = 1.0, 136 | vertexColors=False, 137 | **kwargs): 138 | super(GenericMaterial, self).__init__() 139 | self.color = color 140 | self.reflectivity = reflectivity 141 | self.map = map 142 | self.side = side 143 | self.transparent = transparent 144 | self.opacity = opacity 145 | self.linewidth = linewidth 146 | self.wireframe = wireframe 147 | self.wireframeLinewidth = wireframeLinewidth 148 | self.vertexColors = vertexColors 149 | self.properties = kwargs 150 | 151 | def lower(self, object_data): 152 | # Three.js allows a material to have an opacity which is != 1, 153 | # but to still be non-transparent, in which case the opacity only 154 | # serves to desaturate the material's color. That's a pretty odd 155 | # combination of things to want, so by default we juse use the 156 | # opacity value to decide whether to set transparent to True or 157 | # False. 158 | if self.transparent is None: 159 | transparent = bool(self.opacity != 1) 160 | else: 161 | transparent = self.transparent 162 | data = { 163 | u"uuid": self.uuid, 164 | u"type": self._type, 165 | u"color": self.color, 166 | u"reflectivity": self.reflectivity, 167 | u"side": self.side, 168 | u"transparent": transparent, 169 | u"opacity": self.opacity, 170 | u"linewidth": self.linewidth, 171 | u"wireframe": bool(self.wireframe), 172 | u"wireframeLinewidth": self.wireframeLinewidth, 173 | u"vertexColors": (2 if self.vertexColors else 0), # three.js wants an enum 174 | } 175 | data.update(self.properties) 176 | if self.map is not None: 177 | data[u"map"] = self.map.lower_in_object(object_data) 178 | return data 179 | 180 | 181 | class MeshBasicMaterial(GenericMaterial): 182 | _type=u"MeshBasicMaterial" 183 | 184 | 185 | class MeshPhongMaterial(GenericMaterial): 186 | _type=u"MeshPhongMaterial" 187 | 188 | 189 | class MeshLambertMaterial(GenericMaterial): 190 | _type=u"MeshLambertMaterial" 191 | 192 | 193 | class MeshToonMaterial(GenericMaterial): 194 | _type=u"MeshToonMaterial" 195 | 196 | 197 | class LineBasicMaterial(GenericMaterial): 198 | _type=u"LineBasicMaterial" 199 | 200 | 201 | class PngImage(Image): 202 | def __init__(self, data): 203 | super(PngImage, self).__init__() 204 | self.data = data 205 | 206 | @staticmethod 207 | def from_file(fname): 208 | with open(fname, "rb") as f: 209 | return PngImage(f.read()) 210 | 211 | def lower(self, object_data): 212 | return { 213 | u"uuid": self.uuid, 214 | u"url": str("data:image/png;base64," + base64.b64encode(self.data).decode('ascii')) 215 | } 216 | 217 | 218 | class TextTexture(Texture): 219 | def __init__(self, text, font_size=100, font_face='sans-serif'): 220 | super(TextTexture, self).__init__() 221 | self.text = text 222 | # font_size will be passed to the JS side as is; however if the 223 | # text width exceeds canvas width, font_size will be reduced. 224 | self.font_size = font_size 225 | self.font_face = font_face 226 | 227 | def lower(self, object_data): 228 | return { 229 | u"uuid": self.uuid, 230 | u"type": u"_text", 231 | u"text": self.text, 232 | u"font_size": self.font_size, 233 | u"font_face": self.font_face, 234 | } 235 | 236 | 237 | class GenericTexture(Texture): 238 | def __init__(self, properties): 239 | super(GenericTexture, self).__init__() 240 | self.properties = properties 241 | 242 | def lower(self, object_data): 243 | data = {u"uuid": self.uuid} 244 | data.update(self.properties) 245 | if u"image" in data: 246 | image = data[u"image"] 247 | data[u"image"] = image.lower_in_object(object_data) 248 | return data 249 | 250 | 251 | class ImageTexture(Texture): 252 | def __init__(self, image, wrap=[1001, 1001], repeat=[1, 1], **kwargs): 253 | super(ImageTexture, self).__init__() 254 | self.image = image 255 | self.wrap = wrap 256 | self.repeat = repeat 257 | self.properties = kwargs 258 | 259 | def lower(self, object_data): 260 | data = { 261 | u"uuid": self.uuid, 262 | u"wrap": self.wrap, 263 | u"repeat": self.repeat, 264 | u"image": self.image.lower_in_object(object_data) 265 | } 266 | data.update(self.properties) 267 | return data 268 | 269 | 270 | class Object(SceneElement): 271 | def __init__(self, geometry, material=MeshPhongMaterial()): 272 | super(Object, self).__init__() 273 | self.geometry = geometry 274 | self.material = material 275 | 276 | def lower(self): 277 | data = { 278 | u"metadata": { 279 | u"version": 4.5, 280 | u"type": u"Object", 281 | }, 282 | u"geometries": [], 283 | u"materials": [], 284 | u"object": { 285 | u"uuid": self.uuid, 286 | u"type": self._type, 287 | u"geometry": self.geometry.uuid, 288 | u"material": self.material.uuid, 289 | u"matrix": list(self.geometry.intrinsic_transform().flatten()) 290 | } 291 | } 292 | self.geometry.lower_in_object(data) 293 | self.material.lower_in_object(data) 294 | return data 295 | 296 | 297 | class Mesh(Object): 298 | _type = u"Mesh" 299 | 300 | 301 | class OrthographicCamera(SceneElement): 302 | def __init__(self, left, right, top, bottom, near, far, zoom=1): 303 | super(OrthographicCamera, self).__init__() 304 | self.left = left 305 | self.right = right 306 | self.top = top 307 | self.bottom = bottom 308 | self.near = near 309 | self.far = far 310 | self.zoom = zoom 311 | 312 | def lower(self): 313 | data = { 314 | u"object": { 315 | u"uuid": self.uuid, 316 | u"type": u"OrthographicCamera", 317 | u"left": self.left, 318 | u"right": self.right, 319 | u"top": self.top, 320 | u"bottom": self.bottom, 321 | u"near": self.near, 322 | u"far": self.far, 323 | u"zoom": self.zoom, 324 | } 325 | } 326 | return data 327 | 328 | class PerspectiveCamera(SceneElement): 329 | """ 330 | The PerspectiveCamera is the default camera used by the meshcat viewer. See 331 | https://threejs.org/docs/#api/en/cameras/PerspectiveCamera for more 332 | information. 333 | """ 334 | def __init__(self, fov = 50, aspect = 1, near = 0.1, far = 2000, 335 | zoom = 1, filmGauge=35, filmOffset = 0, focus = 10): 336 | """ 337 | fov : Camera frustum vertical field of view, from bottom to top of view, in degrees. Default is 50. 338 | aspect: Camera frustum aspect ratio, usually the canvas width / canvas height. Default is 1 (square canvas). 339 | near : Camera frustum near plane. Default is 0.1. The valid range is greater than 0 and less than the current 340 | value of the far plane. Note that, unlike for the OrthographicCamera, 0 is not a valid value for a 341 | PerspectiveCamera's near plane. 342 | far : Camera frustum far plane. Default is 2000. 343 | zoom : Gets or sets the zoom factor of the camera. Default is 1. 344 | filmGauge: Film size used for the larger axis. Default is 35 (millimeters). This parameter does not influence 345 | the projection matrix unless .filmOffset is set to a nonzero value. 346 | filmOffset: Horizontal off-center offset in the same unit as .filmGauge. Default is 0. 347 | focus: Object distance used for stereoscopy and depth-of-field effects. This parameter does not influence 348 | the projection matrix unless a StereoCamera is being used. Default is 10. 349 | """ 350 | #super(PerspectiveCamera, self).__init__() 351 | SceneElement.__init__(self) 352 | self.fov = fov 353 | self.aspect = aspect 354 | self.far = far 355 | self.near = near 356 | self.zoom = zoom 357 | self.filmGauge = filmGauge 358 | self.filmOffset = filmOffset 359 | self.focus = focus 360 | 361 | def lower(self): 362 | data = { 363 | u"object": { 364 | u"uuid": self.uuid, 365 | u"type": u"PerspectiveCamera", 366 | u"aspect": self.aspect, 367 | u"far": self.far, 368 | u"filmGauge": self.filmGauge, 369 | u"filmOffset": self.filmOffset, 370 | u"focus": self.focus, 371 | u"fov": self.fov, 372 | u"near": self.near, 373 | u"zoom": self.zoom, 374 | } 375 | } 376 | return data 377 | 378 | def item_size(array): 379 | if array.ndim == 1: 380 | return 1 381 | elif array.ndim == 2: 382 | return array.shape[0] 383 | else: 384 | raise ValueError("I can only pack 1- or 2-dimensional numpy arrays, but this one has {:d} dimensions".format(array.ndim)) 385 | 386 | 387 | def threejs_type(dtype): 388 | if dtype == np.uint8: 389 | return u"Uint8Array", 0x12 390 | elif dtype == np.int32: 391 | return u"Int32Array", 0x15 392 | elif dtype == np.uint32: 393 | return u"Uint32Array", 0x16 394 | elif dtype == np.float32: 395 | return u"Float32Array", 0x17 396 | else: 397 | raise ValueError("Unsupported datatype: " + str(dtype)) 398 | 399 | 400 | def pack_numpy_array(x): 401 | if x.dtype == np.float64: 402 | x = x.astype(np.float32) 403 | typename, extcode = threejs_type(x.dtype) 404 | return { 405 | u"itemSize": item_size(x), 406 | u"type": typename, 407 | u"array": umsgpack.Ext(extcode, x.tobytes('F')), 408 | u"normalized": False 409 | } 410 | 411 | 412 | def data_from_stream(stream): 413 | if isinstance(stream, BytesIO): 414 | data = stream.read().decode(encoding='utf-8') 415 | elif isinstance(stream, StringIO): 416 | data = stream.read() 417 | else: 418 | raise ValueError('Stream must be instance of StringIO or BytesIO, not {}'.format(type(stream))) 419 | return data 420 | 421 | 422 | class MeshGeometry(Geometry): 423 | def __init__(self, contents, mesh_format): 424 | super(MeshGeometry, self).__init__() 425 | self.contents = contents 426 | self.mesh_format = mesh_format 427 | 428 | def lower(self, object_data): 429 | return { 430 | u"type": u"_meshfile_geometry", 431 | u"uuid": self.uuid, 432 | u"format": self.mesh_format, 433 | u"data": self.contents 434 | } 435 | 436 | 437 | class ObjMeshGeometry(MeshGeometry): 438 | def __init__(self, contents): 439 | super(ObjMeshGeometry, self, contents, u"obj").__init__() 440 | 441 | @staticmethod 442 | def from_file(fname): 443 | with open(fname, "r") as f: 444 | return MeshGeometry(f.read(), u"obj") 445 | 446 | @staticmethod 447 | def from_stream(f): 448 | return MeshGeometry(data_from_stream(f), u"obj") 449 | 450 | 451 | class DaeMeshGeometry(MeshGeometry): 452 | def __init__(self, contents): 453 | super(DaeMeshGeometry, self, contents, u"dae").__init__() 454 | 455 | @staticmethod 456 | def from_file(fname): 457 | with open(fname, "r") as f: 458 | return MeshGeometry(f.read(), u"dae") 459 | 460 | @staticmethod 461 | def from_stream(f): 462 | return MeshGeometry(data_from_stream(f), u"dae") 463 | 464 | 465 | class StlMeshGeometry(MeshGeometry): 466 | def __init__(self, contents): 467 | super(StlMeshGeometry, self, contents, u"stl").__init__() 468 | 469 | @staticmethod 470 | def from_file(fname): 471 | with open(fname, "rb") as f: 472 | arr = np.frombuffer(f.read(), dtype=np.uint8) 473 | _, extcode = threejs_type(np.uint8) 474 | encoded = umsgpack.Ext(extcode, arr.tobytes()) 475 | return MeshGeometry(encoded, u"stl") 476 | 477 | @staticmethod 478 | def from_stream(f): 479 | if isinstance(f, BytesIO): 480 | arr = np.frombuffer(f.read(), dtype=np.uint8) 481 | elif isinstance(f, StringIO): 482 | arr = np.frombuffer(bytes(f.read(), "utf-8"), dtype=np.uint8) 483 | else: 484 | raise ValueError('Stream must be instance of StringIO or BytesIO, not {}'.format(type(f))) 485 | _, extcode = threejs_type(np.uint8) 486 | encoded = umsgpack.Ext(extcode, arr.tobytes()) 487 | return MeshGeometry(encoded, u"stl") 488 | 489 | 490 | class TriangularMeshGeometry(Geometry): 491 | """ 492 | A mesh consisting of an arbitrary collection of triangular faces. To 493 | construct one, you need to pass in a collection of vertices as an Nx3 array 494 | and a collection of faces as an Mx3 array. Each element of `faces` should 495 | be a collection of 3 indices into the `vertices` array. 496 | 497 | For example, to create a square made out of two adjacent triangles, we 498 | could do: 499 | 500 | vertices = np.array([ 501 | [0, 0, 0], # the first vertex is at [0, 0, 0] 502 | [1, 0, 0], 503 | [1, 0, 1], 504 | [0, 0, 1] 505 | ]) 506 | faces = np.array([ 507 | [0, 1, 2], # The first face consists of vertices 0, 1, and 2 508 | [3, 0, 2] 509 | ]) 510 | 511 | mesh = TriangularMeshGeometry(vertices, faces) 512 | 513 | To set the color of the mesh by vertex, pass an Nx3 array containing the 514 | RGB values (in range [0,1]) of the vertices to the optional `color` 515 | argument, and set `vertexColors=True` in the Material. 516 | """ 517 | __slots__ = ["vertices", "faces"] 518 | 519 | def __init__(self, vertices, faces, color=None): 520 | super(TriangularMeshGeometry, self).__init__() 521 | 522 | vertices = np.asarray(vertices, dtype=np.float32) 523 | faces = np.asarray(faces, dtype=np.uint32) 524 | assert vertices.shape[1] == 3, "`vertices` must be an Nx3 array" 525 | assert faces.shape[1] == 3, "`faces` must be an Mx3 array" 526 | self.vertices = vertices 527 | self.faces = faces 528 | if color is not None: 529 | color = np.asarray(color, dtype=np.float32) 530 | assert np.array_equal(vertices.shape, color.shape), "`color` must be the same shape as vertices" 531 | self.color = color 532 | 533 | def lower(self, object_data): 534 | attrs = {u"position": pack_numpy_array(self.vertices.T)} 535 | if self.color is not None: 536 | attrs[u"color"] = pack_numpy_array(self.color.T) 537 | return { 538 | u"uuid": self.uuid, 539 | u"type": u"BufferGeometry", 540 | u"data": { 541 | u"attributes": attrs, 542 | u"index": pack_numpy_array(self.faces.T) 543 | } 544 | } 545 | 546 | 547 | class PointsGeometry(Geometry): 548 | def __init__(self, position, color=None): 549 | super(PointsGeometry, self).__init__() 550 | self.position = position 551 | self.color = color 552 | 553 | def lower(self, object_data): 554 | attrs = {u"position": pack_numpy_array(self.position)} 555 | if self.color is not None: 556 | attrs[u"color"] = pack_numpy_array(self.color) 557 | return { 558 | u"uuid": self.uuid, 559 | u"type": u"BufferGeometry", 560 | u"data": { 561 | u"attributes": attrs 562 | } 563 | } 564 | 565 | 566 | class PointsMaterial(Material): 567 | def __init__(self, size=0.001, color=0xffffff): 568 | super(PointsMaterial, self).__init__() 569 | self.size = size 570 | self.color = color 571 | 572 | def lower(self, object_data): 573 | return { 574 | u"uuid": self.uuid, 575 | u"type": u"PointsMaterial", 576 | u"color": self.color, 577 | u"size": self.size, 578 | u"vertexColors": 2 579 | } 580 | 581 | 582 | class Points(Object): 583 | _type = u"Points" 584 | 585 | 586 | def PointCloud(position, color, **kwargs): 587 | return Points( 588 | PointsGeometry(position, color), 589 | PointsMaterial(**kwargs) 590 | ) 591 | 592 | 593 | def SceneText(text, width=10, height=10, **kwargs): 594 | return Mesh( 595 | Plane(width=width,height=height), 596 | MeshPhongMaterial(map=TextTexture(text,**kwargs),transparent=True, 597 | needsUpdate=True) 598 | ) 599 | 600 | class Line(Object): 601 | _type = u"Line" 602 | 603 | 604 | class LineSegments(Object): 605 | _type = u"LineSegments" 606 | 607 | 608 | class LineLoop(Object): 609 | _type = u"LineLoop" 610 | 611 | 612 | def triad(scale=1.0): 613 | """ 614 | A visual representation of the origin of a coordinate system, drawn as three 615 | lines in red, green, and blue along the x, y, and z axes. The `scale` parameter 616 | controls the length of the three lines. 617 | 618 | Returns an `Object` which can be passed to `set_object()` 619 | """ 620 | return LineSegments( 621 | PointsGeometry(position=np.array([ 622 | [0, 0, 0], [scale, 0, 0], 623 | [0, 0, 0], [0, scale, 0], 624 | [0, 0, 0], [0, 0, scale]]).astype(np.float32).T, 625 | color=np.array([ 626 | [1, 0, 0], [1, 0.6, 0], 627 | [0, 1, 0], [0.6, 1, 0], 628 | [0, 0, 1], [0, 0.6, 1]]).astype(np.float32).T 629 | ), 630 | LineBasicMaterial(vertexColors=True)) 631 | -------------------------------------------------------------------------------- /src/meshcat/transformations.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | # transformations.py 3 | 4 | # Copyright (c) 2006-2015, Christoph Gohlke 5 | # Copyright (c) 2006-2015, The Regents of the University of California 6 | # Produced at the Laboratory for Fluorescence Dynamics 7 | # All rights reserved. 8 | # 9 | # Redistribution and use in source and binary forms, with or without 10 | # modification, are permitted provided that the following conditions are met: 11 | # 12 | # * Redistributions of source code must retain the above copyright 13 | # notice, this list of conditions and the following disclaimer. 14 | # * Redistributions in binary form must reproduce the above copyright 15 | # notice, this list of conditions and the following disclaimer in the 16 | # documentation and/or other materials provided with the distribution. 17 | # * Neither the name of the copyright holders nor the names of any 18 | # contributors may be used to endorse or promote products derived 19 | # from this software without specific prior written permission. 20 | # 21 | # THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" 22 | # AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE 23 | # IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE 24 | # ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR CONTRIBUTORS BE 25 | # LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR 26 | # CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF 27 | # SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS 28 | # INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN 29 | # CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) 30 | # ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE 31 | # POSSIBILITY OF SUCH DAMAGE. 32 | 33 | """Homogeneous Transformation Matrices and Quaternions. 34 | 35 | A library for calculating 4x4 matrices for translating, rotating, reflecting, 36 | scaling, shearing, projecting, orthogonalizing, and superimposing arrays of 37 | 3D homogeneous coordinates as well as for converting between rotation matrices, 38 | Euler angles, and quaternions. Also includes an Arcball control object and 39 | functions to decompose transformation matrices. 40 | 41 | :Author: 42 | `Christoph Gohlke `_ 43 | 44 | :Organization: 45 | Laboratory for Fluorescence Dynamics, University of California, Irvine 46 | 47 | :Version: 2015.07.18 48 | 49 | Requirements 50 | ------------ 51 | * `CPython 2.7 or 3.4 `_ 52 | * `Numpy 1.9 `_ 53 | * `Transformations.c 2015.07.18 `_ 54 | (recommended for speedup of some functions) 55 | 56 | Notes 57 | ----- 58 | The API is not stable yet and is expected to change between revisions. 59 | 60 | This Python code is not optimized for speed. Refer to the transformations.c 61 | module for a faster implementation of some functions. 62 | 63 | Documentation in HTML format can be generated with epydoc. 64 | 65 | Matrices (M) can be inverted using numpy.linalg.inv(M), be concatenated using 66 | numpy.dot(M0, M1), or transform homogeneous coordinate arrays (v) using 67 | numpy.dot(M, v) for shape (4, *) column vectors, respectively 68 | numpy.dot(v, M.T) for shape (*, 4) row vectors ("array of points"). 69 | 70 | This module follows the "column vectors on the right" and "row major storage" 71 | (C contiguous) conventions. The translation components are in the right column 72 | of the transformation matrix, i.e. M[:3, 3]. 73 | The transpose of the transformation matrices may have to be used to interface 74 | with other graphics systems, e.g. with OpenGL's glMultMatrixd(). See also [16]. 75 | 76 | Calculations are carried out with numpy.float64 precision. 77 | 78 | Vector, point, quaternion, and matrix function arguments are expected to be 79 | "array like", i.e. tuple, list, or numpy arrays. 80 | 81 | Return types are numpy arrays unless specified otherwise. 82 | 83 | Angles are in radians unless specified otherwise. 84 | 85 | Quaternions w+ix+jy+kz are represented as [w, x, y, z]. 86 | 87 | A triple of Euler angles can be applied/interpreted in 24 ways, which can 88 | be specified using a 4 character string or encoded 4-tuple: 89 | 90 | *Axes 4-string*: e.g. 'sxyz' or 'ryxy' 91 | 92 | - first character : rotations are applied to 's'tatic or 'r'otating frame 93 | - remaining characters : successive rotation axis 'x', 'y', or 'z' 94 | 95 | *Axes 4-tuple*: e.g. (0, 0, 0, 0) or (1, 1, 1, 1) 96 | 97 | - inner axis: code of axis ('x':0, 'y':1, 'z':2) of rightmost matrix. 98 | - parity : even (0) if inner axis 'x' is followed by 'y', 'y' is followed 99 | by 'z', or 'z' is followed by 'x'. Otherwise odd (1). 100 | - repetition : first and last axis are same (1) or different (0). 101 | - frame : rotations are applied to static (0) or rotating (1) frame. 102 | 103 | Other Python packages and modules for 3D transformations and quaternions: 104 | 105 | * `Transforms3d `_ 106 | includes most code of this module. 107 | * `Blender.mathutils `_ 108 | * `numpy-dtypes `_ 109 | 110 | References 111 | ---------- 112 | (1) Matrices and transformations. Ronald Goldman. 113 | In "Graphics Gems I", pp 472-475. Morgan Kaufmann, 1990. 114 | (2) More matrices and transformations: shear and pseudo-perspective. 115 | Ronald Goldman. In "Graphics Gems II", pp 320-323. Morgan Kaufmann, 1991. 116 | (3) Decomposing a matrix into simple transformations. Spencer Thomas. 117 | In "Graphics Gems II", pp 320-323. Morgan Kaufmann, 1991. 118 | (4) Recovering the data from the transformation matrix. Ronald Goldman. 119 | In "Graphics Gems II", pp 324-331. Morgan Kaufmann, 1991. 120 | (5) Euler angle conversion. Ken Shoemake. 121 | In "Graphics Gems IV", pp 222-229. Morgan Kaufmann, 1994. 122 | (6) Arcball rotation control. Ken Shoemake. 123 | In "Graphics Gems IV", pp 175-192. Morgan Kaufmann, 1994. 124 | (7) Representing attitude: Euler angles, unit quaternions, and rotation 125 | vectors. James Diebel. 2006. 126 | (8) A discussion of the solution for the best rotation to relate two sets 127 | of vectors. W Kabsch. Acta Cryst. 1978. A34, 827-828. 128 | (9) Closed-form solution of absolute orientation using unit quaternions. 129 | BKP Horn. J Opt Soc Am A. 1987. 4(4):629-642. 130 | (10) Quaternions. Ken Shoemake. 131 | http://www.sfu.ca/~jwa3/cmpt461/files/quatut.pdf 132 | (11) From quaternion to matrix and back. JMP van Waveren. 2005. 133 | http://www.intel.com/cd/ids/developer/asmo-na/eng/293748.htm 134 | (12) Uniform random rotations. Ken Shoemake. 135 | In "Graphics Gems III", pp 124-132. Morgan Kaufmann, 1992. 136 | (13) Quaternion in molecular modeling. CFF Karney. 137 | J Mol Graph Mod, 25(5):595-604 138 | (14) New method for extracting the quaternion from a rotation matrix. 139 | Itzhack Y Bar-Itzhack, J Guid Contr Dynam. 2000. 23(6): 1085-1087. 140 | (15) Multiple View Geometry in Computer Vision. Hartley and Zissermann. 141 | Cambridge University Press; 2nd Ed. 2004. Chapter 4, Algorithm 4.7, p 130. 142 | (16) Column Vectors vs. Row Vectors. 143 | http://steve.hollasch.net/cgindex/math/matrix/column-vec.html 144 | 145 | Examples 146 | -------- 147 | >>> alpha, beta, gamma = 0.123, -1.234, 2.345 148 | >>> origin, xaxis, yaxis, zaxis = [0, 0, 0], [1, 0, 0], [0, 1, 0], [0, 0, 1] 149 | >>> I = identity_matrix() 150 | >>> Rx = rotation_matrix(alpha, xaxis) 151 | >>> Ry = rotation_matrix(beta, yaxis) 152 | >>> Rz = rotation_matrix(gamma, zaxis) 153 | >>> R = concatenate_matrices(Rx, Ry, Rz) 154 | >>> euler = euler_from_matrix(R, 'rxyz') 155 | >>> numpy.allclose([alpha, beta, gamma], euler) 156 | True 157 | >>> Re = euler_matrix(alpha, beta, gamma, 'rxyz') 158 | >>> is_same_transform(R, Re) 159 | True 160 | >>> al, be, ga = euler_from_matrix(Re, 'rxyz') 161 | >>> is_same_transform(Re, euler_matrix(al, be, ga, 'rxyz')) 162 | True 163 | >>> qx = quaternion_about_axis(alpha, xaxis) 164 | >>> qy = quaternion_about_axis(beta, yaxis) 165 | >>> qz = quaternion_about_axis(gamma, zaxis) 166 | >>> q = quaternion_multiply(qx, qy) 167 | >>> q = quaternion_multiply(q, qz) 168 | >>> Rq = quaternion_matrix(q) 169 | >>> is_same_transform(R, Rq) 170 | True 171 | >>> S = scale_matrix(1.23, origin) 172 | >>> T = translation_matrix([1, 2, 3]) 173 | >>> Z = shear_matrix(beta, xaxis, origin, zaxis) 174 | >>> R = random_rotation_matrix(numpy.random.rand(3)) 175 | >>> M = concatenate_matrices(T, R, Z, S) 176 | >>> scale, shear, angles, trans, persp = decompose_matrix(M) 177 | >>> numpy.allclose(scale, 1.23) 178 | True 179 | >>> numpy.allclose(trans, [1, 2, 3]) 180 | True 181 | >>> numpy.allclose(shear, [0, math.tan(beta), 0]) 182 | True 183 | >>> is_same_transform(R, euler_matrix(axes='sxyz', *angles)) 184 | True 185 | >>> M1 = compose_matrix(scale, shear, angles, trans, persp) 186 | >>> is_same_transform(M, M1) 187 | True 188 | >>> v0, v1 = random_vector(3), random_vector(3) 189 | >>> M = rotation_matrix(angle_between_vectors(v0, v1), vector_product(v0, v1)) 190 | >>> v2 = numpy.dot(v0, M[:3,:3].T) 191 | >>> numpy.allclose(unit_vector(v1), unit_vector(v2)) 192 | True 193 | 194 | """ 195 | 196 | import math 197 | 198 | import numpy 199 | 200 | __version__ = '2015.07.18' 201 | __docformat__ = 'restructuredtext en' 202 | __all__ = () 203 | 204 | 205 | def identity_matrix(): 206 | """Return 4x4 identity/unit matrix. 207 | 208 | >>> I = identity_matrix() 209 | >>> numpy.allclose(I, numpy.dot(I, I)) 210 | True 211 | >>> numpy.sum(I), numpy.trace(I) 212 | (4.0, 4.0) 213 | >>> numpy.allclose(I, numpy.identity(4)) 214 | True 215 | 216 | """ 217 | return numpy.identity(4) 218 | 219 | 220 | def translation_matrix(direction): 221 | """Return matrix to translate by direction vector. 222 | 223 | >>> v = numpy.random.random(3) - 0.5 224 | >>> numpy.allclose(v, translation_matrix(v)[:3, 3]) 225 | True 226 | 227 | """ 228 | M = numpy.identity(4) 229 | M[:3, 3] = direction[:3] 230 | return M 231 | 232 | 233 | def translation_from_matrix(matrix): 234 | """Return translation vector from translation matrix. 235 | 236 | >>> v0 = numpy.random.random(3) - 0.5 237 | >>> v1 = translation_from_matrix(translation_matrix(v0)) 238 | >>> numpy.allclose(v0, v1) 239 | True 240 | 241 | """ 242 | return numpy.array(matrix, copy=False)[:3, 3].copy() 243 | 244 | 245 | def reflection_matrix(point, normal): 246 | """Return matrix to mirror at plane defined by point and normal vector. 247 | 248 | >>> v0 = numpy.random.random(4) - 0.5 249 | >>> v0[3] = 1. 250 | >>> v1 = numpy.random.random(3) - 0.5 251 | >>> R = reflection_matrix(v0, v1) 252 | >>> numpy.allclose(2, numpy.trace(R)) 253 | True 254 | >>> numpy.allclose(v0, numpy.dot(R, v0)) 255 | True 256 | >>> v2 = v0.copy() 257 | >>> v2[:3] += v1 258 | >>> v3 = v0.copy() 259 | >>> v2[:3] -= v1 260 | >>> numpy.allclose(v2, numpy.dot(R, v3)) 261 | True 262 | 263 | """ 264 | normal = unit_vector(normal[:3]) 265 | M = numpy.identity(4) 266 | M[:3, :3] -= 2.0 * numpy.outer(normal, normal) 267 | M[:3, 3] = (2.0 * numpy.dot(point[:3], normal)) * normal 268 | return M 269 | 270 | 271 | def reflection_from_matrix(matrix): 272 | """Return mirror plane point and normal vector from reflection matrix. 273 | 274 | >>> v0 = numpy.random.random(3) - 0.5 275 | >>> v1 = numpy.random.random(3) - 0.5 276 | >>> M0 = reflection_matrix(v0, v1) 277 | >>> point, normal = reflection_from_matrix(M0) 278 | >>> M1 = reflection_matrix(point, normal) 279 | >>> is_same_transform(M0, M1) 280 | True 281 | 282 | """ 283 | M = numpy.array(matrix, dtype=numpy.float64, copy=False) 284 | # normal: unit eigenvector corresponding to eigenvalue -1 285 | w, V = numpy.linalg.eig(M[:3, :3]) 286 | i = numpy.where(abs(numpy.real(w) + 1.0) < 1e-8)[0] 287 | if not len(i): 288 | raise ValueError("no unit eigenvector corresponding to eigenvalue -1") 289 | normal = numpy.real(V[:, i[0]]).squeeze() 290 | # point: any unit eigenvector corresponding to eigenvalue 1 291 | w, V = numpy.linalg.eig(M) 292 | i = numpy.where(abs(numpy.real(w) - 1.0) < 1e-8)[0] 293 | if not len(i): 294 | raise ValueError("no unit eigenvector corresponding to eigenvalue 1") 295 | point = numpy.real(V[:, i[-1]]).squeeze() 296 | point /= point[3] 297 | return point, normal 298 | 299 | 300 | def rotation_matrix(angle, direction, point=None): 301 | """Return matrix to rotate about axis defined by point and direction. 302 | 303 | >>> R = rotation_matrix(math.pi/2, [0, 0, 1], [1, 0, 0]) 304 | >>> numpy.allclose(numpy.dot(R, [0, 0, 0, 1]), [1, -1, 0, 1]) 305 | True 306 | >>> angle = (random.random() - 0.5) * (2*math.pi) 307 | >>> direc = numpy.random.random(3) - 0.5 308 | >>> point = numpy.random.random(3) - 0.5 309 | >>> R0 = rotation_matrix(angle, direc, point) 310 | >>> R1 = rotation_matrix(angle-2*math.pi, direc, point) 311 | >>> is_same_transform(R0, R1) 312 | True 313 | >>> R0 = rotation_matrix(angle, direc, point) 314 | >>> R1 = rotation_matrix(-angle, -direc, point) 315 | >>> is_same_transform(R0, R1) 316 | True 317 | >>> I = numpy.identity(4, numpy.float64) 318 | >>> numpy.allclose(I, rotation_matrix(math.pi*2, direc)) 319 | True 320 | >>> numpy.allclose(2, numpy.trace(rotation_matrix(math.pi/2, 321 | ... direc, point))) 322 | True 323 | 324 | """ 325 | sina = math.sin(angle) 326 | cosa = math.cos(angle) 327 | direction = unit_vector(direction[:3]) 328 | # rotation matrix around unit vector 329 | R = numpy.diag([cosa, cosa, cosa]) 330 | R += numpy.outer(direction, direction) * (1.0 - cosa) 331 | direction *= sina 332 | R += numpy.array([[ 0.0, -direction[2], direction[1]], 333 | [ direction[2], 0.0, -direction[0]], 334 | [-direction[1], direction[0], 0.0]]) 335 | M = numpy.identity(4) 336 | M[:3, :3] = R 337 | if point is not None: 338 | # rotation not around origin 339 | point = numpy.array(point[:3], dtype=numpy.float64, copy=False) 340 | M[:3, 3] = point - numpy.dot(R, point) 341 | return M 342 | 343 | 344 | def rotation_from_matrix(matrix): 345 | """Return rotation angle and axis from rotation matrix. 346 | 347 | >>> angle = (random.random() - 0.5) * (2*math.pi) 348 | >>> direc = numpy.random.random(3) - 0.5 349 | >>> point = numpy.random.random(3) - 0.5 350 | >>> R0 = rotation_matrix(angle, direc, point) 351 | >>> angle, direc, point = rotation_from_matrix(R0) 352 | >>> R1 = rotation_matrix(angle, direc, point) 353 | >>> is_same_transform(R0, R1) 354 | True 355 | 356 | """ 357 | R = numpy.array(matrix, dtype=numpy.float64, copy=False) 358 | R33 = R[:3, :3] 359 | # direction: unit eigenvector of R33 corresponding to eigenvalue of 1 360 | w, W = numpy.linalg.eig(R33.T) 361 | i = numpy.where(abs(numpy.real(w) - 1.0) < 1e-8)[0] 362 | if not len(i): 363 | raise ValueError("no unit eigenvector corresponding to eigenvalue 1") 364 | direction = numpy.real(W[:, i[-1]]).squeeze() 365 | # point: unit eigenvector of R33 corresponding to eigenvalue of 1 366 | w, Q = numpy.linalg.eig(R) 367 | i = numpy.where(abs(numpy.real(w) - 1.0) < 1e-8)[0] 368 | if not len(i): 369 | raise ValueError("no unit eigenvector corresponding to eigenvalue 1") 370 | point = numpy.real(Q[:, i[-1]]).squeeze() 371 | point /= point[3] 372 | # rotation angle depending on direction 373 | cosa = (numpy.trace(R33) - 1.0) / 2.0 374 | if abs(direction[2]) > 1e-8: 375 | sina = (R[1, 0] + (cosa-1.0)*direction[0]*direction[1]) / direction[2] 376 | elif abs(direction[1]) > 1e-8: 377 | sina = (R[0, 2] + (cosa-1.0)*direction[0]*direction[2]) / direction[1] 378 | else: 379 | sina = (R[2, 1] + (cosa-1.0)*direction[1]*direction[2]) / direction[0] 380 | angle = math.atan2(sina, cosa) 381 | return angle, direction, point 382 | 383 | 384 | def scale_matrix(factor, origin=None, direction=None): 385 | """Return matrix to scale by factor around origin in direction. 386 | 387 | Use factor -1 for point symmetry. 388 | 389 | >>> v = (numpy.random.rand(4, 5) - 0.5) * 20 390 | >>> v[3] = 1 391 | >>> S = scale_matrix(-1.234) 392 | >>> numpy.allclose(numpy.dot(S, v)[:3], -1.234*v[:3]) 393 | True 394 | >>> factor = random.random() * 10 - 5 395 | >>> origin = numpy.random.random(3) - 0.5 396 | >>> direct = numpy.random.random(3) - 0.5 397 | >>> S = scale_matrix(factor, origin) 398 | >>> S = scale_matrix(factor, origin, direct) 399 | 400 | """ 401 | if direction is None: 402 | # uniform scaling 403 | M = numpy.diag([factor, factor, factor, 1.0]) 404 | if origin is not None: 405 | M[:3, 3] = origin[:3] 406 | M[:3, 3] *= 1.0 - factor 407 | else: 408 | # nonuniform scaling 409 | direction = unit_vector(direction[:3]) 410 | factor = 1.0 - factor 411 | M = numpy.identity(4) 412 | M[:3, :3] -= factor * numpy.outer(direction, direction) 413 | if origin is not None: 414 | M[:3, 3] = (factor * numpy.dot(origin[:3], direction)) * direction 415 | return M 416 | 417 | 418 | def scale_from_matrix(matrix): 419 | """Return scaling factor, origin and direction from scaling matrix. 420 | 421 | >>> factor = random.random() * 10 - 5 422 | >>> origin = numpy.random.random(3) - 0.5 423 | >>> direct = numpy.random.random(3) - 0.5 424 | >>> S0 = scale_matrix(factor, origin) 425 | >>> factor, origin, direction = scale_from_matrix(S0) 426 | >>> S1 = scale_matrix(factor, origin, direction) 427 | >>> is_same_transform(S0, S1) 428 | True 429 | >>> S0 = scale_matrix(factor, origin, direct) 430 | >>> factor, origin, direction = scale_from_matrix(S0) 431 | >>> S1 = scale_matrix(factor, origin, direction) 432 | >>> is_same_transform(S0, S1) 433 | True 434 | 435 | """ 436 | M = numpy.array(matrix, dtype=numpy.float64, copy=False) 437 | M33 = M[:3, :3] 438 | factor = numpy.trace(M33) - 2.0 439 | try: 440 | # direction: unit eigenvector corresponding to eigenvalue factor 441 | w, V = numpy.linalg.eig(M33) 442 | i = numpy.where(abs(numpy.real(w) - factor) < 1e-8)[0][0] 443 | direction = numpy.real(V[:, i]).squeeze() 444 | direction /= vector_norm(direction) 445 | except IndexError: 446 | # uniform scaling 447 | factor = (factor + 2.0) / 3.0 448 | direction = None 449 | # origin: any eigenvector corresponding to eigenvalue 1 450 | w, V = numpy.linalg.eig(M) 451 | i = numpy.where(abs(numpy.real(w) - 1.0) < 1e-8)[0] 452 | if not len(i): 453 | raise ValueError("no eigenvector corresponding to eigenvalue 1") 454 | origin = numpy.real(V[:, i[-1]]).squeeze() 455 | origin /= origin[3] 456 | return factor, origin, direction 457 | 458 | 459 | def projection_matrix(point, normal, direction=None, 460 | perspective=None, pseudo=False): 461 | """Return matrix to project onto plane defined by point and normal. 462 | 463 | Using either perspective point, projection direction, or none of both. 464 | 465 | If pseudo is True, perspective projections will preserve relative depth 466 | such that Perspective = dot(Orthogonal, PseudoPerspective). 467 | 468 | >>> P = projection_matrix([0, 0, 0], [1, 0, 0]) 469 | >>> numpy.allclose(P[1:, 1:], numpy.identity(4)[1:, 1:]) 470 | True 471 | >>> point = numpy.random.random(3) - 0.5 472 | >>> normal = numpy.random.random(3) - 0.5 473 | >>> direct = numpy.random.random(3) - 0.5 474 | >>> persp = numpy.random.random(3) - 0.5 475 | >>> P0 = projection_matrix(point, normal) 476 | >>> P1 = projection_matrix(point, normal, direction=direct) 477 | >>> P2 = projection_matrix(point, normal, perspective=persp) 478 | >>> P3 = projection_matrix(point, normal, perspective=persp, pseudo=True) 479 | >>> is_same_transform(P2, numpy.dot(P0, P3)) 480 | True 481 | >>> P = projection_matrix([3, 0, 0], [1, 1, 0], [1, 0, 0]) 482 | >>> v0 = (numpy.random.rand(4, 5) - 0.5) * 20 483 | >>> v0[3] = 1 484 | >>> v1 = numpy.dot(P, v0) 485 | >>> numpy.allclose(v1[1], v0[1]) 486 | True 487 | >>> numpy.allclose(v1[0], 3-v1[1]) 488 | True 489 | 490 | """ 491 | M = numpy.identity(4) 492 | point = numpy.array(point[:3], dtype=numpy.float64, copy=False) 493 | normal = unit_vector(normal[:3]) 494 | if perspective is not None: 495 | # perspective projection 496 | perspective = numpy.array(perspective[:3], dtype=numpy.float64, 497 | copy=False) 498 | M[0, 0] = M[1, 1] = M[2, 2] = numpy.dot(perspective-point, normal) 499 | M[:3, :3] -= numpy.outer(perspective, normal) 500 | if pseudo: 501 | # preserve relative depth 502 | M[:3, :3] -= numpy.outer(normal, normal) 503 | M[:3, 3] = numpy.dot(point, normal) * (perspective+normal) 504 | else: 505 | M[:3, 3] = numpy.dot(point, normal) * perspective 506 | M[3, :3] = -normal 507 | M[3, 3] = numpy.dot(perspective, normal) 508 | elif direction is not None: 509 | # parallel projection 510 | direction = numpy.array(direction[:3], dtype=numpy.float64, copy=False) 511 | scale = numpy.dot(direction, normal) 512 | M[:3, :3] -= numpy.outer(direction, normal) / scale 513 | M[:3, 3] = direction * (numpy.dot(point, normal) / scale) 514 | else: 515 | # orthogonal projection 516 | M[:3, :3] -= numpy.outer(normal, normal) 517 | M[:3, 3] = numpy.dot(point, normal) * normal 518 | return M 519 | 520 | 521 | def projection_from_matrix(matrix, pseudo=False): 522 | """Return projection plane and perspective point from projection matrix. 523 | 524 | Return values are same as arguments for projection_matrix function: 525 | point, normal, direction, perspective, and pseudo. 526 | 527 | >>> point = numpy.random.random(3) - 0.5 528 | >>> normal = numpy.random.random(3) - 0.5 529 | >>> direct = numpy.random.random(3) - 0.5 530 | >>> persp = numpy.random.random(3) - 0.5 531 | >>> P0 = projection_matrix(point, normal) 532 | >>> result = projection_from_matrix(P0) 533 | >>> P1 = projection_matrix(*result) 534 | >>> is_same_transform(P0, P1) 535 | True 536 | >>> P0 = projection_matrix(point, normal, direct) 537 | >>> result = projection_from_matrix(P0) 538 | >>> P1 = projection_matrix(*result) 539 | >>> is_same_transform(P0, P1) 540 | True 541 | >>> P0 = projection_matrix(point, normal, perspective=persp, pseudo=False) 542 | >>> result = projection_from_matrix(P0, pseudo=False) 543 | >>> P1 = projection_matrix(*result) 544 | >>> is_same_transform(P0, P1) 545 | True 546 | >>> P0 = projection_matrix(point, normal, perspective=persp, pseudo=True) 547 | >>> result = projection_from_matrix(P0, pseudo=True) 548 | >>> P1 = projection_matrix(*result) 549 | >>> is_same_transform(P0, P1) 550 | True 551 | 552 | """ 553 | M = numpy.array(matrix, dtype=numpy.float64, copy=False) 554 | M33 = M[:3, :3] 555 | w, V = numpy.linalg.eig(M) 556 | i = numpy.where(abs(numpy.real(w) - 1.0) < 1e-8)[0] 557 | if not pseudo and len(i): 558 | # point: any eigenvector corresponding to eigenvalue 1 559 | point = numpy.real(V[:, i[-1]]).squeeze() 560 | point /= point[3] 561 | # direction: unit eigenvector corresponding to eigenvalue 0 562 | w, V = numpy.linalg.eig(M33) 563 | i = numpy.where(abs(numpy.real(w)) < 1e-8)[0] 564 | if not len(i): 565 | raise ValueError("no eigenvector corresponding to eigenvalue 0") 566 | direction = numpy.real(V[:, i[0]]).squeeze() 567 | direction /= vector_norm(direction) 568 | # normal: unit eigenvector of M33.T corresponding to eigenvalue 0 569 | w, V = numpy.linalg.eig(M33.T) 570 | i = numpy.where(abs(numpy.real(w)) < 1e-8)[0] 571 | if len(i): 572 | # parallel projection 573 | normal = numpy.real(V[:, i[0]]).squeeze() 574 | normal /= vector_norm(normal) 575 | return point, normal, direction, None, False 576 | else: 577 | # orthogonal projection, where normal equals direction vector 578 | return point, direction, None, None, False 579 | else: 580 | # perspective projection 581 | i = numpy.where(abs(numpy.real(w)) > 1e-8)[0] 582 | if not len(i): 583 | raise ValueError( 584 | "no eigenvector not corresponding to eigenvalue 0") 585 | point = numpy.real(V[:, i[-1]]).squeeze() 586 | point /= point[3] 587 | normal = - M[3, :3] 588 | perspective = M[:3, 3] / numpy.dot(point[:3], normal) 589 | if pseudo: 590 | perspective -= normal 591 | return point, normal, None, perspective, pseudo 592 | 593 | 594 | def clip_matrix(left, right, bottom, top, near, far, perspective=False): 595 | """Return matrix to obtain normalized device coordinates from frustum. 596 | 597 | The frustum bounds are axis-aligned along x (left, right), 598 | y (bottom, top) and z (near, far). 599 | 600 | Normalized device coordinates are in range [-1, 1] if coordinates are 601 | inside the frustum. 602 | 603 | If perspective is True the frustum is a truncated pyramid with the 604 | perspective point at origin and direction along z axis, otherwise an 605 | orthographic canonical view volume (a box). 606 | 607 | Homogeneous coordinates transformed by the perspective clip matrix 608 | need to be dehomogenized (divided by w coordinate). 609 | 610 | >>> frustum = numpy.random.rand(6) 611 | >>> frustum[1] += frustum[0] 612 | >>> frustum[3] += frustum[2] 613 | >>> frustum[5] += frustum[4] 614 | >>> M = clip_matrix(perspective=False, *frustum) 615 | >>> numpy.dot(M, [frustum[0], frustum[2], frustum[4], 1]) 616 | array([-1., -1., -1., 1.]) 617 | >>> numpy.dot(M, [frustum[1], frustum[3], frustum[5], 1]) 618 | array([ 1., 1., 1., 1.]) 619 | >>> M = clip_matrix(perspective=True, *frustum) 620 | >>> v = numpy.dot(M, [frustum[0], frustum[2], frustum[4], 1]) 621 | >>> v / v[3] 622 | array([-1., -1., -1., 1.]) 623 | >>> v = numpy.dot(M, [frustum[1], frustum[3], frustum[4], 1]) 624 | >>> v / v[3] 625 | array([ 1., 1., -1., 1.]) 626 | 627 | """ 628 | if left >= right or bottom >= top or near >= far: 629 | raise ValueError("invalid frustum") 630 | if perspective: 631 | if near <= _EPS: 632 | raise ValueError("invalid frustum: near <= 0") 633 | t = 2.0 * near 634 | M = [[t/(left-right), 0.0, (right+left)/(right-left), 0.0], 635 | [0.0, t/(bottom-top), (top+bottom)/(top-bottom), 0.0], 636 | [0.0, 0.0, (far+near)/(near-far), t*far/(far-near)], 637 | [0.0, 0.0, -1.0, 0.0]] 638 | else: 639 | M = [[2.0/(right-left), 0.0, 0.0, (right+left)/(left-right)], 640 | [0.0, 2.0/(top-bottom), 0.0, (top+bottom)/(bottom-top)], 641 | [0.0, 0.0, 2.0/(far-near), (far+near)/(near-far)], 642 | [0.0, 0.0, 0.0, 1.0]] 643 | return numpy.array(M) 644 | 645 | 646 | def shear_matrix(angle, direction, point, normal): 647 | """Return matrix to shear by angle along direction vector on shear plane. 648 | 649 | The shear plane is defined by a point and normal vector. The direction 650 | vector must be orthogonal to the plane's normal vector. 651 | 652 | A point P is transformed by the shear matrix into P" such that 653 | the vector P-P" is parallel to the direction vector and its extent is 654 | given by the angle of P-P'-P", where P' is the orthogonal projection 655 | of P onto the shear plane. 656 | 657 | >>> angle = (random.random() - 0.5) * 4*math.pi 658 | >>> direct = numpy.random.random(3) - 0.5 659 | >>> point = numpy.random.random(3) - 0.5 660 | >>> normal = numpy.cross(direct, numpy.random.random(3)) 661 | >>> S = shear_matrix(angle, direct, point, normal) 662 | >>> numpy.allclose(1, numpy.linalg.det(S)) 663 | True 664 | 665 | """ 666 | normal = unit_vector(normal[:3]) 667 | direction = unit_vector(direction[:3]) 668 | if abs(numpy.dot(normal, direction)) > 1e-6: 669 | raise ValueError("direction and normal vectors are not orthogonal") 670 | angle = math.tan(angle) 671 | M = numpy.identity(4) 672 | M[:3, :3] += angle * numpy.outer(direction, normal) 673 | M[:3, 3] = -angle * numpy.dot(point[:3], normal) * direction 674 | return M 675 | 676 | 677 | def shear_from_matrix(matrix): 678 | """Return shear angle, direction and plane from shear matrix. 679 | 680 | >>> angle = (random.random() - 0.5) * 4*math.pi 681 | >>> direct = numpy.random.random(3) - 0.5 682 | >>> point = numpy.random.random(3) - 0.5 683 | >>> normal = numpy.cross(direct, numpy.random.random(3)) 684 | >>> S0 = shear_matrix(angle, direct, point, normal) 685 | >>> angle, direct, point, normal = shear_from_matrix(S0) 686 | >>> S1 = shear_matrix(angle, direct, point, normal) 687 | >>> is_same_transform(S0, S1) 688 | True 689 | 690 | """ 691 | M = numpy.array(matrix, dtype=numpy.float64, copy=False) 692 | M33 = M[:3, :3] 693 | # normal: cross independent eigenvectors corresponding to the eigenvalue 1 694 | w, V = numpy.linalg.eig(M33) 695 | i = numpy.where(abs(numpy.real(w) - 1.0) < 1e-4)[0] 696 | if len(i) < 2: 697 | raise ValueError("no two linear independent eigenvectors found %s" % w) 698 | V = numpy.real(V[:, i]).squeeze().T 699 | lenorm = -1.0 700 | for i0, i1 in ((0, 1), (0, 2), (1, 2)): 701 | n = numpy.cross(V[i0], V[i1]) 702 | w = vector_norm(n) 703 | if w > lenorm: 704 | lenorm = w 705 | normal = n 706 | normal /= lenorm 707 | # direction and angle 708 | direction = numpy.dot(M33 - numpy.identity(3), normal) 709 | angle = vector_norm(direction) 710 | direction /= angle 711 | angle = math.atan(angle) 712 | # point: eigenvector corresponding to eigenvalue 1 713 | w, V = numpy.linalg.eig(M) 714 | i = numpy.where(abs(numpy.real(w) - 1.0) < 1e-8)[0] 715 | if not len(i): 716 | raise ValueError("no eigenvector corresponding to eigenvalue 1") 717 | point = numpy.real(V[:, i[-1]]).squeeze() 718 | point /= point[3] 719 | return angle, direction, point, normal 720 | 721 | 722 | def decompose_matrix(matrix): 723 | """Return sequence of transformations from transformation matrix. 724 | 725 | matrix : array_like 726 | Non-degenerative homogeneous transformation matrix 727 | 728 | Return tuple of: 729 | scale : vector of 3 scaling factors 730 | shear : list of shear factors for x-y, x-z, y-z axes 731 | angles : list of Euler angles about static x, y, z axes 732 | translate : translation vector along x, y, z axes 733 | perspective : perspective partition of matrix 734 | 735 | Raise ValueError if matrix is of wrong type or degenerative. 736 | 737 | >>> T0 = translation_matrix([1, 2, 3]) 738 | >>> scale, shear, angles, trans, persp = decompose_matrix(T0) 739 | >>> T1 = translation_matrix(trans) 740 | >>> numpy.allclose(T0, T1) 741 | True 742 | >>> S = scale_matrix(0.123) 743 | >>> scale, shear, angles, trans, persp = decompose_matrix(S) 744 | >>> scale[0] 745 | 0.123 746 | >>> R0 = euler_matrix(1, 2, 3) 747 | >>> scale, shear, angles, trans, persp = decompose_matrix(R0) 748 | >>> R1 = euler_matrix(*angles) 749 | >>> numpy.allclose(R0, R1) 750 | True 751 | 752 | """ 753 | M = numpy.array(matrix, dtype=numpy.float64, copy=True).T 754 | if abs(M[3, 3]) < _EPS: 755 | raise ValueError("M[3, 3] is zero") 756 | M /= M[3, 3] 757 | P = M.copy() 758 | P[:, 3] = 0.0, 0.0, 0.0, 1.0 759 | if not numpy.linalg.det(P): 760 | raise ValueError("matrix is singular") 761 | 762 | scale = numpy.zeros((3, )) 763 | shear = [0.0, 0.0, 0.0] 764 | angles = [0.0, 0.0, 0.0] 765 | 766 | if any(abs(M[:3, 3]) > _EPS): 767 | perspective = numpy.dot(M[:, 3], numpy.linalg.inv(P.T)) 768 | M[:, 3] = 0.0, 0.0, 0.0, 1.0 769 | else: 770 | perspective = numpy.array([0.0, 0.0, 0.0, 1.0]) 771 | 772 | translate = M[3, :3].copy() 773 | M[3, :3] = 0.0 774 | 775 | row = M[:3, :3].copy() 776 | scale[0] = vector_norm(row[0]) 777 | row[0] /= scale[0] 778 | shear[0] = numpy.dot(row[0], row[1]) 779 | row[1] -= row[0] * shear[0] 780 | scale[1] = vector_norm(row[1]) 781 | row[1] /= scale[1] 782 | shear[0] /= scale[1] 783 | shear[1] = numpy.dot(row[0], row[2]) 784 | row[2] -= row[0] * shear[1] 785 | shear[2] = numpy.dot(row[1], row[2]) 786 | row[2] -= row[1] * shear[2] 787 | scale[2] = vector_norm(row[2]) 788 | row[2] /= scale[2] 789 | shear[1:] /= scale[2] 790 | 791 | if numpy.dot(row[0], numpy.cross(row[1], row[2])) < 0: 792 | numpy.negative(scale, scale) 793 | numpy.negative(row, row) 794 | 795 | angles[1] = math.asin(-row[0, 2]) 796 | if math.cos(angles[1]): 797 | angles[0] = math.atan2(row[1, 2], row[2, 2]) 798 | angles[2] = math.atan2(row[0, 1], row[0, 0]) 799 | else: 800 | #angles[0] = math.atan2(row[1, 0], row[1, 1]) 801 | angles[0] = math.atan2(-row[2, 1], row[1, 1]) 802 | angles[2] = 0.0 803 | 804 | return scale, shear, angles, translate, perspective 805 | 806 | 807 | def compose_matrix(scale=None, shear=None, angles=None, translate=None, 808 | perspective=None): 809 | """Return transformation matrix from sequence of transformations. 810 | 811 | This is the inverse of the decompose_matrix function. 812 | 813 | Sequence of transformations: 814 | scale : vector of 3 scaling factors 815 | shear : list of shear factors for x-y, x-z, y-z axes 816 | angles : list of Euler angles about static x, y, z axes 817 | translate : translation vector along x, y, z axes 818 | perspective : perspective partition of matrix 819 | 820 | >>> scale = numpy.random.random(3) - 0.5 821 | >>> shear = numpy.random.random(3) - 0.5 822 | >>> angles = (numpy.random.random(3) - 0.5) * (2*math.pi) 823 | >>> trans = numpy.random.random(3) - 0.5 824 | >>> persp = numpy.random.random(4) - 0.5 825 | >>> M0 = compose_matrix(scale, shear, angles, trans, persp) 826 | >>> result = decompose_matrix(M0) 827 | >>> M1 = compose_matrix(*result) 828 | >>> is_same_transform(M0, M1) 829 | True 830 | 831 | """ 832 | M = numpy.identity(4) 833 | if perspective is not None: 834 | P = numpy.identity(4) 835 | P[3, :] = perspective[:4] 836 | M = numpy.dot(M, P) 837 | if translate is not None: 838 | T = numpy.identity(4) 839 | T[:3, 3] = translate[:3] 840 | M = numpy.dot(M, T) 841 | if angles is not None: 842 | R = euler_matrix(angles[0], angles[1], angles[2], 'sxyz') 843 | M = numpy.dot(M, R) 844 | if shear is not None: 845 | Z = numpy.identity(4) 846 | Z[1, 2] = shear[2] 847 | Z[0, 2] = shear[1] 848 | Z[0, 1] = shear[0] 849 | M = numpy.dot(M, Z) 850 | if scale is not None: 851 | S = numpy.identity(4) 852 | S[0, 0] = scale[0] 853 | S[1, 1] = scale[1] 854 | S[2, 2] = scale[2] 855 | M = numpy.dot(M, S) 856 | M /= M[3, 3] 857 | return M 858 | 859 | 860 | def orthogonalization_matrix(lengths, angles): 861 | """Return orthogonalization matrix for crystallographic cell coordinates. 862 | 863 | Angles are expected in degrees. 864 | 865 | The de-orthogonalization matrix is the inverse. 866 | 867 | >>> O = orthogonalization_matrix([10, 10, 10], [90, 90, 90]) 868 | >>> numpy.allclose(O[:3, :3], numpy.identity(3, float) * 10) 869 | True 870 | >>> O = orthogonalization_matrix([9.8, 12.0, 15.5], [87.2, 80.7, 69.7]) 871 | >>> numpy.allclose(numpy.sum(O), 43.063229) 872 | True 873 | 874 | """ 875 | a, b, c = lengths 876 | angles = numpy.radians(angles) 877 | sina, sinb, _ = numpy.sin(angles) 878 | cosa, cosb, cosg = numpy.cos(angles) 879 | co = (cosa * cosb - cosg) / (sina * sinb) 880 | return numpy.array([ 881 | [ a*sinb*math.sqrt(1.0-co*co), 0.0, 0.0, 0.0], 882 | [-a*sinb*co, b*sina, 0.0, 0.0], 883 | [ a*cosb, b*cosa, c, 0.0], 884 | [ 0.0, 0.0, 0.0, 1.0]]) 885 | 886 | 887 | def affine_matrix_from_points(v0, v1, shear=True, scale=True, usesvd=True): 888 | """Return affine transform matrix to register two point sets. 889 | 890 | v0 and v1 are shape (ndims, *) arrays of at least ndims non-homogeneous 891 | coordinates, where ndims is the dimensionality of the coordinate space. 892 | 893 | If shear is False, a similarity transformation matrix is returned. 894 | If also scale is False, a rigid/Euclidean transformation matrix 895 | is returned. 896 | 897 | By default the algorithm by Hartley and Zissermann [15] is used. 898 | If usesvd is True, similarity and Euclidean transformation matrices 899 | are calculated by minimizing the weighted sum of squared deviations 900 | (RMSD) according to the algorithm by Kabsch [8]. 901 | Otherwise, and if ndims is 3, the quaternion based algorithm by Horn [9] 902 | is used, which is slower when using this Python implementation. 903 | 904 | The returned matrix performs rotation, translation and uniform scaling 905 | (if specified). 906 | 907 | >>> v0 = [[0, 1031, 1031, 0], [0, 0, 1600, 1600]] 908 | >>> v1 = [[675, 826, 826, 677], [55, 52, 281, 277]] 909 | >>> affine_matrix_from_points(v0, v1) 910 | array([[ 0.14549, 0.00062, 675.50008], 911 | [ 0.00048, 0.14094, 53.24971], 912 | [ 0. , 0. , 1. ]]) 913 | >>> T = translation_matrix(numpy.random.random(3)-0.5) 914 | >>> R = random_rotation_matrix(numpy.random.random(3)) 915 | >>> S = scale_matrix(random.random()) 916 | >>> M = concatenate_matrices(T, R, S) 917 | >>> v0 = (numpy.random.rand(4, 100) - 0.5) * 20 918 | >>> v0[3] = 1 919 | >>> v1 = numpy.dot(M, v0) 920 | >>> v0[:3] += numpy.random.normal(0, 1e-8, 300).reshape(3, -1) 921 | >>> M = affine_matrix_from_points(v0[:3], v1[:3]) 922 | >>> numpy.allclose(v1, numpy.dot(M, v0)) 923 | True 924 | 925 | More examples in superimposition_matrix() 926 | 927 | """ 928 | v0 = numpy.array(v0, dtype=numpy.float64, copy=True) 929 | v1 = numpy.array(v1, dtype=numpy.float64, copy=True) 930 | 931 | ndims = v0.shape[0] 932 | if ndims < 2 or v0.shape[1] < ndims or v0.shape != v1.shape: 933 | raise ValueError("input arrays are of wrong shape or type") 934 | 935 | # move centroids to origin 936 | t0 = -numpy.mean(v0, axis=1) 937 | M0 = numpy.identity(ndims+1) 938 | M0[:ndims, ndims] = t0 939 | v0 += t0.reshape(ndims, 1) 940 | t1 = -numpy.mean(v1, axis=1) 941 | M1 = numpy.identity(ndims+1) 942 | M1[:ndims, ndims] = t1 943 | v1 += t1.reshape(ndims, 1) 944 | 945 | if shear: 946 | # Affine transformation 947 | A = numpy.concatenate((v0, v1), axis=0) 948 | u, s, vh = numpy.linalg.svd(A.T) 949 | vh = vh[:ndims].T 950 | B = vh[:ndims] 951 | C = vh[ndims:2*ndims] 952 | t = numpy.dot(C, numpy.linalg.pinv(B)) 953 | t = numpy.concatenate((t, numpy.zeros((ndims, 1))), axis=1) 954 | M = numpy.vstack((t, ((0.0,)*ndims) + (1.0,))) 955 | elif usesvd or ndims != 3: 956 | # Rigid transformation via SVD of covariance matrix 957 | u, s, vh = numpy.linalg.svd(numpy.dot(v1, v0.T)) 958 | # rotation matrix from SVD orthonormal bases 959 | R = numpy.dot(u, vh) 960 | if numpy.linalg.det(R) < 0.0: 961 | # R does not constitute right handed system 962 | R -= numpy.outer(u[:, ndims-1], vh[ndims-1, :]*2.0) 963 | s[-1] *= -1.0 964 | # homogeneous transformation matrix 965 | M = numpy.identity(ndims+1) 966 | M[:ndims, :ndims] = R 967 | else: 968 | # Rigid transformation matrix via quaternion 969 | # compute symmetric matrix N 970 | xx, yy, zz = numpy.sum(v0 * v1, axis=1) 971 | xy, yz, zx = numpy.sum(v0 * numpy.roll(v1, -1, axis=0), axis=1) 972 | xz, yx, zy = numpy.sum(v0 * numpy.roll(v1, -2, axis=0), axis=1) 973 | N = [[xx+yy+zz, 0.0, 0.0, 0.0], 974 | [yz-zy, xx-yy-zz, 0.0, 0.0], 975 | [zx-xz, xy+yx, yy-xx-zz, 0.0], 976 | [xy-yx, zx+xz, yz+zy, zz-xx-yy]] 977 | # quaternion: eigenvector corresponding to most positive eigenvalue 978 | w, V = numpy.linalg.eigh(N) 979 | q = V[:, numpy.argmax(w)] 980 | q /= vector_norm(q) # unit quaternion 981 | # homogeneous transformation matrix 982 | M = quaternion_matrix(q) 983 | 984 | if scale and not shear: 985 | # Affine transformation; scale is ratio of RMS deviations from centroid 986 | v0 *= v0 987 | v1 *= v1 988 | M[:ndims, :ndims] *= math.sqrt(numpy.sum(v1) / numpy.sum(v0)) 989 | 990 | # move centroids back 991 | M = numpy.dot(numpy.linalg.inv(M1), numpy.dot(M, M0)) 992 | M /= M[ndims, ndims] 993 | return M 994 | 995 | 996 | def superimposition_matrix(v0, v1, scale=False, usesvd=True): 997 | """Return matrix to transform given 3D point set into second point set. 998 | 999 | v0 and v1 are shape (3, *) or (4, *) arrays of at least 3 points. 1000 | 1001 | The parameters scale and usesvd are explained in the more general 1002 | affine_matrix_from_points function. 1003 | 1004 | The returned matrix is a similarity or Euclidean transformation matrix. 1005 | This function has a fast C implementation in transformations.c. 1006 | 1007 | >>> v0 = numpy.random.rand(3, 10) 1008 | >>> M = superimposition_matrix(v0, v0) 1009 | >>> numpy.allclose(M, numpy.identity(4)) 1010 | True 1011 | >>> R = random_rotation_matrix(numpy.random.random(3)) 1012 | >>> v0 = [[1,0,0], [0,1,0], [0,0,1], [1,1,1]] 1013 | >>> v1 = numpy.dot(R, v0) 1014 | >>> M = superimposition_matrix(v0, v1) 1015 | >>> numpy.allclose(v1, numpy.dot(M, v0)) 1016 | True 1017 | >>> v0 = (numpy.random.rand(4, 100) - 0.5) * 20 1018 | >>> v0[3] = 1 1019 | >>> v1 = numpy.dot(R, v0) 1020 | >>> M = superimposition_matrix(v0, v1) 1021 | >>> numpy.allclose(v1, numpy.dot(M, v0)) 1022 | True 1023 | >>> S = scale_matrix(random.random()) 1024 | >>> T = translation_matrix(numpy.random.random(3)-0.5) 1025 | >>> M = concatenate_matrices(T, R, S) 1026 | >>> v1 = numpy.dot(M, v0) 1027 | >>> v0[:3] += numpy.random.normal(0, 1e-9, 300).reshape(3, -1) 1028 | >>> M = superimposition_matrix(v0, v1, scale=True) 1029 | >>> numpy.allclose(v1, numpy.dot(M, v0)) 1030 | True 1031 | >>> M = superimposition_matrix(v0, v1, scale=True, usesvd=False) 1032 | >>> numpy.allclose(v1, numpy.dot(M, v0)) 1033 | True 1034 | >>> v = numpy.empty((4, 100, 3)) 1035 | >>> v[:, :, 0] = v0 1036 | >>> M = superimposition_matrix(v0, v1, scale=True, usesvd=False) 1037 | >>> numpy.allclose(v1, numpy.dot(M, v[:, :, 0])) 1038 | True 1039 | 1040 | """ 1041 | v0 = numpy.array(v0, dtype=numpy.float64, copy=False)[:3] 1042 | v1 = numpy.array(v1, dtype=numpy.float64, copy=False)[:3] 1043 | return affine_matrix_from_points(v0, v1, shear=False, 1044 | scale=scale, usesvd=usesvd) 1045 | 1046 | 1047 | def euler_matrix(ai, aj, ak, axes='sxyz'): 1048 | """Return homogeneous rotation matrix from Euler angles and axis sequence. 1049 | 1050 | ai, aj, ak : Euler's roll, pitch and yaw angles 1051 | axes : One of 24 axis sequences as string or encoded tuple 1052 | 1053 | >>> R = euler_matrix(1, 2, 3, 'syxz') 1054 | >>> numpy.allclose(numpy.sum(R[0]), -1.34786452) 1055 | True 1056 | >>> R = euler_matrix(1, 2, 3, (0, 1, 0, 1)) 1057 | >>> numpy.allclose(numpy.sum(R[0]), -0.383436184) 1058 | True 1059 | >>> ai, aj, ak = (4*math.pi) * (numpy.random.random(3) - 0.5) 1060 | >>> for axes in _AXES2TUPLE.keys(): 1061 | ... R = euler_matrix(ai, aj, ak, axes) 1062 | >>> for axes in _TUPLE2AXES.keys(): 1063 | ... R = euler_matrix(ai, aj, ak, axes) 1064 | 1065 | """ 1066 | try: 1067 | firstaxis, parity, repetition, frame = _AXES2TUPLE[axes] 1068 | except (AttributeError, KeyError): 1069 | _TUPLE2AXES[axes] # validation 1070 | firstaxis, parity, repetition, frame = axes 1071 | 1072 | i = firstaxis 1073 | j = _NEXT_AXIS[i+parity] 1074 | k = _NEXT_AXIS[i-parity+1] 1075 | 1076 | if frame: 1077 | ai, ak = ak, ai 1078 | if parity: 1079 | ai, aj, ak = -ai, -aj, -ak 1080 | 1081 | si, sj, sk = math.sin(ai), math.sin(aj), math.sin(ak) 1082 | ci, cj, ck = math.cos(ai), math.cos(aj), math.cos(ak) 1083 | cc, cs = ci*ck, ci*sk 1084 | sc, ss = si*ck, si*sk 1085 | 1086 | M = numpy.identity(4) 1087 | if repetition: 1088 | M[i, i] = cj 1089 | M[i, j] = sj*si 1090 | M[i, k] = sj*ci 1091 | M[j, i] = sj*sk 1092 | M[j, j] = -cj*ss+cc 1093 | M[j, k] = -cj*cs-sc 1094 | M[k, i] = -sj*ck 1095 | M[k, j] = cj*sc+cs 1096 | M[k, k] = cj*cc-ss 1097 | else: 1098 | M[i, i] = cj*ck 1099 | M[i, j] = sj*sc-cs 1100 | M[i, k] = sj*cc+ss 1101 | M[j, i] = cj*sk 1102 | M[j, j] = sj*ss+cc 1103 | M[j, k] = sj*cs-sc 1104 | M[k, i] = -sj 1105 | M[k, j] = cj*si 1106 | M[k, k] = cj*ci 1107 | return M 1108 | 1109 | 1110 | def euler_from_matrix(matrix, axes='sxyz'): 1111 | """Return Euler angles from rotation matrix for specified axis sequence. 1112 | 1113 | axes : One of 24 axis sequences as string or encoded tuple 1114 | 1115 | Note that many Euler angle triplets can describe one matrix. 1116 | 1117 | >>> R0 = euler_matrix(1, 2, 3, 'syxz') 1118 | >>> al, be, ga = euler_from_matrix(R0, 'syxz') 1119 | >>> R1 = euler_matrix(al, be, ga, 'syxz') 1120 | >>> numpy.allclose(R0, R1) 1121 | True 1122 | >>> angles = (4*math.pi) * (numpy.random.random(3) - 0.5) 1123 | >>> for axes in _AXES2TUPLE.keys(): 1124 | ... R0 = euler_matrix(axes=axes, *angles) 1125 | ... R1 = euler_matrix(axes=axes, *euler_from_matrix(R0, axes)) 1126 | ... if not numpy.allclose(R0, R1): print(axes, "failed") 1127 | 1128 | """ 1129 | try: 1130 | firstaxis, parity, repetition, frame = _AXES2TUPLE[axes.lower()] 1131 | except (AttributeError, KeyError): 1132 | _TUPLE2AXES[axes] # validation 1133 | firstaxis, parity, repetition, frame = axes 1134 | 1135 | i = firstaxis 1136 | j = _NEXT_AXIS[i+parity] 1137 | k = _NEXT_AXIS[i-parity+1] 1138 | 1139 | M = numpy.array(matrix, dtype=numpy.float64, copy=False)[:3, :3] 1140 | if repetition: 1141 | sy = math.sqrt(M[i, j]*M[i, j] + M[i, k]*M[i, k]) 1142 | if sy > _EPS: 1143 | ax = math.atan2( M[i, j], M[i, k]) 1144 | ay = math.atan2( sy, M[i, i]) 1145 | az = math.atan2( M[j, i], -M[k, i]) 1146 | else: 1147 | ax = math.atan2(-M[j, k], M[j, j]) 1148 | ay = math.atan2( sy, M[i, i]) 1149 | az = 0.0 1150 | else: 1151 | cy = math.sqrt(M[i, i]*M[i, i] + M[j, i]*M[j, i]) 1152 | if cy > _EPS: 1153 | ax = math.atan2( M[k, j], M[k, k]) 1154 | ay = math.atan2(-M[k, i], cy) 1155 | az = math.atan2( M[j, i], M[i, i]) 1156 | else: 1157 | ax = math.atan2(-M[j, k], M[j, j]) 1158 | ay = math.atan2(-M[k, i], cy) 1159 | az = 0.0 1160 | 1161 | if parity: 1162 | ax, ay, az = -ax, -ay, -az 1163 | if frame: 1164 | ax, az = az, ax 1165 | return ax, ay, az 1166 | 1167 | 1168 | def euler_from_quaternion(quaternion, axes='sxyz'): 1169 | """Return Euler angles from quaternion for specified axis sequence. 1170 | 1171 | >>> angles = euler_from_quaternion([0.99810947, 0.06146124, 0, 0]) 1172 | >>> numpy.allclose(angles, [0.123, 0, 0]) 1173 | True 1174 | 1175 | """ 1176 | return euler_from_matrix(quaternion_matrix(quaternion), axes) 1177 | 1178 | 1179 | def quaternion_from_euler(ai, aj, ak, axes='sxyz'): 1180 | """Return quaternion from Euler angles and axis sequence. 1181 | 1182 | ai, aj, ak : Euler's roll, pitch and yaw angles 1183 | axes : One of 24 axis sequences as string or encoded tuple 1184 | 1185 | >>> q = quaternion_from_euler(1, 2, 3, 'ryxz') 1186 | >>> numpy.allclose(q, [0.435953, 0.310622, -0.718287, 0.444435]) 1187 | True 1188 | 1189 | """ 1190 | try: 1191 | firstaxis, parity, repetition, frame = _AXES2TUPLE[axes.lower()] 1192 | except (AttributeError, KeyError): 1193 | _TUPLE2AXES[axes] # validation 1194 | firstaxis, parity, repetition, frame = axes 1195 | 1196 | i = firstaxis + 1 1197 | j = _NEXT_AXIS[i+parity-1] + 1 1198 | k = _NEXT_AXIS[i-parity] + 1 1199 | 1200 | if frame: 1201 | ai, ak = ak, ai 1202 | if parity: 1203 | aj = -aj 1204 | 1205 | ai /= 2.0 1206 | aj /= 2.0 1207 | ak /= 2.0 1208 | ci = math.cos(ai) 1209 | si = math.sin(ai) 1210 | cj = math.cos(aj) 1211 | sj = math.sin(aj) 1212 | ck = math.cos(ak) 1213 | sk = math.sin(ak) 1214 | cc = ci*ck 1215 | cs = ci*sk 1216 | sc = si*ck 1217 | ss = si*sk 1218 | 1219 | q = numpy.empty((4, )) 1220 | if repetition: 1221 | q[0] = cj*(cc - ss) 1222 | q[i] = cj*(cs + sc) 1223 | q[j] = sj*(cc + ss) 1224 | q[k] = sj*(cs - sc) 1225 | else: 1226 | q[0] = cj*cc + sj*ss 1227 | q[i] = cj*sc - sj*cs 1228 | q[j] = cj*ss + sj*cc 1229 | q[k] = cj*cs - sj*sc 1230 | if parity: 1231 | q[j] *= -1.0 1232 | 1233 | return q 1234 | 1235 | 1236 | def quaternion_about_axis(angle, axis): 1237 | """Return quaternion for rotation about axis. 1238 | 1239 | >>> q = quaternion_about_axis(0.123, [1, 0, 0]) 1240 | >>> numpy.allclose(q, [0.99810947, 0.06146124, 0, 0]) 1241 | True 1242 | 1243 | """ 1244 | q = numpy.array([0.0, axis[0], axis[1], axis[2]]) 1245 | qlen = vector_norm(q) 1246 | if qlen > _EPS: 1247 | q *= math.sin(angle/2.0) / qlen 1248 | q[0] = math.cos(angle/2.0) 1249 | return q 1250 | 1251 | 1252 | def quaternion_matrix(quaternion): 1253 | """Return homogeneous rotation matrix from quaternion. 1254 | 1255 | >>> M = quaternion_matrix([0.99810947, 0.06146124, 0, 0]) 1256 | >>> numpy.allclose(M, rotation_matrix(0.123, [1, 0, 0])) 1257 | True 1258 | >>> M = quaternion_matrix([1, 0, 0, 0]) 1259 | >>> numpy.allclose(M, numpy.identity(4)) 1260 | True 1261 | >>> M = quaternion_matrix([0, 1, 0, 0]) 1262 | >>> numpy.allclose(M, numpy.diag([1, -1, -1, 1])) 1263 | True 1264 | 1265 | """ 1266 | q = numpy.array(quaternion, dtype=numpy.float64, copy=True) 1267 | n = numpy.dot(q, q) 1268 | if n < _EPS: 1269 | return numpy.identity(4) 1270 | q *= math.sqrt(2.0 / n) 1271 | q = numpy.outer(q, q) 1272 | return numpy.array([ 1273 | [1.0-q[2, 2]-q[3, 3], q[1, 2]-q[3, 0], q[1, 3]+q[2, 0], 0.0], 1274 | [ q[1, 2]+q[3, 0], 1.0-q[1, 1]-q[3, 3], q[2, 3]-q[1, 0], 0.0], 1275 | [ q[1, 3]-q[2, 0], q[2, 3]+q[1, 0], 1.0-q[1, 1]-q[2, 2], 0.0], 1276 | [ 0.0, 0.0, 0.0, 1.0]]) 1277 | 1278 | 1279 | def quaternion_from_matrix(matrix, isprecise=False): 1280 | """Return quaternion from rotation matrix. 1281 | 1282 | If isprecise is True, the input matrix is assumed to be a precise rotation 1283 | matrix and a faster algorithm is used. 1284 | 1285 | >>> q = quaternion_from_matrix(numpy.identity(4), True) 1286 | >>> numpy.allclose(q, [1, 0, 0, 0]) 1287 | True 1288 | >>> q = quaternion_from_matrix(numpy.diag([1, -1, -1, 1])) 1289 | >>> numpy.allclose(q, [0, 1, 0, 0]) or numpy.allclose(q, [0, -1, 0, 0]) 1290 | True 1291 | >>> R = rotation_matrix(0.123, (1, 2, 3)) 1292 | >>> q = quaternion_from_matrix(R, True) 1293 | >>> numpy.allclose(q, [0.9981095, 0.0164262, 0.0328524, 0.0492786]) 1294 | True 1295 | >>> R = [[-0.545, 0.797, 0.260, 0], [0.733, 0.603, -0.313, 0], 1296 | ... [-0.407, 0.021, -0.913, 0], [0, 0, 0, 1]] 1297 | >>> q = quaternion_from_matrix(R) 1298 | >>> numpy.allclose(q, [0.19069, 0.43736, 0.87485, -0.083611]) 1299 | True 1300 | >>> R = [[0.395, 0.362, 0.843, 0], [-0.626, 0.796, -0.056, 0], 1301 | ... [-0.677, -0.498, 0.529, 0], [0, 0, 0, 1]] 1302 | >>> q = quaternion_from_matrix(R) 1303 | >>> numpy.allclose(q, [0.82336615, -0.13610694, 0.46344705, -0.29792603]) 1304 | True 1305 | >>> R = random_rotation_matrix() 1306 | >>> q = quaternion_from_matrix(R) 1307 | >>> is_same_transform(R, quaternion_matrix(q)) 1308 | True 1309 | >>> R = euler_matrix(0.0, 0.0, numpy.pi/2.0) 1310 | >>> numpy.allclose(quaternion_from_matrix(R, isprecise=False), 1311 | ... quaternion_from_matrix(R, isprecise=True)) 1312 | True 1313 | 1314 | """ 1315 | M = numpy.array(matrix, dtype=numpy.float64, copy=False)[:4, :4] 1316 | if isprecise: 1317 | q = numpy.empty((4, )) 1318 | t = numpy.trace(M) 1319 | if t > M[3, 3]: 1320 | q[0] = t 1321 | q[3] = M[1, 0] - M[0, 1] 1322 | q[2] = M[0, 2] - M[2, 0] 1323 | q[1] = M[2, 1] - M[1, 2] 1324 | else: 1325 | i, j, k = 1, 2, 3 1326 | if M[1, 1] > M[0, 0]: 1327 | i, j, k = 2, 3, 1 1328 | if M[2, 2] > M[i, i]: 1329 | i, j, k = 3, 1, 2 1330 | t = M[i, i] - (M[j, j] + M[k, k]) + M[3, 3] 1331 | q[i] = t 1332 | q[j] = M[i, j] + M[j, i] 1333 | q[k] = M[k, i] + M[i, k] 1334 | q[3] = M[k, j] - M[j, k] 1335 | q *= 0.5 / math.sqrt(t * M[3, 3]) 1336 | else: 1337 | m00 = M[0, 0] 1338 | m01 = M[0, 1] 1339 | m02 = M[0, 2] 1340 | m10 = M[1, 0] 1341 | m11 = M[1, 1] 1342 | m12 = M[1, 2] 1343 | m20 = M[2, 0] 1344 | m21 = M[2, 1] 1345 | m22 = M[2, 2] 1346 | # symmetric matrix K 1347 | K = numpy.array([[m00-m11-m22, 0.0, 0.0, 0.0], 1348 | [m01+m10, m11-m00-m22, 0.0, 0.0], 1349 | [m02+m20, m12+m21, m22-m00-m11, 0.0], 1350 | [m21-m12, m02-m20, m10-m01, m00+m11+m22]]) 1351 | K /= 3.0 1352 | # quaternion is eigenvector of K that corresponds to largest eigenvalue 1353 | w, V = numpy.linalg.eigh(K) 1354 | q = V[[3, 0, 1, 2], numpy.argmax(w)] 1355 | if q[0] < 0.0: 1356 | numpy.negative(q, q) 1357 | return q 1358 | 1359 | 1360 | def quaternion_multiply(quaternion1, quaternion0): 1361 | """Return multiplication of two quaternions. 1362 | 1363 | >>> q = quaternion_multiply([4, 1, -2, 3], [8, -5, 6, 7]) 1364 | >>> numpy.allclose(q, [28, -44, -14, 48]) 1365 | True 1366 | 1367 | """ 1368 | w0, x0, y0, z0 = quaternion0 1369 | w1, x1, y1, z1 = quaternion1 1370 | return numpy.array([-x1*x0 - y1*y0 - z1*z0 + w1*w0, 1371 | x1*w0 + y1*z0 - z1*y0 + w1*x0, 1372 | -x1*z0 + y1*w0 + z1*x0 + w1*y0, 1373 | x1*y0 - y1*x0 + z1*w0 + w1*z0], dtype=numpy.float64) 1374 | 1375 | 1376 | def quaternion_conjugate(quaternion): 1377 | """Return conjugate of quaternion. 1378 | 1379 | >>> q0 = random_quaternion() 1380 | >>> q1 = quaternion_conjugate(q0) 1381 | >>> q1[0] == q0[0] and all(q1[1:] == -q0[1:]) 1382 | True 1383 | 1384 | """ 1385 | q = numpy.array(quaternion, dtype=numpy.float64, copy=True) 1386 | numpy.negative(q[1:], q[1:]) 1387 | return q 1388 | 1389 | 1390 | def quaternion_inverse(quaternion): 1391 | """Return inverse of quaternion. 1392 | 1393 | >>> q0 = random_quaternion() 1394 | >>> q1 = quaternion_inverse(q0) 1395 | >>> numpy.allclose(quaternion_multiply(q0, q1), [1, 0, 0, 0]) 1396 | True 1397 | 1398 | """ 1399 | q = numpy.array(quaternion, dtype=numpy.float64, copy=True) 1400 | numpy.negative(q[1:], q[1:]) 1401 | return q / numpy.dot(q, q) 1402 | 1403 | 1404 | def quaternion_real(quaternion): 1405 | """Return real part of quaternion. 1406 | 1407 | >>> quaternion_real([3, 0, 1, 2]) 1408 | 3.0 1409 | 1410 | """ 1411 | return float(quaternion[0]) 1412 | 1413 | 1414 | def quaternion_imag(quaternion): 1415 | """Return imaginary part of quaternion. 1416 | 1417 | >>> quaternion_imag([3, 0, 1, 2]) 1418 | array([ 0., 1., 2.]) 1419 | 1420 | """ 1421 | return numpy.array(quaternion[1:4], dtype=numpy.float64, copy=True) 1422 | 1423 | 1424 | def quaternion_slerp(quat0, quat1, fraction, spin=0, shortestpath=True): 1425 | """Return spherical linear interpolation between two quaternions. 1426 | 1427 | >>> q0 = random_quaternion() 1428 | >>> q1 = random_quaternion() 1429 | >>> q = quaternion_slerp(q0, q1, 0) 1430 | >>> numpy.allclose(q, q0) 1431 | True 1432 | >>> q = quaternion_slerp(q0, q1, 1, 1) 1433 | >>> numpy.allclose(q, q1) 1434 | True 1435 | >>> q = quaternion_slerp(q0, q1, 0.5) 1436 | >>> angle = math.acos(numpy.dot(q0, q)) 1437 | >>> (numpy.allclose(2, math.acos(numpy.dot(q0, q1)) / angle) or 1438 | numpy.allclose(2, math.acos(-numpy.dot(q0, q1)) / angle)) 1439 | True 1440 | 1441 | """ 1442 | q0 = unit_vector(quat0[:4]) 1443 | q1 = unit_vector(quat1[:4]) 1444 | if fraction == 0.0: 1445 | return q0 1446 | elif fraction == 1.0: 1447 | return q1 1448 | d = numpy.dot(q0, q1) 1449 | if abs(abs(d) - 1.0) < _EPS: 1450 | return q0 1451 | if shortestpath and d < 0.0: 1452 | # invert rotation 1453 | d = -d 1454 | numpy.negative(q1, q1) 1455 | angle = math.acos(d) + spin * math.pi 1456 | if abs(angle) < _EPS: 1457 | return q0 1458 | isin = 1.0 / math.sin(angle) 1459 | q0 *= math.sin((1.0 - fraction) * angle) * isin 1460 | q1 *= math.sin(fraction * angle) * isin 1461 | q0 += q1 1462 | return q0 1463 | 1464 | 1465 | def random_quaternion(rand=None): 1466 | """Return uniform random unit quaternion. 1467 | 1468 | rand: array like or None 1469 | Three independent random variables that are uniformly distributed 1470 | between 0 and 1. 1471 | 1472 | >>> q = random_quaternion() 1473 | >>> numpy.allclose(1, vector_norm(q)) 1474 | True 1475 | >>> q = random_quaternion(numpy.random.random(3)) 1476 | >>> len(q.shape), q.shape[0]==4 1477 | (1, True) 1478 | 1479 | """ 1480 | if rand is None: 1481 | rand = numpy.random.rand(3) 1482 | else: 1483 | assert len(rand) == 3 1484 | r1 = numpy.sqrt(1.0 - rand[0]) 1485 | r2 = numpy.sqrt(rand[0]) 1486 | pi2 = math.pi * 2.0 1487 | t1 = pi2 * rand[1] 1488 | t2 = pi2 * rand[2] 1489 | return numpy.array([numpy.cos(t2)*r2, numpy.sin(t1)*r1, 1490 | numpy.cos(t1)*r1, numpy.sin(t2)*r2]) 1491 | 1492 | 1493 | def random_rotation_matrix(rand=None): 1494 | """Return uniform random rotation matrix. 1495 | 1496 | rand: array like 1497 | Three independent random variables that are uniformly distributed 1498 | between 0 and 1 for each returned quaternion. 1499 | 1500 | >>> R = random_rotation_matrix() 1501 | >>> numpy.allclose(numpy.dot(R.T, R), numpy.identity(4)) 1502 | True 1503 | 1504 | """ 1505 | return quaternion_matrix(random_quaternion(rand)) 1506 | 1507 | 1508 | class Arcball(object): 1509 | """Virtual Trackball Control. 1510 | 1511 | >>> ball = Arcball() 1512 | >>> ball = Arcball(initial=numpy.identity(4)) 1513 | >>> ball.place([320, 320], 320) 1514 | >>> ball.down([500, 250]) 1515 | >>> ball.drag([475, 275]) 1516 | >>> R = ball.matrix() 1517 | >>> numpy.allclose(numpy.sum(R), 3.90583455) 1518 | True 1519 | >>> ball = Arcball(initial=[1, 0, 0, 0]) 1520 | >>> ball.place([320, 320], 320) 1521 | >>> ball.setaxes([1, 1, 0], [-1, 1, 0]) 1522 | >>> ball.constrain = True 1523 | >>> ball.down([400, 200]) 1524 | >>> ball.drag([200, 400]) 1525 | >>> R = ball.matrix() 1526 | >>> numpy.allclose(numpy.sum(R), 0.2055924) 1527 | True 1528 | >>> ball.next() 1529 | 1530 | """ 1531 | def __init__(self, initial=None): 1532 | """Initialize virtual trackball control. 1533 | 1534 | initial : quaternion or rotation matrix 1535 | 1536 | """ 1537 | self._axis = None 1538 | self._axes = None 1539 | self._radius = 1.0 1540 | self._center = [0.0, 0.0] 1541 | self._vdown = numpy.array([0.0, 0.0, 1.0]) 1542 | self._constrain = False 1543 | if initial is None: 1544 | self._qdown = numpy.array([1.0, 0.0, 0.0, 0.0]) 1545 | else: 1546 | initial = numpy.array(initial, dtype=numpy.float64) 1547 | if initial.shape == (4, 4): 1548 | self._qdown = quaternion_from_matrix(initial) 1549 | elif initial.shape == (4, ): 1550 | initial /= vector_norm(initial) 1551 | self._qdown = initial 1552 | else: 1553 | raise ValueError("initial not a quaternion or matrix") 1554 | self._qnow = self._qpre = self._qdown 1555 | 1556 | def place(self, center, radius): 1557 | """Place Arcball, e.g. when window size changes. 1558 | 1559 | center : sequence[2] 1560 | Window coordinates of trackball center. 1561 | radius : float 1562 | Radius of trackball in window coordinates. 1563 | 1564 | """ 1565 | self._radius = float(radius) 1566 | self._center[0] = center[0] 1567 | self._center[1] = center[1] 1568 | 1569 | def setaxes(self, *axes): 1570 | """Set axes to constrain rotations.""" 1571 | if axes is None: 1572 | self._axes = None 1573 | else: 1574 | self._axes = [unit_vector(axis) for axis in axes] 1575 | 1576 | @property 1577 | def constrain(self): 1578 | """Return state of constrain to axis mode.""" 1579 | return self._constrain 1580 | 1581 | @constrain.setter 1582 | def constrain(self, value): 1583 | """Set state of constrain to axis mode.""" 1584 | self._constrain = bool(value) 1585 | 1586 | def down(self, point): 1587 | """Set initial cursor window coordinates and pick constrain-axis.""" 1588 | self._vdown = arcball_map_to_sphere(point, self._center, self._radius) 1589 | self._qdown = self._qpre = self._qnow 1590 | if self._constrain and self._axes is not None: 1591 | self._axis = arcball_nearest_axis(self._vdown, self._axes) 1592 | self._vdown = arcball_constrain_to_axis(self._vdown, self._axis) 1593 | else: 1594 | self._axis = None 1595 | 1596 | def drag(self, point): 1597 | """Update current cursor window coordinates.""" 1598 | vnow = arcball_map_to_sphere(point, self._center, self._radius) 1599 | if self._axis is not None: 1600 | vnow = arcball_constrain_to_axis(vnow, self._axis) 1601 | self._qpre = self._qnow 1602 | t = numpy.cross(self._vdown, vnow) 1603 | if numpy.dot(t, t) < _EPS: 1604 | self._qnow = self._qdown 1605 | else: 1606 | q = [numpy.dot(self._vdown, vnow), t[0], t[1], t[2]] 1607 | self._qnow = quaternion_multiply(q, self._qdown) 1608 | 1609 | def next(self, acceleration=0.0): 1610 | """Continue rotation in direction of last drag.""" 1611 | q = quaternion_slerp(self._qpre, self._qnow, 2.0+acceleration, False) 1612 | self._qpre, self._qnow = self._qnow, q 1613 | 1614 | def matrix(self): 1615 | """Return homogeneous rotation matrix.""" 1616 | return quaternion_matrix(self._qnow) 1617 | 1618 | 1619 | def arcball_map_to_sphere(point, center, radius): 1620 | """Return unit sphere coordinates from window coordinates.""" 1621 | v0 = (point[0] - center[0]) / radius 1622 | v1 = (center[1] - point[1]) / radius 1623 | n = v0*v0 + v1*v1 1624 | if n > 1.0: 1625 | # position outside of sphere 1626 | n = math.sqrt(n) 1627 | return numpy.array([v0/n, v1/n, 0.0]) 1628 | else: 1629 | return numpy.array([v0, v1, math.sqrt(1.0 - n)]) 1630 | 1631 | 1632 | def arcball_constrain_to_axis(point, axis): 1633 | """Return sphere point perpendicular to axis.""" 1634 | v = numpy.array(point, dtype=numpy.float64, copy=True) 1635 | a = numpy.array(axis, dtype=numpy.float64, copy=True) 1636 | v -= a * numpy.dot(a, v) # on plane 1637 | n = vector_norm(v) 1638 | if n > _EPS: 1639 | if v[2] < 0.0: 1640 | numpy.negative(v, v) 1641 | v /= n 1642 | return v 1643 | if a[2] == 1.0: 1644 | return numpy.array([1.0, 0.0, 0.0]) 1645 | return unit_vector([-a[1], a[0], 0.0]) 1646 | 1647 | 1648 | def arcball_nearest_axis(point, axes): 1649 | """Return axis, which arc is nearest to point.""" 1650 | point = numpy.array(point, dtype=numpy.float64, copy=False) 1651 | nearest = None 1652 | mx = -1.0 1653 | for axis in axes: 1654 | t = numpy.dot(arcball_constrain_to_axis(point, axis), point) 1655 | if t > mx: 1656 | nearest = axis 1657 | mx = t 1658 | return nearest 1659 | 1660 | 1661 | # epsilon for testing whether a number is close to zero 1662 | _EPS = numpy.finfo(float).eps * 4.0 1663 | 1664 | # axis sequences for Euler angles 1665 | _NEXT_AXIS = [1, 2, 0, 1] 1666 | 1667 | # map axes strings to/from tuples of inner axis, parity, repetition, frame 1668 | _AXES2TUPLE = { 1669 | 'sxyz': (0, 0, 0, 0), 'sxyx': (0, 0, 1, 0), 'sxzy': (0, 1, 0, 0), 1670 | 'sxzx': (0, 1, 1, 0), 'syzx': (1, 0, 0, 0), 'syzy': (1, 0, 1, 0), 1671 | 'syxz': (1, 1, 0, 0), 'syxy': (1, 1, 1, 0), 'szxy': (2, 0, 0, 0), 1672 | 'szxz': (2, 0, 1, 0), 'szyx': (2, 1, 0, 0), 'szyz': (2, 1, 1, 0), 1673 | 'rzyx': (0, 0, 0, 1), 'rxyx': (0, 0, 1, 1), 'ryzx': (0, 1, 0, 1), 1674 | 'rxzx': (0, 1, 1, 1), 'rxzy': (1, 0, 0, 1), 'ryzy': (1, 0, 1, 1), 1675 | 'rzxy': (1, 1, 0, 1), 'ryxy': (1, 1, 1, 1), 'ryxz': (2, 0, 0, 1), 1676 | 'rzxz': (2, 0, 1, 1), 'rxyz': (2, 1, 0, 1), 'rzyz': (2, 1, 1, 1)} 1677 | 1678 | _TUPLE2AXES = dict((v, k) for k, v in _AXES2TUPLE.items()) 1679 | 1680 | 1681 | def vector_norm(data, axis=None, out=None): 1682 | """Return length, i.e. Euclidean norm, of ndarray along axis. 1683 | 1684 | >>> v = numpy.random.random(3) 1685 | >>> n = vector_norm(v) 1686 | >>> numpy.allclose(n, numpy.linalg.norm(v)) 1687 | True 1688 | >>> v = numpy.random.rand(6, 5, 3) 1689 | >>> n = vector_norm(v, axis=-1) 1690 | >>> numpy.allclose(n, numpy.sqrt(numpy.sum(v*v, axis=2))) 1691 | True 1692 | >>> n = vector_norm(v, axis=1) 1693 | >>> numpy.allclose(n, numpy.sqrt(numpy.sum(v*v, axis=1))) 1694 | True 1695 | >>> v = numpy.random.rand(5, 4, 3) 1696 | >>> n = numpy.empty((5, 3)) 1697 | >>> vector_norm(v, axis=1, out=n) 1698 | >>> numpy.allclose(n, numpy.sqrt(numpy.sum(v*v, axis=1))) 1699 | True 1700 | >>> vector_norm([]) 1701 | 0.0 1702 | >>> vector_norm([1]) 1703 | 1.0 1704 | 1705 | """ 1706 | data = numpy.array(data, dtype=numpy.float64, copy=True) 1707 | if out is None: 1708 | if data.ndim == 1: 1709 | return math.sqrt(numpy.dot(data, data)) 1710 | data *= data 1711 | out = numpy.atleast_1d(numpy.sum(data, axis=axis)) 1712 | numpy.sqrt(out, out) 1713 | return out 1714 | else: 1715 | data *= data 1716 | numpy.sum(data, axis=axis, out=out) 1717 | numpy.sqrt(out, out) 1718 | 1719 | 1720 | def unit_vector(data, axis=None, out=None): 1721 | """Return ndarray normalized by length, i.e. Euclidean norm, along axis. 1722 | 1723 | >>> v0 = numpy.random.random(3) 1724 | >>> v1 = unit_vector(v0) 1725 | >>> numpy.allclose(v1, v0 / numpy.linalg.norm(v0)) 1726 | True 1727 | >>> v0 = numpy.random.rand(5, 4, 3) 1728 | >>> v1 = unit_vector(v0, axis=-1) 1729 | >>> v2 = v0 / numpy.expand_dims(numpy.sqrt(numpy.sum(v0*v0, axis=2)), 2) 1730 | >>> numpy.allclose(v1, v2) 1731 | True 1732 | >>> v1 = unit_vector(v0, axis=1) 1733 | >>> v2 = v0 / numpy.expand_dims(numpy.sqrt(numpy.sum(v0*v0, axis=1)), 1) 1734 | >>> numpy.allclose(v1, v2) 1735 | True 1736 | >>> v1 = numpy.empty((5, 4, 3)) 1737 | >>> unit_vector(v0, axis=1, out=v1) 1738 | >>> numpy.allclose(v1, v2) 1739 | True 1740 | >>> list(unit_vector([])) 1741 | [] 1742 | >>> list(unit_vector([1])) 1743 | [1.0] 1744 | 1745 | """ 1746 | if out is None: 1747 | data = numpy.array(data, dtype=numpy.float64, copy=True) 1748 | if data.ndim == 1: 1749 | data /= math.sqrt(numpy.dot(data, data)) 1750 | return data 1751 | else: 1752 | if out is not data: 1753 | out[:] = numpy.array(data, copy=False) 1754 | data = out 1755 | length = numpy.atleast_1d(numpy.sum(data*data, axis)) 1756 | numpy.sqrt(length, length) 1757 | if axis is not None: 1758 | length = numpy.expand_dims(length, axis) 1759 | data /= length 1760 | if out is None: 1761 | return data 1762 | 1763 | 1764 | def random_vector(size): 1765 | """Return array of random doubles in the half-open interval [0.0, 1.0). 1766 | 1767 | >>> v = random_vector(10000) 1768 | >>> numpy.all(v >= 0) and numpy.all(v < 1) 1769 | True 1770 | >>> v0 = random_vector(10) 1771 | >>> v1 = random_vector(10) 1772 | >>> numpy.any(v0 == v1) 1773 | False 1774 | 1775 | """ 1776 | return numpy.random.random(size) 1777 | 1778 | 1779 | def vector_product(v0, v1, axis=0): 1780 | """Return vector perpendicular to vectors. 1781 | 1782 | >>> v = vector_product([2, 0, 0], [0, 3, 0]) 1783 | >>> numpy.allclose(v, [0, 0, 6]) 1784 | True 1785 | >>> v0 = [[2, 0, 0, 2], [0, 2, 0, 2], [0, 0, 2, 2]] 1786 | >>> v1 = [[3], [0], [0]] 1787 | >>> v = vector_product(v0, v1) 1788 | >>> numpy.allclose(v, [[0, 0, 0, 0], [0, 0, 6, 6], [0, -6, 0, -6]]) 1789 | True 1790 | >>> v0 = [[2, 0, 0], [2, 0, 0], [0, 2, 0], [2, 0, 0]] 1791 | >>> v1 = [[0, 3, 0], [0, 0, 3], [0, 0, 3], [3, 3, 3]] 1792 | >>> v = vector_product(v0, v1, axis=1) 1793 | >>> numpy.allclose(v, [[0, 0, 6], [0, -6, 0], [6, 0, 0], [0, -6, 6]]) 1794 | True 1795 | 1796 | """ 1797 | return numpy.cross(v0, v1, axis=axis) 1798 | 1799 | 1800 | def angle_between_vectors(v0, v1, directed=True, axis=0): 1801 | """Return angle between vectors. 1802 | 1803 | If directed is False, the input vectors are interpreted as undirected axes, 1804 | i.e. the maximum angle is pi/2. 1805 | 1806 | >>> a = angle_between_vectors([1, -2, 3], [-1, 2, -3]) 1807 | >>> numpy.allclose(a, math.pi) 1808 | True 1809 | >>> a = angle_between_vectors([1, -2, 3], [-1, 2, -3], directed=False) 1810 | >>> numpy.allclose(a, 0) 1811 | True 1812 | >>> v0 = [[2, 0, 0, 2], [0, 2, 0, 2], [0, 0, 2, 2]] 1813 | >>> v1 = [[3], [0], [0]] 1814 | >>> a = angle_between_vectors(v0, v1) 1815 | >>> numpy.allclose(a, [0, 1.5708, 1.5708, 0.95532]) 1816 | True 1817 | >>> v0 = [[2, 0, 0], [2, 0, 0], [0, 2, 0], [2, 0, 0]] 1818 | >>> v1 = [[0, 3, 0], [0, 0, 3], [0, 0, 3], [3, 3, 3]] 1819 | >>> a = angle_between_vectors(v0, v1, axis=1) 1820 | >>> numpy.allclose(a, [1.5708, 1.5708, 1.5708, 0.95532]) 1821 | True 1822 | 1823 | """ 1824 | v0 = numpy.array(v0, dtype=numpy.float64, copy=False) 1825 | v1 = numpy.array(v1, dtype=numpy.float64, copy=False) 1826 | dot = numpy.sum(v0 * v1, axis=axis) 1827 | dot /= vector_norm(v0, axis=axis) * vector_norm(v1, axis=axis) 1828 | return numpy.arccos(dot if directed else numpy.fabs(dot)) 1829 | 1830 | 1831 | def inverse_matrix(matrix): 1832 | """Return inverse of square transformation matrix. 1833 | 1834 | >>> M0 = random_rotation_matrix() 1835 | >>> M1 = inverse_matrix(M0.T) 1836 | >>> numpy.allclose(M1, numpy.linalg.inv(M0.T)) 1837 | True 1838 | >>> for size in range(1, 7): 1839 | ... M0 = numpy.random.rand(size, size) 1840 | ... M1 = inverse_matrix(M0) 1841 | ... if not numpy.allclose(M1, numpy.linalg.inv(M0)): print(size) 1842 | 1843 | """ 1844 | return numpy.linalg.inv(matrix) 1845 | 1846 | 1847 | def concatenate_matrices(*matrices): 1848 | """Return concatenation of series of transformation matrices. 1849 | 1850 | >>> M = numpy.random.rand(16).reshape((4, 4)) - 0.5 1851 | >>> numpy.allclose(M, concatenate_matrices(M)) 1852 | True 1853 | >>> numpy.allclose(numpy.dot(M, M.T), concatenate_matrices(M, M.T)) 1854 | True 1855 | 1856 | """ 1857 | M = numpy.identity(4) 1858 | for i in matrices: 1859 | M = numpy.dot(M, i) 1860 | return M 1861 | 1862 | 1863 | def is_same_transform(matrix0, matrix1): 1864 | """Return True if two matrices perform same transformation. 1865 | 1866 | >>> is_same_transform(numpy.identity(4), numpy.identity(4)) 1867 | True 1868 | >>> is_same_transform(numpy.identity(4), random_rotation_matrix()) 1869 | False 1870 | 1871 | """ 1872 | matrix0 = numpy.array(matrix0, dtype=numpy.float64, copy=True) 1873 | matrix0 /= matrix0[3, 3] 1874 | matrix1 = numpy.array(matrix1, dtype=numpy.float64, copy=True) 1875 | matrix1 /= matrix1[3, 3] 1876 | return numpy.allclose(matrix0, matrix1) 1877 | 1878 | 1879 | def _import_module(name, package=None, warn=True, prefix='_py_', ignore='_'): 1880 | """Try import all public attributes from module into global namespace. 1881 | 1882 | Existing attributes with name clashes are renamed with prefix. 1883 | Attributes starting with underscore are ignored by default. 1884 | 1885 | Return True on successful import. 1886 | 1887 | """ 1888 | import warnings 1889 | from importlib import import_module 1890 | try: 1891 | if not package: 1892 | module = import_module(name) 1893 | else: 1894 | module = import_module('.' + name, package=package) 1895 | except ImportError: 1896 | if warn: 1897 | warnings.warn("failed to import module %s" % name) 1898 | else: 1899 | for attr in dir(module): 1900 | if ignore and attr.startswith(ignore): 1901 | continue 1902 | if prefix: 1903 | if attr in globals(): 1904 | globals()[prefix + attr] = globals()[attr] 1905 | elif warn: 1906 | warnings.warn("no Python implementation of " + attr) 1907 | globals()[attr] = getattr(module, attr) 1908 | return True 1909 | 1910 | 1911 | # _import_module('_transformations', package='meshcat') 1912 | 1913 | if __name__ == "__main__": 1914 | import doctest 1915 | import random # used in doctests 1916 | numpy.set_printoptions(suppress=True, precision=5) 1917 | doctest.testmod() 1918 | 1919 | --------------------------------------------------------------------------------