├── 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 |
--------------------------------------------------------------------------------