This commit is contained in:
cjw
2026-02-12 23:22:11 +08:00
parent 7b09eb3d89
commit 89660bba4e
5988 changed files with 2517516 additions and 0 deletions
@@ -0,0 +1,39 @@
"""
viewer
-------------
View meshes and scenes via pyglet or inline HTML.
"""
from .. import exceptions
from .notebook import (
in_notebook,
scene_to_html,
scene_to_mo_notebook,
scene_to_notebook,
)
try:
# try importing windowed which will fail
# if we can't create an openGL context
from .windowed import SceneViewer, render_scene
except BaseException as E:
# if windowed failed to import only raise
# the exception if someone tries to use them
SceneViewer = exceptions.ExceptionWrapper(E)
render_scene = exceptions.ExceptionWrapper(E)
# this is only standard library imports
# explicitly list imports in __all__
# as otherwise flake8 gets mad
__all__ = [
"SceneViewer",
"SceneWidget",
"in_notebook",
"render_scene",
"scene_to_html",
"scene_to_mo_notebook",
"scene_to_notebook",
]
@@ -0,0 +1,174 @@
"""
notebook.py
-------------
Render trimesh.Scene objects in HTML
and jupyter and marimo notebooks using three.js
"""
import base64
import os
from typing import Literal
# for our template
from .. import resources, util
def scene_to_html(scene, escape_quotes: bool = False) -> str:
"""
Return HTML that will render the scene using
GLTF/GLB encoded to base64 loaded by three.js
Parameters
--------------
scene : trimesh.Scene
Source geometry
escape_quotes
If true, replaces quotes '"' with '"' so that the
HTML is valid inside a `srcdoc` property.
Returns
--------------
html : str
HTML containing embedded geometry
"""
# fetch HTML template from ZIP archive
# it is bundling all of three.js so compression is nice
base = (
util.decompress(resources.get_bytes("templates/viewer.zip"), file_type="zip")[
"viewer.html.template"
]
.read()
.decode("utf-8")
)
# make sure scene has camera populated before export
_ = scene.camera
# get export as bytes
data = scene.export(file_type="glb")
# encode as base64 string
encoded = base64.b64encode(data).decode("utf-8")
# replace keyword with our scene data
html = base.replace("$B64GLTF", encoded)
if escape_quotes:
return html.replace('"', """)
return html
def scene_to_notebook(scene, height=500, **kwargs):
"""
Convert a scene to HTML containing embedded geometry
and a three.js viewer that will display nicely in
an IPython/Jupyter notebook.
Parameters
-------------
scene : trimesh.Scene
Source geometry
Returns
-------------
html : IPython.display.HTML
Object containing rendered scene
"""
# keep as soft dependency
from IPython import display
# convert scene to a full HTML page
as_html = scene_to_html(scene=scene, escape_quotes=True)
# escape the quotes in the HTML
srcdoc = as_html
# embed this puppy as the srcdoc attr of an IFframe
# I tried this a dozen ways and this is the only one that works
# display.IFrame/display.Javascript really, really don't work
# div is to avoid IPython's pointless hardcoded warning
embedded = display.HTML(
" ".join(
[
'<div><iframe srcdoc="{srcdoc}"',
'width="100%" height="{height}px"',
'style="border:none;"></iframe></div>',
]
).format(srcdoc=srcdoc, height=height)
)
return embedded
def scene_to_mo_notebook(scene, height=500, **kwargs):
"""
Convert a scene to HTML containing embedded geometry
and a three.js viewer that will display nicely in
an Marimo notebook.
Parameters
-------------
scene : trimesh.Scene
Source geometry
Returns
-------------
html : mo.Html
Object containing rendered scene
"""
# keep as soft dependency
import marimo as mo
# convert scene to a full HTML page
srcdoc = scene_to_html(scene=scene, escape_quotes=True)
# Embed as srcdoc attr of IFrame, using mo.iframe
# turns out displaying an empty image. Likely
# similar to display.IFrame
embedded = mo.Html(
" ".join(
[
'<div><iframe srcdoc="{srcdoc}"',
'width="100%" height="{height}px"',
'style="border:none;"></iframe></div>',
]
).format(srcdoc=srcdoc, height=height)
)
return embedded
def in_notebook() -> Literal["jupyter", "marimo", False]:
"""
Check to see if we are in a Jypyter or Marimo notebook.
Returns
-----------
in_notebook
Returns the type of notebook we're in or False if it
is running as terminal application.
"""
try:
# function returns IPython context, but only in IPython
ipy = get_ipython() # NOQA
# we only want to render rich output in notebooks
# in terminals we definitely do not want to output HTML
name = str(ipy.__class__).lower()
terminal = "terminal" in name
# spyder uses ZMQshell, and can appear to be a notebook
spyder = "_" in os.environ and "spyder" in os.environ["_"]
# assume we are in a notebook if we are not in
# a terminal and we haven't been run by spyder
if (not terminal) and (not spyder):
return "jupyter"
except BaseException:
pass
try:
import marimo as mo
if mo.running_in_notebook():
return "marimo"
except BaseException:
pass
return False
@@ -0,0 +1,240 @@
# Copied from
# https://github.com/mmatl/pyrender/blob/master/pyrender/trackball.py
# MIT License
#
# Copyright (c) 2019 Matthew Matl
#
# Permission is hereby granted, free of charge, to any person obtaining a copy
# of this software and associated documentation files (the "Software"), to deal
# in the Software without restriction, including without limitation the rights
# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
# copies of the Software, and to permit persons to whom the Software is
# furnished to do so, subject to the following conditions:
#
# The above copyright notice and this permission notice shall be included in all
# copies or substantial portions of the Software.
#
# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
# SOFTWARE.
"""Trackball class for 3D manipulation of viewpoints."""
import numpy as np
from .. import transformations
class Trackball:
"""A trackball class for creating camera transforms from mouse movements."""
STATE_ROTATE = 0
STATE_PAN = 1
STATE_ROLL = 2
STATE_ZOOM = 3
def __init__(self, pose, size, scale, target=None):
"""Initialize a trackball with an initial camera-to-world pose
and the given parameters.
Parameters
----------
pose : [4,4]
An initial camera-to-world pose for the trackball.
size : (float, float)
The width and height of the camera image in pixels.
scale : float
The diagonal of the scene's bounding box --
used for ensuring translation motions are sufficiently
fast for differently-sized scenes.
target : (3,) float
The center of the scene in world coordinates.
The trackball will revolve around this point.
"""
self._size = np.array(size)
self._scale = float(scale)
self._pose = pose
self._n_pose = pose
if target is None:
self._target = np.array([0.0, 0.0, 0.0])
self._n_target = np.array([0.0, 0.0, 0.0])
else:
self._target = target
self._n_target = target
self._state = Trackball.STATE_ROTATE
@property
def pose(self):
"""autolab_core.RigidTransform : The current camera-to-world pose."""
return self._n_pose
def set_state(self, state):
"""Set the state of the trackball in order to change the effect of
dragging motions.
Parameters
----------
state : int
One of Trackball.STATE_ROTATE, Trackball.STATE_PAN,
Trackball.STATE_ROLL, and Trackball.STATE_ZOOM.
"""
self._state = state
def resize(self, size):
"""Resize the window.
Parameters
----------
size : (float, float)
The new width and height of the camera image in pixels.
"""
self._size = np.array(size)
def down(self, point):
"""Record an initial mouse press at a given point.
Parameters
----------
point : (2,) int
The x and y pixel coordinates of the mouse press.
"""
self._pdown = np.array(point, dtype=np.float32)
self._pose = self._n_pose
self._target = self._n_target
def drag(self, point):
"""Update the tracball during a drag.
Parameters
----------
point : (2,) int
The current x and y pixel coordinates of the mouse during a drag.
This will compute a movement for the trackball with the relative
motion between this point and the one marked by down().
"""
point = np.array(point, dtype=np.float32)
# get the "down" point defaulting to current point making
# this a no-op if the "down" event didn't trigger for some reason
dx, dy = point - getattr(self, "_pdown", point)
mindim = 0.3 * np.min(self._size)
target = self._target
x_axis = self._pose[:3, 0].flatten()
y_axis = self._pose[:3, 1].flatten()
z_axis = self._pose[:3, 2].flatten()
eye = self._pose[:3, 3].flatten()
# Interpret drag as a rotation
if self._state == Trackball.STATE_ROTATE:
x_angle = -dx / mindim
x_rot_mat = transformations.rotation_matrix(x_angle, y_axis, target)
y_angle = dy / mindim
y_rot_mat = transformations.rotation_matrix(y_angle, x_axis, target)
self._n_pose = y_rot_mat.dot(x_rot_mat.dot(self._pose))
# Interpret drag as a roll about the camera axis
elif self._state == Trackball.STATE_ROLL:
center = self._size / 2.0
v_init = self._pdown - center
v_curr = point - center
v_init = v_init / np.linalg.norm(v_init)
v_curr = v_curr / np.linalg.norm(v_curr)
theta = -np.arctan2(v_curr[1], v_curr[0]) + np.arctan2(v_init[1], v_init[0])
rot_mat = transformations.rotation_matrix(theta, z_axis, target)
self._n_pose = rot_mat.dot(self._pose)
# Interpret drag as a camera pan in view plane
elif self._state == Trackball.STATE_PAN:
dx = -dx / (5.0 * mindim) * self._scale
dy = -dy / (5.0 * mindim) * self._scale
translation = dx * x_axis + dy * y_axis
self._n_target = self._target + translation
t_tf = np.eye(4)
t_tf[:3, 3] = translation
self._n_pose = t_tf.dot(self._pose)
# Interpret drag as a zoom motion
elif self._state == Trackball.STATE_ZOOM:
radius = np.linalg.norm(eye - target)
ratio = 0.0
if dy > 0:
ratio = np.exp(abs(dy) / (0.5 * self._size[1])) - 1.0
elif dy < 0:
ratio = 1.0 - np.exp(dy / (0.5 * (self._size[1])))
translation = -np.sign(dy) * ratio * radius * z_axis
t_tf = np.eye(4)
t_tf[:3, 3] = translation
self._n_pose = t_tf.dot(self._pose)
def scroll(self, clicks):
"""Zoom using a mouse scroll wheel motion.
Parameters
----------
clicks : int
The number of clicks. Positive numbers indicate forward wheel
movement.
"""
target = self._target
ratio = 0.90
mult = 1.0
if clicks > 0:
mult = ratio**clicks
elif clicks < 0:
mult = (1.0 / ratio) ** abs(clicks)
z_axis = self._n_pose[:3, 2].flatten()
eye = self._n_pose[:3, 3].flatten()
radius = np.linalg.norm(eye - target)
translation = (mult * radius - radius) * z_axis
t_tf = np.eye(4)
t_tf[:3, 3] = translation
self._n_pose = t_tf.dot(self._n_pose)
z_axis = self._pose[:3, 2].flatten()
eye = self._pose[:3, 3].flatten()
radius = np.linalg.norm(eye - target)
translation = (mult * radius - radius) * z_axis
t_tf = np.eye(4)
t_tf[:3, 3] = translation
self._pose = t_tf.dot(self._pose)
def rotate(self, azimuth, axis=None):
"""Rotate the trackball about the "Up" axis by azimuth radians.
Parameters
----------
azimuth : float
The number of radians to rotate.
"""
target = self._target
y_axis = self._n_pose[:3, 1].flatten()
if axis is not None:
y_axis = axis
x_rot_mat = transformations.rotation_matrix(azimuth, y_axis, target)
self._n_pose = x_rot_mat.dot(self._n_pose)
y_axis = self._pose[:3, 1].flatten()
if axis is not None:
y_axis = axis
x_rot_mat = transformations.rotation_matrix(azimuth, y_axis, target)
self._pose = x_rot_mat.dot(self._pose)
@@ -0,0 +1,294 @@
"""
widget.py
-------------
A widget which can visualize trimesh.Scene objects in a glooey window.
Check out an example in `examples/widget.py`
"""
import warnings
import glooey
import numpy as np
import pyglet
from pyglet import gl
from trimesh import rendering
from trimesh.viewer.trackball import Trackball
from trimesh.viewer.windowed import SceneViewer, _geometry_hash
warnings.warn(
"`trimesh.viewer.widget` is deprecated and will "
+ "be removed in January 2026, please vendor `widget.py` "
+ "into your own project. It will be moved to the `examples` "
+ "of trimesh and will no longer be importable!",
category=DeprecationWarning,
stacklevel=2,
)
class SceneGroup(pyglet.graphics.Group):
def __init__(
self,
rect,
scene,
background=None,
pixel_per_point=(1, 1),
parent=None,
):
super().__init__(parent)
self.rect = rect
self.scene = scene
if background is None:
background = [0.99, 0.99, 0.99, 1.0]
self._background = background
self._pixel_per_point = pixel_per_point
def _set_view(self):
left = int(self._pixel_per_point[0] * self.rect.left)
bottom = int(self._pixel_per_point[1] * self.rect.bottom)
width = int(self._pixel_per_point[0] * self.rect.width)
height = int(self._pixel_per_point[1] * self.rect.height)
gl.glPushAttrib(gl.GL_ENABLE_BIT)
gl.glEnable(gl.GL_SCISSOR_TEST)
gl.glScissor(left, bottom, width, height)
self._mode = (gl.GLint)()
gl.glGetIntegerv(gl.GL_MATRIX_MODE, self._mode)
self._viewport = (gl.GLint * 4)()
gl.glGetIntegerv(gl.GL_VIEWPORT, self._viewport)
gl.glViewport(left, bottom, width, height)
gl.glMatrixMode(gl.GL_PROJECTION)
gl.glPushMatrix()
gl.glLoadIdentity()
near = 0.01
far = 1000.0
gl.gluPerspective(self.scene.camera.fov[1], width / height, near, far)
gl.glMatrixMode(gl.GL_MODELVIEW)
def _unset_view(self):
gl.glMatrixMode(gl.GL_PROJECTION)
gl.glPopMatrix()
gl.glMatrixMode(self._mode.value)
gl.glViewport(
self._viewport[0],
self._viewport[1],
self._viewport[2],
self._viewport[3],
)
gl.glPopAttrib()
def set_state(self):
self._set_view()
SceneViewer._gl_set_background(self._background)
SceneViewer._gl_enable_depth(self.scene.camera)
SceneViewer._gl_enable_color_material()
SceneViewer._gl_enable_blending()
SceneViewer._gl_enable_smooth_lines()
SceneViewer._gl_enable_lighting(self.scene)
gl.glClear(gl.GL_COLOR_BUFFER_BIT | gl.GL_DEPTH_BUFFER_BIT)
gl.glPushMatrix()
gl.glLoadIdentity()
gl.glMultMatrixf(
rendering.matrix_to_gl(np.linalg.inv(self.scene.camera_transform))
)
def unset_state(self):
gl.glPopMatrix()
SceneViewer._gl_unset_background()
self._unset_view()
class MeshGroup(pyglet.graphics.Group):
def __init__(self, transform=None, texture=None, parent=None):
super().__init__(parent)
if transform is None:
transform = np.eye(4)
self.transform = transform
self.texture = texture
def set_state(self):
gl.glPushMatrix()
gl.glMultMatrixf(rendering.matrix_to_gl(self.transform))
if self.texture:
gl.glEnable(self.texture.target)
gl.glBindTexture(self.texture.target, self.texture.id)
def unset_state(self):
if self.texture:
gl.glDisable(self.texture.target)
gl.glPopMatrix()
class SceneWidget(glooey.Widget):
def __init__(self, scene, **kwargs):
super().__init__()
self.scene = scene
self._scene_group = None
# key is node_name
self.mesh_group = {}
# key is geometry_name
self.vertex_list = {}
self.vertex_list_hash = {}
self.textures = {}
self._initial_camera_transform = self.scene.camera_transform.copy()
self.reset_view()
self._background = kwargs.pop("background", None)
self._smooth = kwargs.pop("smooth", True)
if kwargs:
raise TypeError(f"unexpected kwargs: {kwargs}")
@property
def scene_group(self):
if self._scene_group is None:
pixel_per_point = np.array(self.window.get_viewport_size()) / np.array(
self.window.get_size()
)
self._scene_group = SceneGroup(
rect=self.rect,
scene=self.scene,
background=self._background,
pixel_per_point=pixel_per_point,
parent=self.group,
)
return self._scene_group
def clear(self):
self._scene_group = None
self.mesh_group = {}
while self.vertex_list:
_, vertex = self.vertex_list.popitem()
vertex.delete()
self.vertex_list_hash = {}
self.textures = {}
def reset_view(self):
self.view = {
"ball": Trackball(
pose=self._initial_camera_transform,
size=self.scene.camera.resolution,
scale=self.scene.scale,
target=self.scene.centroid,
)
}
self.scene.camera_transform = self.view["ball"].pose
def do_claim(self):
return 0, 0
def do_regroup(self):
if not self.vertex_list:
return
node_names = self.scene.graph.nodes_geometry
for node_name in node_names:
transform, geometry_name = self.scene.graph[node_name]
if geometry_name not in self.vertex_list:
continue
vertex_list = self.vertex_list[geometry_name]
if node_name in self.mesh_group:
mesh_group = self.mesh_group[node_name]
else:
mesh_group = MeshGroup(
transform=transform,
texture=self.textures.get(geometry_name),
parent=self.scene_group,
)
self.mesh_group[node_name] = mesh_group
self.batch.migrate(vertex_list, gl.GL_TRIANGLES, mesh_group, self.batch)
def do_draw(self):
resolution = (self.rect.width, self.rect.height)
if not (resolution == self.scene.camera.resolution).all():
self.scene.camera.resolution = resolution
node_names = self.scene.graph.nodes_geometry
for node_name in node_names:
transform, geometry_name = self.scene.graph[node_name]
geometry = self.scene.geometry[geometry_name]
self._update_node(node_name, geometry_name, geometry, transform)
def do_undraw(self):
if not self.vertex_list:
return
for vertex_list in self.vertex_list.values():
vertex_list.delete()
self._scene_group = None
self.mesh_group = {}
self.vertex_list = {}
self.vertex_list_hash = {}
self.textures = {}
def on_mouse_press(self, x, y, buttons, modifiers):
SceneViewer.on_mouse_press(self, x, y, buttons, modifiers)
self._draw()
def on_mouse_drag(self, x, y, dx, dy, buttons, modifiers):
# detect a drag across widgets
x_prev = x - dx
y_prev = y - dy
left, bottom = self.rect.left, self.rect.bottom
width, height = self.rect.width, self.rect.height
if not (left < x_prev <= left + width) or not (
bottom < y_prev <= bottom + height
):
self.view["ball"].down(np.array([x, y]))
SceneViewer.on_mouse_drag(self, x, y, dx, dy, buttons, modifiers)
self._draw()
def on_mouse_scroll(self, x, y, dx, dy):
SceneViewer.on_mouse_scroll(self, x, y, dx, dy)
self._draw()
def _update_node(self, node_name, geometry_name, geometry, transform):
geometry_hash_new = _geometry_hash(geometry)
if self.vertex_list_hash.get(geometry_name) != geometry_hash_new:
# if geometry has texture defined convert it to opengl form
if hasattr(geometry, "visual") and hasattr(geometry.visual, "material"):
tex = rendering.material_to_texture(geometry.visual.material)
if tex is not None:
self.textures[geometry_name] = tex
if node_name in self.mesh_group:
mesh_group = self.mesh_group[node_name]
mesh_group.transform = transform
mesh_group.texture = self.textures.get(geometry_name)
else:
mesh_group = MeshGroup(
transform=transform,
texture=self.textures.get(geometry_name),
parent=self.scene_group,
)
self.mesh_group[node_name] = mesh_group
if self.vertex_list_hash.get(geometry_name) != geometry_hash_new:
if geometry_name in self.vertex_list:
self.vertex_list[geometry_name].delete()
# convert geometry to constructor args
args = rendering.convert_to_vertexlist(
geometry, group=mesh_group, smooth=self._smooth
)
# create the indexed vertex list
self.vertex_list[geometry_name] = self.batch.add_indexed(*args)
# save the MD5 of the geometry
self.vertex_list_hash[geometry_name] = geometry_hash_new
@@ -0,0 +1,933 @@
"""
windowed.py
---------------
Provides a pyglet- based windowed viewer to preview
Trimesh, Scene, PointCloud, and Path objects.
Works on all major platforms: Windows, Linux, and OSX.
"""
import collections
import numpy as np
import pyglet
# pyglet 2.0 is close to a re-write moving from fixed-function
# to shaders and we will likely support it by forking an entirely
# new viewer `trimesh.viewer.shaders` and then basically keeping
# `windowed` around for backwards-compatibility with no changes
if int(pyglet.version.split(".")[0]) >= 2:
raise ImportError('`trimesh.viewer.windowed` requires `pip install "pyglet<2"`')
from .. import rendering, util
from ..transformations import translation_matrix
from ..visual import to_rgba
from .trackball import Trackball
pyglet.options["shadow_window"] = False
import pyglet.gl as gl # NOQA
# smooth only when fewer faces than this
_SMOOTH_MAX_FACES = 100000
class SceneViewer(pyglet.window.Window):
def __init__(
self,
scene,
smooth=True,
flags=None,
visible=True,
resolution=None,
fullscreen=False,
resizable=True,
start_loop=True,
callback=None,
callback_period=None,
caption=None,
fixed=None,
offset_lines=True,
line_settings=None,
background=None,
window_conf=None,
profile=False,
record=False,
**kwargs,
):
"""
Create a window that will display a trimesh.Scene object
in an OpenGL context via pyglet.
Parameters
---------------
scene : trimesh.scene.Scene
Scene with geometry and transforms
smooth : bool
If True try to smooth shade things
flags : dict
If passed apply keys to self.view:
['cull', 'wireframe', etc]
visible : bool
Display window or not
resolution : (2,) int
Initial resolution of window
fullscreen : bool
Determines whether the window is rendered in fullscreen mode.
resizable : bool
Determines whether the rendered window can be resized by the user.
start_loop : bool
Call pyglet.app.run() at the end of init
callback : function
A function which can be called periodically to
update things in the scene
callback_period : float
How often to call the callback, in seconds
fixed : None or iterable
List of keys in scene.geometry to skip view
transform on to keep fixed relative to camera
offset_lines : bool
If True, will offset lines slightly so if drawn
coplanar with mesh geometry they will be visible
background : None or (4,) uint8
Color for background
window_conf : None, or gl.Config
Passed to window init
profile : bool
If set will run a `pyinstrument` profile for
every call to `on_draw` and print the output.
record : bool
If True, will save a list of `png` bytes to
a list located in `scene.metadata['recording']`
kwargs : dict
Additional arguments to pass, including
'background' for to set background color
"""
self.scene = self._scene = scene
self.callback = callback
self.callback_period = callback_period
self.scene._redraw = self._redraw
self.offset_lines = bool(offset_lines)
self.background = background
# save initial camera transform
self._initial_camera_transform = scene.camera_transform.copy()
# a transform to offset lines slightly to avoid Z-fighting
self._line_offset = translation_matrix(
[0, 0, scene.scale / 1000 if self.offset_lines else 0]
)
self.reset_view()
self.batch = pyglet.graphics.Batch()
self._smooth = smooth
self._profile = bool(profile)
if self._profile:
from pyinstrument import Profiler
self.Profiler = Profiler
self._record = bool(record)
if self._record:
# will save bytes here
self.scene.metadata["recording"] = []
# store kwargs
self.kwargs = kwargs
# store a vertexlist for an axis marker
self._axis = None
# store a vertexlist for a grid display
self._grid = None
# store scene geometry as vertex lists
self.vertex_list = {}
# store geometry hashes
self.vertex_list_hash = {}
# store geometry rendering mode
self.vertex_list_mode = {}
# store meshes that don't rotate relative to viewer
self.fixed = fixed
# store a hidden (don't not display) node.
self._nodes_hidden = set()
# name : texture
self.textures = {}
# if resolution isn't defined set a default value
if resolution is None:
resolution = scene.camera.resolution
else:
scene.camera.resolution = resolution
# set the default line settings to a fraction
# of our resolution so the points aren't tiny
scale = max(resolution)
self.line_settings = {"point_size": scale / 200, "line_width": scale / 400}
# if we've been passed line settings override the default
if line_settings is not None:
self.line_settings.update(line_settings)
# no window conf was passed so try to get the best looking one
if window_conf is None:
try:
# try enabling antialiasing
# if you have a graphics card this will probably work
conf = gl.Config(
sample_buffers=1, samples=4, depth_size=24, double_buffer=True
)
super().__init__(
config=conf,
visible=visible,
fullscreen=fullscreen,
resizable=resizable,
width=resolution[0],
height=resolution[1],
caption=caption,
)
except pyglet.window.NoSuchConfigException:
conf = gl.Config(double_buffer=True)
super().__init__(
config=conf,
fullscreen=fullscreen,
resizable=resizable,
visible=visible,
width=resolution[0],
height=resolution[1],
caption=caption,
)
else:
# window config was manually passed
super().__init__(
config=window_conf,
fullscreen=fullscreen,
resizable=resizable,
visible=visible,
width=resolution[0],
height=resolution[1],
caption=caption,
)
# add scene geometry to viewer geometry
self._update_vertex_list()
# call after geometry is added
self.init_gl()
self.set_size(*resolution)
if flags is not None:
self.reset_view(flags=flags)
self.update_flags()
# someone has passed a callback to be called periodically
if self.callback is not None:
# if no callback period is specified set it to default
if callback_period is None:
# 30 times per second
callback_period = 1.0 / 30.0
# set up a do-nothing periodic task which will
# trigger `self.on_draw` every `callback_period`
# seconds if someone has passed a callback
pyglet.clock.schedule_interval(lambda x: x, callback_period)
if start_loop:
pyglet.app.run()
def _redraw(self):
self.on_draw()
def _update_vertex_list(self):
# update vertex_list if needed
for name, geom in self.scene.geometry.items():
if geom.is_empty:
continue
if _geometry_hash(geom) == self.vertex_list_hash.get(name):
continue
self.add_geometry(name=name, geometry=geom, smooth=bool(self._smooth))
def _update_meshes(self):
# call the callback if specified
if self.callback is not None:
self.callback(self.scene)
self._update_vertex_list()
self._update_perspective(self.width, self.height)
def add_geometry(self, name, geometry, **kwargs):
"""
Add a geometry to the viewer.
Parameters
--------------
name : hashable
Name that references geometry
geometry : Trimesh, Path2D, Path3D, PointCloud
Geometry to display in the viewer window
kwargs **
Passed to rendering.convert_to_vertexlist
"""
try:
# convert geometry to constructor args
args = rendering.convert_to_vertexlist(geometry, **kwargs)
except BaseException:
util.log.warning(f"failed to add geometry `{name}`", exc_info=True)
return
# create the indexed vertex list
self.vertex_list[name] = self.batch.add_indexed(*args)
# save the hash of the geometry
self.vertex_list_hash[name] = _geometry_hash(geometry)
# save the rendering mode from the constructor args
self.vertex_list_mode[name] = args[1]
# get the visual if the element has it
visual = getattr(geometry, "visual", None)
if hasattr(visual, "uv") and hasattr(visual, "material"):
try:
tex = rendering.material_to_texture(visual.material)
if tex is not None:
self.textures[name] = tex
except BaseException:
util.log.warning("failed to load texture", exc_info=True)
def cleanup_geometries(self):
"""
Remove any stored vertex lists that no longer
exist in the scene.
"""
# shorthand to scene graph
graph = self.scene.graph
# which parts of the graph still have geometry
geom_keep = {graph[node][1] for node in graph.nodes_geometry}
# which geometries no longer need to be kept
geom_delete = [geom for geom in self.vertex_list if geom not in geom_keep]
for geom in geom_delete:
# remove stored vertex references
self.vertex_list.pop(geom, None)
self.vertex_list_hash.pop(geom, None)
self.vertex_list_mode.pop(geom, None)
self.textures.pop(geom, None)
def unhide_geometry(self, node):
"""
If a node is hidden remove the flag and show the
geometry on the next draw.
Parameters
-------------
node : str
Node to display
"""
self._nodes_hidden.discard(node)
def hide_geometry(self, node):
"""
Don't display the geometry contained at a node on
the next draw.
Parameters
-------------
node : str
Node to not display
"""
self._nodes_hidden.add(node)
def reset_view(self, flags=None):
"""
Set view to the default view.
Parameters
--------------
flags : None or dict
If any view key passed override the default
e.g. {'cull': False}
"""
self.view = {
"cull": True,
"axis": False,
"grid": False,
"fullscreen": False,
"wireframe": False,
"ball": Trackball(
pose=self._initial_camera_transform,
size=self.scene.camera.resolution,
scale=self.scene.scale,
target=self.scene.centroid,
),
}
try:
# if any flags are passed override defaults
if isinstance(flags, dict):
for k, v in flags.items():
if k in self.view:
self.view[k] = v
self.update_flags()
except BaseException:
pass
def init_gl(self):
"""
Perform the magic incantations to create an
OpenGL scene using pyglet.
"""
# if user passed a background color use it
if self.background is None:
# default background color is white
background = np.ones(4)
else:
# convert to (4,) uint8 RGBA
background = to_rgba(self.background)
# convert to 0.0-1.0 float
background = background.astype(np.float64) / 255.0
self._gl_set_background(background)
# use camera setting for depth
self._gl_enable_depth(self.scene.camera)
self._gl_enable_color_material()
self._gl_enable_blending()
self._gl_enable_smooth_lines(**self.line_settings)
self._gl_enable_lighting(self.scene)
@staticmethod
def _gl_set_background(background):
gl.glClearColor(*background)
@staticmethod
def _gl_unset_background():
gl.glClearColor(*[0, 0, 0, 0])
@staticmethod
def _gl_enable_depth(camera):
"""
Enable depth test in OpenGL using distances
from `scene.camera`.
"""
gl.glClearDepth(1.0)
gl.glEnable(gl.GL_DEPTH_TEST)
gl.glDepthFunc(gl.GL_LEQUAL)
gl.glEnable(gl.GL_DEPTH_TEST)
gl.glEnable(gl.GL_CULL_FACE)
@staticmethod
def _gl_enable_color_material():
# do some openGL things
gl.glColorMaterial(gl.GL_FRONT_AND_BACK, gl.GL_AMBIENT_AND_DIFFUSE)
gl.glEnable(gl.GL_COLOR_MATERIAL)
gl.glShadeModel(gl.GL_SMOOTH)
gl.glMaterialfv(
gl.GL_FRONT,
gl.GL_AMBIENT,
rendering.vector_to_gl(0.192250, 0.192250, 0.192250),
)
gl.glMaterialfv(
gl.GL_FRONT,
gl.GL_DIFFUSE,
rendering.vector_to_gl(0.507540, 0.507540, 0.507540),
)
gl.glMaterialfv(
gl.GL_FRONT,
gl.GL_SPECULAR,
rendering.vector_to_gl(0.5082730, 0.5082730, 0.5082730),
)
gl.glMaterialf(gl.GL_FRONT, gl.GL_SHININESS, 0.4 * 128.0)
@staticmethod
def _gl_enable_blending():
# enable blending for transparency
gl.glEnable(gl.GL_BLEND)
gl.glBlendFunc(gl.GL_SRC_ALPHA, gl.GL_ONE_MINUS_SRC_ALPHA)
@staticmethod
def _gl_enable_smooth_lines(line_width=4, point_size=4):
# make the lines from Path3D objects less ugly
gl.glEnable(gl.GL_LINE_SMOOTH)
gl.glHint(gl.GL_LINE_SMOOTH_HINT, gl.GL_NICEST)
# set the width of lines to 4 pixels
gl.glLineWidth(line_width)
# set PointCloud markers to 4 pixels in size
gl.glPointSize(point_size)
@staticmethod
def _gl_enable_lighting(scene):
"""
Take the lights defined in scene.lights and
apply them as openGL lights.
"""
gl.glEnable(gl.GL_LIGHTING)
# opengl only supports 7 lights?
for i, light in enumerate(scene.lights[:7]):
# the index of which light we have
lightN = eval(f"gl.GL_LIGHT{i}")
# get the transform for the light by name
matrix = scene.graph.get(light.name)[0]
# convert light object to glLightfv calls
multiargs = rendering.light_to_gl(
light=light, transform=matrix, lightN=lightN
)
# enable the light in question
gl.glEnable(lightN)
# run the glLightfv calls
for args in multiargs:
gl.glLightfv(*args)
def toggle_culling(self):
"""
Toggle back face culling.
It is on by default but if you are dealing with
non- watertight meshes you probably want to be able
to see the back sides.
"""
self.view["cull"] = not self.view["cull"]
self.update_flags()
def toggle_wireframe(self):
"""
Toggle wireframe mode
Good for looking inside meshes, off by default.
"""
self.view["wireframe"] = not self.view["wireframe"]
self.update_flags()
def toggle_fullscreen(self):
"""
Toggle between fullscreen and windowed mode.
"""
self.view["fullscreen"] = not self.view["fullscreen"]
self.update_flags()
def toggle_axis(self):
"""
Toggle a rendered XYZ/RGB axis marker:
off, world frame, every frame
"""
# cycle through three axis states
states = [False, "world", "all", "without_world"]
# the state after toggling
index = (states.index(self.view["axis"]) + 1) % len(states)
# update state to next index
self.view["axis"] = states[index]
# perform gl actions
self.update_flags()
def toggle_grid(self):
"""
Toggle a rendered grid.
"""
# update state to next index
self.view["grid"] = not self.view["grid"]
# perform gl actions
self.update_flags()
def update_flags(self):
"""
Check the view flags, and call required GL functions.
"""
# view mode, filled vs wirefrom
if self.view["wireframe"]:
gl.glPolygonMode(gl.GL_FRONT_AND_BACK, gl.GL_LINE)
else:
gl.glPolygonMode(gl.GL_FRONT_AND_BACK, gl.GL_FILL)
# set fullscreen or windowed
self.set_fullscreen(fullscreen=self.view["fullscreen"])
# backface culling on or off
if self.view["cull"]:
gl.glEnable(gl.GL_CULL_FACE)
else:
gl.glDisable(gl.GL_CULL_FACE)
# case where we WANT an axis and NO vertexlist
# is stored internally
if self.view["axis"] and self._axis is None:
from .. import creation
# create an axis marker sized relative to the scene
axis = creation.axis(origin_size=self.scene.scale / 100)
# create ordered args for a vertex list
args = rendering.mesh_to_vertexlist(axis)
# store the axis as a reference
self._axis = self.batch.add_indexed(*args)
# case where we DON'T want an axis but a vertexlist
# IS stored internally
elif not self.view["axis"] and self._axis is not None:
# remove the axis from the rendering batch
self._axis.delete()
# set the reference to None
self._axis = None
if self.view["grid"] and self._grid is None:
try:
# create a grid marker
from ..path.creation import grid
bounds = self.scene.bounds
center = bounds.mean(axis=0)
# set the grid to the lowest Z position
# also offset by the scale to avoid interference
center[2] = bounds[0][2] - (np.ptp(bounds[:, 2]) / 100)
# choose the side length by maximum XY length
side = np.ptp(bounds, axis=0)[:2].max()
# create an axis marker sized relative to the scene
grid_mesh = grid(side=side, count=4, transform=translation_matrix(center))
# convert the path to vertexlist args
args = rendering.convert_to_vertexlist(grid_mesh)
# create ordered args for a vertex list
self._grid = self.batch.add_indexed(*args)
except BaseException:
util.log.warning("failed to create grid!", exc_info=True)
elif not self.view["grid"] and self._grid is not None:
self._grid.delete()
self._grid = None
def _update_perspective(self, width, height):
try:
# for high DPI screens viewport size
# will be different then the passed size
width, height = self.get_viewport_size()
except BaseException:
# older versions of pyglet may not have this
pass
# set the new viewport size
gl.glViewport(0, 0, width, height)
gl.glMatrixMode(gl.GL_PROJECTION)
gl.glLoadIdentity()
# get field of view and Z range from camera
camera = self.scene.camera
# set perspective from camera data
gl.gluPerspective(
camera.fov[1], width / float(height), camera.z_near, camera.z_far
)
gl.glMatrixMode(gl.GL_MODELVIEW)
return width, height
def on_resize(self, width, height):
"""
Handle resized windows.
"""
width, height = self._update_perspective(width, height)
self.scene.camera.resolution = (width, height)
self.view["ball"].resize(self.scene.camera.resolution)
self.scene.camera_transform = self.view["ball"].pose
def on_mouse_press(self, x, y, buttons, modifiers):
"""
Set the start point of the drag.
"""
self.view["ball"].set_state(Trackball.STATE_ROTATE)
if buttons == pyglet.window.mouse.LEFT:
ctrl = modifiers & pyglet.window.key.MOD_CTRL
shift = modifiers & pyglet.window.key.MOD_SHIFT
if ctrl and shift:
self.view["ball"].set_state(Trackball.STATE_ZOOM)
elif shift:
self.view["ball"].set_state(Trackball.STATE_ROLL)
elif ctrl:
self.view["ball"].set_state(Trackball.STATE_PAN)
elif buttons == pyglet.window.mouse.MIDDLE:
self.view["ball"].set_state(Trackball.STATE_PAN)
elif buttons == pyglet.window.mouse.RIGHT:
self.view["ball"].set_state(Trackball.STATE_ZOOM)
self.view["ball"].down(np.array([x, y]))
self.scene.camera_transform = self.view["ball"].pose
def on_mouse_drag(self, x, y, dx, dy, buttons, modifiers):
"""
Pan or rotate the view.
"""
self.view["ball"].drag(np.array([x, y]))
self.scene.camera_transform = self.view["ball"].pose
def on_mouse_scroll(self, x, y, dx, dy):
"""
Zoom the view.
"""
self.view["ball"].scroll(dy)
self.scene.camera_transform = self.view["ball"].pose
def on_key_press(self, symbol, modifiers):
"""
Call appropriate functions given key presses.
"""
magnitude = 10
if symbol == pyglet.window.key.W:
self.toggle_wireframe()
elif symbol == pyglet.window.key.Z:
self.reset_view()
elif symbol == pyglet.window.key.C:
self.toggle_culling()
elif symbol == pyglet.window.key.A:
self.toggle_axis()
elif symbol == pyglet.window.key.G:
self.toggle_grid()
elif symbol == pyglet.window.key.Q:
self.on_close()
elif symbol == pyglet.window.key.M:
self.maximize()
elif symbol == pyglet.window.key.F:
self.toggle_fullscreen()
if symbol in [
pyglet.window.key.LEFT,
pyglet.window.key.RIGHT,
pyglet.window.key.DOWN,
pyglet.window.key.UP,
]:
self.view["ball"].down([0, 0])
if symbol == pyglet.window.key.LEFT:
self.view["ball"].drag([-magnitude, 0])
elif symbol == pyglet.window.key.RIGHT:
self.view["ball"].drag([magnitude, 0])
elif symbol == pyglet.window.key.DOWN:
self.view["ball"].drag([0, -magnitude])
elif symbol == pyglet.window.key.UP:
self.view["ball"].drag([0, magnitude])
self.scene.camera_transform = self.view["ball"].pose
def on_draw(self):
"""
Run the actual draw calls.
"""
if self._profile:
profiler = self.Profiler()
profiler.start()
self._update_meshes()
gl.glClear(gl.GL_COLOR_BUFFER_BIT | gl.GL_DEPTH_BUFFER_BIT)
gl.glLoadIdentity()
# pull the new camera transform from the scene
transform_camera = np.linalg.inv(self.scene.camera_transform)
# apply the camera transform to the matrix stack
gl.glMultMatrixf(rendering.matrix_to_gl(transform_camera))
# we want to render fully opaque objects first,
# followed by objects which have transparency
node_names = collections.deque(self.scene.graph.nodes_geometry)
# how many nodes did we start with
count_original = len(node_names)
count = -1
# if we are rendering an axis marker at the world
if self._axis and not self.view["axis"] == "without_world":
# we stored it as a vertex list
self._axis.draw(mode=gl.GL_TRIANGLES)
if self._grid:
self._grid.draw(mode=gl.GL_LINES)
# save a reference outside of the loop
geometry = self.scene.geometry
graph = self.scene.graph
while len(node_names) > 0:
count += 1
current_node = node_names.popleft()
if current_node in self._nodes_hidden:
continue
# get the transform from world to geometry and mesh name
transform, geometry_name = graph.get(current_node)
# if no geometry at this frame continue without rendering
if geometry_name is None or geometry_name not in self.vertex_list_mode:
continue
# if a geometry is marked as fixed apply the inverse view transform
if self.fixed is not None and geometry_name in self.fixed:
# remove altered camera transform from fixed geometry
transform_fix = np.linalg.inv(
np.dot(self._initial_camera_transform, transform_camera)
)
# apply the transform so the fixed geometry doesn't move
transform = np.dot(transform, transform_fix)
# get a reference to the mesh so we can check transparency
mesh = geometry[geometry_name]
if mesh.is_empty:
continue
# get the GL mode of the current geometry
mode = self.vertex_list_mode[geometry_name]
# if you draw a coplanar line with a triangle it will z-fight
# the best way to do this is probably a shader but this works fine
if mode == gl.GL_LINES:
# apply the offset in camera space
transform = util.multi_dot(
[
transform,
np.linalg.inv(transform_camera),
self._line_offset,
transform_camera,
]
)
# add a new matrix to the model stack
gl.glPushMatrix()
# transform by the nodes transform
gl.glMultMatrixf(rendering.matrix_to_gl(transform))
# draw an axis marker for each mesh frame
if self.view["axis"] == "all":
self._axis.draw(mode=gl.GL_TRIANGLES)
elif self.view["axis"] == "without_world":
if not util.allclose(transform, np.eye(4), atol=1e-5):
self._axis.draw(mode=gl.GL_TRIANGLES)
# transparent things must be drawn last
if (
hasattr(mesh, "visual")
and hasattr(mesh.visual, "transparency")
and mesh.visual.transparency
):
# put the current item onto the back of the queue
if count < count_original:
# add the node to be drawn last
node_names.append(current_node)
# pop the matrix stack for now
gl.glPopMatrix()
# come back to this mesh later
continue
# if we have texture enable the target texture
texture = None
if geometry_name in self.textures:
texture = self.textures[geometry_name]
gl.glEnable(texture.target)
gl.glBindTexture(texture.target, texture.id)
# draw the mesh with its transform applied
self.vertex_list[geometry_name].draw(mode=mode)
# pop the matrix stack as we drew what we needed to draw
gl.glPopMatrix()
# disable texture after using
if texture is not None:
gl.glDisable(texture.target)
if self._profile:
profiler.stop()
util.log.debug(profiler.output_text(unicode=True, color=True))
def flip(self):
super().flip()
if self._record:
# will save a PNG-encoded bytes
img = self.save_image(util.BytesIO())
# seek start of file-like object
img.seek(0)
# save the bytes from the file object
self.scene.metadata["recording"].append(img.read())
def save_image(self, file_obj):
"""
Save the current color buffer to a file object
in PNG format.
Parameters
-------------
file_obj: file name, or file- like object
"""
manager = pyglet.image.get_buffer_manager()
colorbuffer = manager.get_color_buffer()
# if passed a string save by name
if hasattr(file_obj, "write"):
colorbuffer.save(file=file_obj)
else:
colorbuffer.save(filename=file_obj)
return file_obj
def _geometry_hash(geometry):
"""
Get a hash for a geometry object
Parameters
------------
geometry : object
Returns
------------
hash : str
"""
h = str(hash(geometry))
if hasattr(geometry, "visual"):
# if visual properties are defined
h += str(hash(geometry.visual))
return h
def render_scene(
scene, resolution=None, visible=True, fullscreen=False, resizable=True, **kwargs
):
"""
Render a preview of a scene to a PNG. Note that
whether this works or not highly variable based on
platform and graphics driver.
Parameters
------------
scene : trimesh.Scene
Geometry to be rendered
resolution : (2,) int or None
Resolution in pixels or set from scene.camera
visible : bool
Show a window during rendering. Note that MANY
platforms refuse to render with hidden windows
and will likely return a blank image; this is a
platform issue and cannot be fixed in Python.
fullscreen : bool
Determines whether the window is rendered in fullscreen mode.
Defaults to False (windowed).
resizable : bool
Determines whether the rendered window can be resized by the user.
Defaults to True (resizable).
kwargs : **
Passed to SceneViewer
Returns
---------
render : bytes
Image in PNG format
"""
window = SceneViewer(
scene,
start_loop=False,
visible=visible,
resolution=resolution,
fullscreen=fullscreen,
resizable=resizable,
**kwargs,
)
from ..util import BytesIO
# need to run loop twice to display anything
for save in [False, False, True]:
pyglet.clock.tick()
window.switch_to()
window.dispatch_events()
window.dispatch_event("on_draw")
window.flip()
if save:
# save the color buffer data to memory
file_obj = BytesIO()
window.save_image(file_obj)
file_obj.seek(0)
render = file_obj.read()
window.close()
return render