diff --git a/trimesh/exchange/gltf.py b/trimesh/exchange/gltf.py index 86e0ade7f..11697b0b1 100644 --- a/trimesh/exchange/gltf.py +++ b/trimesh/exchange/gltf.py @@ -13,7 +13,7 @@ import numpy as np -from .. import rendering, resources, transformations, util, visual +from .. import resources, transformations, util, visual from ..caching import hash_fast from ..constants import log, tol from ..resolvers import ResolverLike, ZipResolver diff --git a/trimesh/rendering.py b/trimesh/rendering.py index 6585711f7..4ca60da57 100644 --- a/trimesh/rendering.py +++ b/trimesh/rendering.py @@ -1,417 +1 @@ -""" -rendering.py --------------- - -Functions to convert trimesh objects to pyglet/opengl objects. -""" - -import numpy as np - -from . import util - -# avoid importing pyglet or pyglet.gl -# as pyglet does things on import -GL_POINTS, GL_LINES, GL_TRIANGLES = (0, 1, 4) - - -def convert_to_vertexlist(geometry, **kwargs): - """ - Try to convert various geometry objects to the constructor - args for a pyglet indexed vertex list. - - Parameters - ------------ - obj : Trimesh, Path2D, Path3D, (n,2) float, (n,3) float - Object to render - - Returns - ------------ - args : tuple - Args to be passed to pyglet indexed vertex list - constructor. - """ - if util.is_instance_named(geometry, "Trimesh"): - return mesh_to_vertexlist(geometry, **kwargs) - elif util.is_instance_named(geometry, "Path"): - # works for Path3D and Path2D - # both of which inherit from Path - return path_to_vertexlist(geometry, **kwargs) - elif util.is_instance_named(geometry, "PointCloud"): - # pointcloud objects contain colors - return points_to_vertexlist(geometry.vertices, colors=geometry.colors, **kwargs) - elif util.is_instance_named(geometry, "ndarray"): - # (n,2) or (n,3) points - return points_to_vertexlist(geometry, **kwargs) - elif util.is_instance_named(geometry, "VoxelGrid"): - # for voxels view them as a bunch of boxes - return mesh_to_vertexlist(geometry.as_boxes(**kwargs), **kwargs) - else: - raise ValueError("Geometry passed is not a viewable type!") - - -def mesh_to_vertexlist(mesh, group=None, smooth=True, smooth_threshold=60000): - """ - Convert a Trimesh object to arguments for an - indexed vertex list constructor. - - Parameters - ------------- - mesh : trimesh.Trimesh - Mesh to be rendered - group : str - Rendering group for the vertex list - smooth : bool - Should we try to smooth shade the mesh - smooth_threshold : int - Maximum number of faces to smooth shade - - Returns - -------------- - args : (7,) tuple - Args for vertex list constructor - - """ - # nominally support 2D vertices - if len(mesh.vertices.shape) == 2 and mesh.vertices.shape[1] == 2: - vertices = np.column_stack((mesh.vertices, np.zeros(len(mesh.vertices)))) - else: - vertices = mesh.vertices - - if hasattr(mesh.visual, "uv"): - # if the mesh has texture defined pass it to pyglet - vertex_count = len(vertices) - normals = mesh.vertex_normals - faces = mesh.faces - - # get the per-vertex UV coordinates - uv = mesh.visual.uv - - # shortcut for the material - material = mesh.visual.material - if hasattr(material, "image"): - # does the material actually have an image specified - no_image = material.image is None - elif hasattr(material, "baseColorTexture"): - no_image = material.baseColorTexture is None - else: - no_image = True - - # didn't get valid texture so skip it - if uv is None or no_image or len(uv) != vertex_count: - # if no UV coordinates on material, just set face colors - # to the diffuse color of the material - color_gl = colors_to_gl(material.main_color, vertex_count) - else: - # if someone passed (n, 3) UVR cut it off here - if uv.shape[1] > 2: - uv = uv[:, :2] - # texcoord as (2,) float - color_gl = ("t2f/static", uv.astype(np.float64).reshape(-1).tolist()) - - elif smooth and len(mesh.faces) < smooth_threshold: - # if we have a small number of faces and colors defined - # smooth the mesh by merging vertices of faces below - # the threshold angle - smooth = mesh.smooth_shaded - vertices = smooth.vertices - vertex_count = len(vertices) - normals = smooth.vertex_normals - faces = smooth.faces - color_gl = colors_to_gl(smooth.visual.vertex_colors, vertex_count) - else: - # we don't have textures or want to smooth so - # send a polygon soup of disconnected triangles to opengl - vertex_count = len(mesh.faces) * 3 - normals = np.tile(mesh.face_normals, (1, 3)) - vertices = vertices[mesh.faces] - faces = np.arange(vertex_count, dtype=np.int64) - colors = np.tile(mesh.visual.face_colors, (1, 3)).reshape((-1, 4)) - color_gl = colors_to_gl(colors, vertex_count) - - # create the ordered tuple for pyglet, use like: - # `batch.add_indexed(*args)` - args = ( - vertex_count, # number of vertices - GL_TRIANGLES, # mode - group, # group - faces.reshape(-1).tolist(), # indices - ("v3f/static", vertices.reshape(-1).tolist()), - ("n3f/static", normals.reshape(-1).tolist()), - color_gl, - ) - - return args - - -def path_to_vertexlist(path, group=None, **kwargs): - """ - Convert a Path3D object to arguments for a - pyglet indexed vertex list constructor. - - Parameters - ------------- - path : trimesh.path.Path3D object - Mesh to be rendered - group : str - Rendering group for the vertex list - - Returns - -------------- - args : (7,) tuple - Args for vertex list constructor - """ - # avoid cache check inside tight loop - vertices = path.vertices - - # get (n, 2, (2|3)) lines - stacked = [util.stack_lines(e.discrete(vertices)) for e in path.entities] - lines = util.vstack_empty(stacked) - count = len(lines) - - # stack zeros for 2D lines - if util.is_shape(vertices, (-1, 2)): - lines = lines.reshape((-1, 2)) - lines = np.column_stack((lines, np.zeros(len(lines)))) - # index for GL is one per point - index = np.arange(count).tolist() - # convert from entity color to the color of - # each vertex in the line segments - colors = path.colors - if colors is not None: - colors = np.vstack( - [ - (np.ones((len(s), 4)) * c).astype(np.uint8) - for s, c in zip(stacked, path.colors) - ] - ) - # convert to gl-friendly colors - gl_colors = colors_to_gl(colors, count=count) - - # collect args for vertexlist constructor - args = ( - count, # number of lines - GL_LINES, # mode - group, # group - index, # indices - ("v3f/static", lines.reshape(-1)), - gl_colors, - ) - return args - - -def points_to_vertexlist(points, colors=None, group=None, **kwargs): - """ - Convert a numpy array of 3D points to args for - a vertex list constructor. - - Parameters - ------------- - points : (n, 3) float - Points to be rendered - colors : (n, 3) or (n, 4) float - Colors for each point - group : str - Rendering group for the vertex list - - Returns - -------------- - args : (7,) tuple - Args for vertex list constructor - """ - points = np.asanyarray(points, dtype=np.float64) - - if util.is_shape(points, (-1, 2)): - points = np.column_stack((points, np.zeros(len(points)))) - elif not util.is_shape(points, (-1, 3)): - raise ValueError("Pointcloud must be (n,3)!") - - index = np.arange(len(points)).tolist() - - args = ( - len(points), # number of vertices - GL_POINTS, # mode - group, # group - index, # indices - ("v3f/static", points.reshape(-1)), - colors_to_gl(colors, len(points)), - ) - return args - - -def colors_to_gl(colors, count): - """ - Given a list of colors (or None) return a GL-acceptable - list of colors. - - Parameters - ------------ - colors: (count, (3 or 4)) float - Input colors as an array - - Returns - --------- - colors_type : str - Color type - colors_gl : (count,) list - Colors to pass to pyglet - """ - - colors = np.asanyarray(colors) - count = int(count) - # get the GL kind of color we have - colors_dtypes = {"f": "f", "i": "B", "u": "B"} - - if colors.dtype.kind in colors_dtypes: - dtype = colors_dtypes[colors.dtype.kind] - else: - dtype = None - - if dtype is not None and util.is_shape(colors, (count, (3, 4))): - # save the shape and dtype for opengl color string - colors_type = f"c{colors.shape[1]}{dtype}/static" - # reshape the 2D array into a 1D one and then convert to a python list - gl_colors = colors.reshape(-1).tolist() - elif dtype is not None and colors.shape in [(3,), (4,)]: - # we've been passed a single color so tile them - gl_colors = ( - (np.ones((count, colors.size), dtype=colors.dtype) * colors) - .reshape(-1) - .tolist() - ) - # we know we're tiling - colors_type = f"c{colors.size}{dtype}/static" - else: - # case where colors are wrong shape - # use black as the default color - gl_colors = np.tile([0.0, 0.0, 0.0], (count, 1)).reshape(-1).tolist() - # we're returning RGB float colors - colors_type = "c3f/static" - - return colors_type, gl_colors - - -def material_to_texture(material, upsize=True): - """ - Convert a trimesh.visual.texture.Material object into - a pyglet-compatible texture object. - - Parameters - -------------- - material : trimesh.visual.texture.Material - Material to be converted - upsize: bool - If True, will upscale textures to their nearest power - of two resolution to avoid weirdness - - Returns - --------------- - texture : pyglet.image.Texture - Texture loaded into pyglet form - """ - import pyglet - - # try to extract a PIL image from material - if hasattr(material, "image"): - img = material.image - elif hasattr(material, "baseColorTexture"): - img = material.baseColorTexture - else: - return None - - # if no images in texture return now - if img is None: - return None - - # if we're not powers of two upsize - if upsize: - from .visual.texture import power_resize - - img = power_resize(img) - - # use a PNG export to exchange into pyglet - # probably a way to do this with a PIL converter - with util.BytesIO() as f: - # export PIL image as PNG - img.save(f, format="png") - f.seek(0) - # filename used for format guess - gl_image = pyglet.image.load(filename=".png", file=f) - - # turn image into pyglet texture - texture = gl_image.get_texture() - - return texture - - -def matrix_to_gl(matrix): - """ - Convert a numpy row-major homogeneous transformation matrix - to a flat column-major GLfloat transformation. - - Parameters - ------------- - matrix : (4,4) float - Row-major homogeneous transform - - Returns - ------------- - glmatrix : (16,) gl.GLfloat - Transform in pyglet format - """ - from pyglet import gl - - # convert to GLfloat, switch to column major and flatten to (16,) - return (gl.GLfloat * 16)(*np.array(matrix, dtype=np.float32).T.ravel()) - - -def vector_to_gl(array, *args): - """ - Convert an array and an optional set of args into a - flat vector of gl.GLfloat - """ - from pyglet import gl - - array = np.array(array) - if len(args) > 0: - array = np.append(array, args) - vector = (gl.GLfloat * len(array))(*array) - return vector - - -def light_to_gl(light, transform, lightN): - """ - Convert trimesh.scene.lighting.Light objects into - args for gl.glLightFv calls - - Parameters - -------------- - light : trimesh.scene.lighting.Light - Light object to be converted to GL - transform : (4, 4) float - Transformation matrix of light - lightN : int - Result of gl.GL_LIGHT0, gl.GL_LIGHT1, etc - - Returns - -------------- - multiarg : [tuple] - List of args to pass to gl.glLightFv eg: - [gl.glLightfb(*a) for a in multiarg] - """ - from pyglet import gl - - # convert color to opengl - gl_color = vector_to_gl(light.color.astype(np.float64) / 255.0) - assert len(gl_color) == 4 - - # cartesian translation from matrix - gl_position = vector_to_gl(transform[:3, 3]) - - # create the different position and color arguments - args = [ - (lightN, gl.GL_POSITION, gl_position), - (lightN, gl.GL_SPECULAR, gl_color), - (lightN, gl.GL_DIFFUSE, gl_color), - (lightN, gl.GL_AMBIENT, gl_color), - ] - return args +from .viewer.pyglet1.conversion import * diff --git a/trimesh/scene/cameras.py b/trimesh/scene/cameras.py index 2b83efd36..e4e420ab6 100644 --- a/trimesh/scene/cameras.py +++ b/trimesh/scene/cameras.py @@ -3,6 +3,7 @@ import numpy as np from .. import util +from ..typed import NDArray class Camera: @@ -223,6 +224,31 @@ def fov(self, values): # fov overrides focal self._focal = None + @property + def projection(self) -> NDArray[np.float64]: + """ + Get the (4, 4) projection matrix for this perspective camera. + + Returns + -------------- + projection_matrix : (4, 4) float + Projection matrix for the camera + """ + z_near, z_far, fov_y = self.z_near, self.z_far, self.fov[1] + + aspect = np.divide(*self.resolution) + + f = 1.0 / np.tan(fov_y / 2.0) + projection_matrix = np.eye(4) + + projection_matrix[0, 0] = f / aspect + projection_matrix[1, 1] = f + projection_matrix[2, 2] = (z_far + z_near) / (z_near - z_far) + projection_matrix[2, 3] = (2 * z_far * z_near) / (z_near - z_far) + projection_matrix[3, 2] = -1.0 + + return projection_matrix + def to_rays(self): """ Calculate ray direction vectors. diff --git a/trimesh/util.py b/trimesh/util.py index aac85f02a..ff472fcab 100644 --- a/trimesh/util.py +++ b/trimesh/util.py @@ -2243,7 +2243,6 @@ def allclose(a, b, atol: float = 1e-8): ----------- bool indicating if all elements are within `atol`. """ - # return float(np.ptp(a - b)) < atol diff --git a/trimesh/viewer/__init__.py b/trimesh/viewer/__init__.py index ff3c126b1..52c3d5b7f 100644 --- a/trimesh/viewer/__init__.py +++ b/trimesh/viewer/__init__.py @@ -16,27 +16,22 @@ 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) + from .pyglet2 import SceneViewer, render_scene +except ImportError as E: + try: + from .pyglet1 import SceneViewer, render_scene + # if windowed failed to import only raise + # the exception if someone tries to use them + except ImportError as J: + print(J) + SceneViewer = exceptions.ExceptionWrapper(E) + render_scene = exceptions.ExceptionWrapper(E) -try: - from .widget import SceneWidget -except BaseException as E: - SceneWidget = 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", diff --git a/trimesh/viewer/pyglet1/__init__.py b/trimesh/viewer/pyglet1/__init__.py new file mode 100644 index 000000000..0d0cc378a --- /dev/null +++ b/trimesh/viewer/pyglet1/__init__.py @@ -0,0 +1 @@ +from .viewer import SceneViewer, render_scene diff --git a/trimesh/viewer/pyglet1/conversion.py b/trimesh/viewer/pyglet1/conversion.py new file mode 100644 index 000000000..cb196d1e1 --- /dev/null +++ b/trimesh/viewer/pyglet1/conversion.py @@ -0,0 +1,418 @@ +""" +rendering.py +-------------- + +Functions to convert trimesh objects to pyglet/opengl objects. +""" + +import numpy as np + +from ... import util + +# avoid importing pyglet or pyglet.gl +# as pyglet does things on import +GL_POINTS, GL_LINES, GL_TRIANGLES = (0, 1, 4) + + +def convert_to_vertexlist(geometry, **kwargs): + """ + Try to convert various geometry objects to the constructor + args for a pyglet indexed vertex list. + + Parameters + ------------ + obj : Trimesh, Path2D, Path3D, (n,2) float, (n,3) float + Object to render + + Returns + ------------ + args : tuple + Args to be passed to pyglet indexed vertex list + constructor. + """ + if util.is_instance_named(geometry, "Trimesh"): + return mesh_to_vertexlist(geometry, **kwargs) + elif util.is_instance_named(geometry, "Path"): + # works for Path3D and Path2D + # both of which inherit from Path + return path_to_vertexlist(geometry, **kwargs) + elif util.is_instance_named(geometry, "PointCloud"): + # pointcloud objects contain colors + return points_to_vertexlist(geometry.vertices, colors=geometry.colors, **kwargs) + elif util.is_instance_named(geometry, "ndarray"): + # (n,2) or (n,3) points + return points_to_vertexlist(geometry, **kwargs) + elif util.is_instance_named(geometry, "VoxelGrid"): + # for voxels view them as a bunch of boxes + return mesh_to_vertexlist(geometry.as_boxes(**kwargs), **kwargs) + else: + raise ValueError("Geometry passed is not a viewable type!") + + +def mesh_to_vertexlist(mesh, group=None, smooth=True, smooth_threshold=60000): + """ + Convert a Trimesh object to arguments for an + indexed vertex list constructor. + + Parameters + ------------- + mesh : trimesh.Trimesh + Mesh to be rendered + group : str + Rendering group for the vertex list + smooth : bool + Should we try to smooth shade the mesh + smooth_threshold : int + Maximum number of faces to smooth shade + + Returns + -------------- + args : (7,) tuple + Args for vertex list constructor + + """ + # nominally support 2D vertices + if len(mesh.vertices.shape) == 2 and mesh.vertices.shape[1] == 2: + vertices = np.column_stack((mesh.vertices, np.zeros(len(mesh.vertices)))) + else: + vertices = mesh.vertices + + if hasattr(mesh.visual, "uv"): + # if the mesh has texture defined pass it to pyglet + vertex_count = len(vertices) + normals = mesh.vertex_normals + faces = mesh.faces + + # get the per-vertex UV coordinates + uv = mesh.visual.uv + + # shortcut for the material + material = mesh.visual.material + if hasattr(material, "image"): + # does the material actually have an image specified + no_image = material.image is None + elif hasattr(material, "baseColorTexture"): + no_image = material.baseColorTexture is None + else: + no_image = True + + # didn't get valid texture so skip it + if uv is None or no_image or len(uv) != vertex_count: + # if no UV coordinates on material, just set face colors + # to the diffuse color of the material + color_gl = colors_to_gl(material.main_color, vertex_count) + else: + # if someone passed (n, 3) UVR cut it off here + if uv.shape[1] > 2: + uv = uv[:, :2] + # texcoord as (2,) float + color_gl = ("t2f/static", uv.astype(np.float64).reshape(-1).tolist()) + + elif smooth and len(mesh.faces) < smooth_threshold: + # if we have a small number of faces and colors defined + # smooth the mesh by merging vertices of faces below + # the threshold angle + smooth = mesh.smooth_shaded + vertices = smooth.vertices + vertex_count = len(vertices) + normals = smooth.vertex_normals + faces = smooth.faces + vertices = smooth.vertices + color_gl = colors_to_gl(mesh.visual.vertex_colors, vertex_count) + else: + # we don't have textures or want to smooth so + # send a polygon soup of disconnected triangles to opengl + vertex_count = len(mesh.faces) * 3 + normals = np.tile(mesh.face_normals, (1, 3)) + vertices = vertices[mesh.faces] + faces = np.arange(vertex_count, dtype=np.int64) + colors = np.tile(mesh.visual.face_colors, (1, 3)).reshape((-1, 4)) + color_gl = colors_to_gl(colors, vertex_count) + + # create the ordered tuple for pyglet, use like: + # `batch.add_indexed(*args)` + args = ( + vertex_count, # number of vertices + GL_TRIANGLES, # mode + group, # group + faces.reshape(-1).tolist(), # indices + ("v3f/static", vertices.reshape(-1).tolist()), + ("n3f/static", normals.reshape(-1).tolist()), + color_gl, + ) + + return args + + +def path_to_vertexlist(path, group=None, **kwargs): + """ + Convert a Path3D object to arguments for a + pyglet indexed vertex list constructor. + + Parameters + ------------- + path : trimesh.path.Path3D object + Mesh to be rendered + group : str + Rendering group for the vertex list + + Returns + -------------- + args : (7,) tuple + Args for vertex list constructor + """ + # avoid cache check inside tight loop + vertices = path.vertices + + # get (n, 2, (2|3)) lines + stacked = [util.stack_lines(e.discrete(vertices)) for e in path.entities] + lines = util.vstack_empty(stacked) + count = len(lines) + + # stack zeros for 2D lines + if util.is_shape(vertices, (-1, 2)): + lines = lines.reshape((-1, 2)) + lines = np.column_stack((lines, np.zeros(len(lines)))) + # index for GL is one per point + index = np.arange(count).tolist() + # convert from entity color to the color of + # each vertex in the line segments + colors = path.colors + if colors is not None: + colors = np.vstack( + [ + (np.ones((len(s), 4)) * c).astype(np.uint8) + for s, c in zip(stacked, path.colors) + ] + ) + # convert to gl-friendly colors + gl_colors = colors_to_gl(colors, count=count) + + # collect args for vertexlist constructor + args = ( + count, # number of lines + GL_LINES, # mode + group, # group + index, # indices + ("v3f/static", lines.reshape(-1)), + gl_colors, + ) + return args + + +def points_to_vertexlist(points, colors=None, group=None, **kwargs): + """ + Convert a numpy array of 3D points to args for + a vertex list constructor. + + Parameters + ------------- + points : (n, 3) float + Points to be rendered + colors : (n, 3) or (n, 4) float + Colors for each point + group : str + Rendering group for the vertex list + + Returns + -------------- + args : (7,) tuple + Args for vertex list constructor + """ + points = np.asanyarray(points, dtype=np.float64) + + if util.is_shape(points, (-1, 2)): + points = np.column_stack((points, np.zeros(len(points)))) + elif not util.is_shape(points, (-1, 3)): + raise ValueError("Pointcloud must be (n,3)!") + + index = np.arange(len(points)).tolist() + + args = ( + len(points), # number of vertices + GL_POINTS, # mode + group, # group + index, # indices + ("v3f/static", points.reshape(-1)), + colors_to_gl(colors, len(points)), + ) + return args + + +def colors_to_gl(colors, count): + """ + Given a list of colors (or None) return a GL-acceptable + list of colors. + + Parameters + ------------ + colors: (count, (3 or 4)) float + Input colors as an array + + Returns + --------- + colors_type : str + Color type + colors_gl : (count,) list + Colors to pass to pyglet + """ + + colors = np.asanyarray(colors) + count = int(count) + # get the GL kind of color we have + colors_dtypes = {"f": "f", "i": "B", "u": "B"} + + if colors.dtype.kind in colors_dtypes: + dtype = colors_dtypes[colors.dtype.kind] + else: + dtype = None + + if dtype is not None and util.is_shape(colors, (count, (3, 4))): + # save the shape and dtype for opengl color string + colors_type = f"c{colors.shape[1]}{dtype}/static" + # reshape the 2D array into a 1D one and then convert to a python list + gl_colors = colors.reshape(-1).tolist() + elif dtype is not None and colors.shape in [(3,), (4,)]: + # we've been passed a single color so tile them + gl_colors = ( + (np.ones((count, colors.size), dtype=colors.dtype) * colors) + .reshape(-1) + .tolist() + ) + # we know we're tiling + colors_type = f"c{colors.size}{dtype}/static" + else: + # case where colors are wrong shape + # use black as the default color + gl_colors = np.tile([0.0, 0.0, 0.0], (count, 1)).reshape(-1).tolist() + # we're returning RGB float colors + colors_type = "c3f/static" + + return colors_type, gl_colors + + +def material_to_texture(material, upsize=True): + """ + Convert a trimesh.visual.texture.Material object into + a pyglet-compatible texture object. + + Parameters + -------------- + material : trimesh.visual.texture.Material + Material to be converted + upsize: bool + If True, will upscale textures to their nearest power + of two resolution to avoid weirdness + + Returns + --------------- + texture : pyglet.image.Texture + Texture loaded into pyglet form + """ + import pyglet + + # try to extract a PIL image from material + if hasattr(material, "image"): + img = material.image + elif hasattr(material, "baseColorTexture"): + img = material.baseColorTexture + else: + return None + + # if no images in texture return now + if img is None: + return None + + # if we're not powers of two upsize + if upsize: + from .visual.texture import power_resize + + img = power_resize(img) + + # use a PNG export to exchange into pyglet + # probably a way to do this with a PIL converter + with util.BytesIO() as f: + # export PIL image as PNG + img.save(f, format="png") + f.seek(0) + # filename used for format guess + gl_image = pyglet.image.load(filename=".png", file=f) + + # turn image into pyglet texture + texture = gl_image.get_texture() + + return texture + + +def matrix_to_gl(matrix): + """ + Convert a numpy row-major homogeneous transformation matrix + to a flat column-major GLfloat transformation. + + Parameters + ------------- + matrix : (4,4) float + Row-major homogeneous transform + + Returns + ------------- + glmatrix : (16,) gl.GLfloat + Transform in pyglet format + """ + from pyglet import gl + + # convert to GLfloat, switch to column major and flatten to (16,) + return (gl.GLfloat * 16)(*np.array(matrix, dtype=np.float32).T.ravel()) + + +def vector_to_gl(array, *args): + """ + Convert an array and an optional set of args into a + flat vector of gl.GLfloat + """ + from pyglet import gl + + array = np.array(array) + if len(args) > 0: + array = np.append(array, args) + vector = (gl.GLfloat * len(array))(*array) + return vector + + +def light_to_gl(light, transform, lightN): + """ + Convert trimesh.scene.lighting.Light objects into + args for gl.glLightFv calls + + Parameters + -------------- + light : trimesh.scene.lighting.Light + Light object to be converted to GL + transform : (4, 4) float + Transformation matrix of light + lightN : int + Result of gl.GL_LIGHT0, gl.GL_LIGHT1, etc + + Returns + -------------- + multiarg : [tuple] + List of args to pass to gl.glLightFv eg: + [gl.glLightfb(*a) for a in multiarg] + """ + from pyglet import gl + + # convert color to opengl + gl_color = vector_to_gl(light.color.astype(np.float64) / 255.0) + assert len(gl_color) == 4 + + # cartesian translation from matrix + gl_position = vector_to_gl(transform[:3, 3]) + + # create the different position and color arguments + args = [ + (lightN, gl.GL_POSITION, gl_position), + (lightN, gl.GL_SPECULAR, gl_color), + (lightN, gl.GL_DIFFUSE, gl_color), + (lightN, gl.GL_AMBIENT, gl_color), + ] + return args diff --git a/trimesh/viewer/windowed.py b/trimesh/viewer/pyglet1/viewer.py similarity index 96% rename from trimesh/viewer/windowed.py rename to trimesh/viewer/pyglet1/viewer.py index 6e3d1c807..d1703a7f8 100644 --- a/trimesh/viewer/windowed.py +++ b/trimesh/viewer/pyglet1/viewer.py @@ -18,12 +18,13 @@ # 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"`') + raise ImportError('`trimesh.viewer.pyglet1` requires `pip install "pyglet<2"`') -from .. import rendering, util -from ..transformations import translation_matrix -from ..visual import to_rgba -from .trackball import Trackball +from ... import util +from ...transformations import translation_matrix +from ...visual import to_rgba +from ..trackball import Trackball +from . import conversion pyglet.options["shadow_window"] = False @@ -261,11 +262,11 @@ def add_geometry(self, name, geometry, **kwargs): geometry : Trimesh, Path2D, Path3D, PointCloud Geometry to display in the viewer window kwargs ** - Passed to rendering.convert_to_vertexlist + Passed to conversion.convert_to_vertexlist """ try: # convert geometry to constructor args - args = rendering.convert_to_vertexlist(geometry, **kwargs) + args = conversion.convert_to_vertexlist(geometry, **kwargs) except BaseException: util.log.warning(f"failed to add geometry `{name}`", exc_info=True) return @@ -281,7 +282,7 @@ def add_geometry(self, name, geometry, **kwargs): visual = getattr(geometry, "visual", None) if hasattr(visual, "uv") and hasattr(visual, "material"): try: - tex = rendering.material_to_texture(visual.material) + tex = conversion.material_to_texture(visual.material) if tex is not None: self.textures[name] = tex except BaseException: @@ -417,17 +418,17 @@ def _gl_enable_color_material(): gl.glMaterialfv( gl.GL_FRONT, gl.GL_AMBIENT, - rendering.vector_to_gl(0.192250, 0.192250, 0.192250), + conversion.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), + conversion.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), + conversion.vector_to_gl(0.5082730, 0.5082730, 0.5082730), ) gl.glMaterialf(gl.GL_FRONT, gl.GL_SHININESS, 0.4 * 128.0) @@ -464,7 +465,7 @@ def _gl_enable_lighting(scene): matrix = scene.graph.get(light.name)[0] # convert light object to glLightfv calls - multiargs = rendering.light_to_gl( + multiargs = conversion.light_to_gl( light=light, transform=matrix, lightN=lightN ) @@ -546,12 +547,12 @@ def update_flags(self): # case where we WANT an axis and NO vertexlist # is stored internally if self.view["axis"] and self._axis is None: - from .. import creation + 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) + args = conversion.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 @@ -565,7 +566,7 @@ def update_flags(self): if self.view["grid"] and self._grid is None: try: # create a grid marker - from ..path.creation import grid + from ...path.creation import grid bounds = self.scene.bounds center = bounds.mean(axis=0) @@ -577,7 +578,7 @@ def update_flags(self): # 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) + args = conversion.convert_to_vertexlist(grid_mesh) # create ordered args for a vertex list self._grid = self.batch.add_indexed(*args) except BaseException: @@ -712,7 +713,7 @@ def on_draw(self): 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)) + gl.glMultMatrixf(conversion.matrix_to_gl(transform_camera)) # we want to render fully opaque objects first, # followed by objects which have transparency @@ -777,7 +778,7 @@ def on_draw(self): # add a new matrix to the model stack gl.glPushMatrix() # transform by the nodes transform - gl.glMultMatrixf(rendering.matrix_to_gl(transform)) + gl.glMultMatrixf(conversion.matrix_to_gl(transform)) # draw an axis marker for each mesh frame if self.view["axis"] == "all": @@ -885,7 +886,7 @@ def render_scene( resolution : (2,) int or None Resolution in pixels or set from scene.camera visible : bool - Show a window during rendering. Note that MANY + Show a window during conversion. 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. diff --git a/trimesh/viewer/pyglet2/__init__.py b/trimesh/viewer/pyglet2/__init__.py new file mode 100644 index 000000000..0d0cc378a --- /dev/null +++ b/trimesh/viewer/pyglet2/__init__.py @@ -0,0 +1 @@ +from .viewer import SceneViewer, render_scene diff --git a/trimesh/viewer/pyglet2/viewer.py b/trimesh/viewer/pyglet2/viewer.py new file mode 100644 index 000000000..4ae07522e --- /dev/null +++ b/trimesh/viewer/pyglet2/viewer.py @@ -0,0 +1,385 @@ +from dataclasses import dataclass +from typing import Literal + +import numpy as np +import pyglet +from pyglet.gl import GL_CULL_FACE, GL_DEPTH_TEST, glClearColor, glEnable +from pyglet.graphics.shader import ShaderProgram +from pyglet.math import Mat4 +from pyglet.model import MaterialGroup, SimpleMaterial + +from ...scene import Scene +from ..trackball import Trackball + +# the axis marker can be in several states +_AXIS_STATES = [None, "world", "all", "without_world"] +_AXIS_TYPE = Literal[None, "world", "all", "without_world"] + + +def render_scene(*args, **kwargs): + raise NotImplementedError() + + +class SceneViewer(pyglet.window.Window): + """ + A 3D trackball viewer to debug `trimesh.Scene` objects. + """ + + def __init__( + self, + scene: Scene, + background=None, + ) -> None: + """Initialize the camera.""" + + super().__init__(resizable=True) + + # create the batch all geometry will be added to + self._batch = pyglet.graphics.Batch() + + # assign the scene to this object + self.set_scene(scene) + + # hold current viewer position and settings + self._pose = View( + trackball=Trackball( + pose=self._initial_camera_transform, + size=self.scene.camera.resolution, + scale=self.scene.scale, + target=self.scene.centroid, + ), + ) + + self._exclusive_mouse = False + + # default GL settings + glEnable(GL_DEPTH_TEST) + glEnable(GL_CULL_FACE) + + if background is None: + background = np.ones(4) + glClearColor(*background) + + # set a window caption + self.set_caption("trimesh viewer") + + # run the initial frame render + self.on_refresh(0.0) + + pyglet.app.run() + + def set_scene(self, scene: Scene): + """ + Set the current viewer to a trimesh Scene. + """ + if not isinstance(scene, Scene): + scene = Scene(scene) + + models = {} + for name, geometry in scene.geometry.items(): + models[name] = mesh_to_pyglet(geometry, batch=self._batch) + + self.scene = scene + self._scale = scene.scale + self._models = models + self._initial_camera_transform = scene.camera_transform.copy() + + def on_refresh(self, delta_time: float) -> None: + """ + Called before the window content is drawn. + + Runs every frame applying the camera movement. + """ + # todo : is there a better way of altering this view in-place? + self.view = Mat4(*np.linalg.inv(self._pose.trackball.pose).T.ravel()) + + def on_draw(self): + self.clear() + self._batch.draw() + + def on_resize(self, width: int, height: int) -> bool: + """ + Update the viewport and projection matrix on window resize. + """ + + # `width` and `height` are the new dimensions of the window + # where `actual` is the actual size of the framebuffer in pixels + actual = self.get_framebuffer_size() + self.viewport = (0, 0, *actual) + + self.scene.camera.resolution = actual + self._pose.trackball.resize(actual) + + self.projection = Mat4(*self.scene.camera.projection.T.ravel()) + + return pyglet.event.EVENT_HANDLED + + def on_mouse_press(self, x, y, buttons, modifiers): + """ + Set the start point of the drag. + """ + self._pose.trackball.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._pose.trackball.set_state(Trackball.STATE_ZOOM) + elif shift: + self._pose.trackball.set_state(Trackball.STATE_ROLL) + elif ctrl: + self._pose.trackball.set_state(Trackball.STATE_PAN) + elif buttons == pyglet.window.mouse.MIDDLE: + self._pose.trackball.set_state(Trackball.STATE_PAN) + elif buttons == pyglet.window.mouse.RIGHT: + self._pose.trackball.set_state(Trackball.STATE_ZOOM) + + self._pose.trackball.down(np.array([x, y])) + self.scene.camera_transform = self._pose.trackball.pose + + def on_mouse_drag(self, x, y, dx, dy, buttons, modifiers): + """ + Pan or rotate the view. + """ + self._pose.trackball.drag(np.array([x, y])) + + def on_mouse_scroll(self, x, y, dx, dy): + """ + Zoom the view. + """ + self._pose.trackball.scroll(dy) + + def toggle_wireframe(self): + """ + Toggle the wireframe mode. + """ + self._pose.wireframe = not self._pose.wireframe + self._update_flags() + + def reset_view(self): + """ + Reset the view to the initial camera transform. + """ + raise NotImplementedError() + + def toggle_culling(self): + """ + Toggle backface culling. + """ + self._pose.cull = not self._pose.cull + self._update_flags() + + def toggle_axis(self): + """ + Toggle a rendered XYZ/RGB axis marker: + off, world frame, every frame + """ + # the state after toggling + index = (_AXIS_STATES.index(self._pose["axis"]) + 1) % len(_AXIS_STATES) + # update state to next index + self._pose.axis = _AXIS_STATES[index] + # perform gl actions + self._update_flags() + + def toggle_grid(self): + """ + Toggle a rendered grid. + """ + # update state to next index + self._pose.grid = not self._pose.grid + # perform gl actions + self._update_flags() + + def on_key_press(self, symbol, modifiers): + """ + Call appropriate functions given key presses. + """ + + actions = { + pyglet.window.key.W: self.toggle_wireframe, + pyglet.window.key.Z: self.reset_view, + pyglet.window.key.C: self.toggle_culling, + pyglet.window.key.A: self.toggle_axis, + pyglet.window.key.G: self.toggle_grid, + pyglet.window.key.Q: self.on_close, + pyglet.window.key.M: self.maximize, + pyglet.window.key.F: self.toggle_fullscreen, + } + + if symbol in actions: + actions[symbol]() + + if symbol in [ + pyglet.window.key.LEFT, + pyglet.window.key.RIGHT, + pyglet.window.key.DOWN, + pyglet.window.key.UP, + ]: + magnitude = 10 + self._pose.trackball.down([0, 0]) + if symbol == pyglet.window.key.LEFT: + self._pose.trackball.drag([-magnitude, 0]) + elif symbol == pyglet.window.key.RIGHT: + self._pose.trackball.drag([magnitude, 0]) + elif symbol == pyglet.window.key.DOWN: + self._pose.trackball.drag([0, -magnitude]) + elif symbol == pyglet.window.key.UP: + self._pose.trackball.drag([0, magnitude]) + self.scene.camera_transform = self._pose.trackball.pose + + def toggle_fullscreen(self): + """ + Toggle the window between fullscreen and windowed mode. + """ + self._pose.fullscreen = not self._pose.fullscreen + self._update_flags() + + def _update_flags(self): + """ + Check the view flags, and call required GL functions. + """ + # view mode, filled vs wirefrom + # if self._pose.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._pose.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 = conversion.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 = conversion.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 + + """ + + +@dataclass +class View: + # keep the pose of a trackball + trackball: Trackball + + # enable backface culling + cull: bool = True + + # display a grid + grid: bool = False + + # enable fullscreen mode + fullscreen: bool = False + + # display meshes as a wireframe + wireframe: bool = False + + # display an axis marker + axis: _AXIS_TYPE = None + + def __hash__(self) -> int: + return hash( + ( + self.cull, + self.grid, + self.fullscreen, + self.wireframe, + self.trackball.pose.tobytes(), + ) + ) + + +def mesh_to_pyglet( + mesh, + batch: pyglet.graphics.Batch | None = None, + group=None, +) -> pyglet.model.Model: + """ + Convert a Trimesh object into a Pyglet model. + + Parameters + ------------ + mesh + The Trimesh object to convert. + batch + The Pyglet batch to add the model to. + + Returns + ------------ + model + The Pyglet model reference. + """ + if batch is None: + batch = pyglet.graphics.Batch() + + diffuse = [1.0, 1.0, 1.0, 1.0] + ambient = [1.0, 1.0, 1.0, 1.0] + specular = [1.0, 1.0, 1.0, 1.0] + emission = [0.0, 0.0, 0.0, 1.0] + shininess = 100.0 + + default_material = SimpleMaterial( + "Default", diffuse, ambient, specular, emission, shininess + ) + + program: ShaderProgram = pyglet.gl.current_context.create_program( + (pyglet.model.MaterialGroup.default_vert_src, "vertex"), + (pyglet.model.MaterialGroup.default_frag_src, "fragment"), + ) + + matgroup = MaterialGroup(default_material, program, order=0, parent=group) + + idx = program.vertex_list_indexed( + len(mesh.vertices), + pyglet.gl.GL_TRIANGLES, + mesh.faces.ravel(), + batch=batch, + group=matgroup, + POSITION=("f", mesh.vertices.ravel()), + NORMAL=("f", mesh.vertex_normals.ravel()), + COLOR_0=("f", np.ones(len(mesh.vertices) * 4)), + ) + + return pyglet.model.Model([idx], [matgroup], batch) diff --git a/trimesh/viewer/trackball.py b/trimesh/viewer/trackball.py index 2b6a78ba4..9cb2a1447 100644 --- a/trimesh/viewer/trackball.py +++ b/trimesh/viewer/trackball.py @@ -25,9 +25,12 @@ """Trackball class for 3D manipulation of viewpoints.""" +from typing import Literal + import numpy as np from .. import transformations +from ..typed import ArrayLike, Floating, NDArray, Optional class Trackball: @@ -38,17 +41,23 @@ class Trackball: STATE_ROLL = 2 STATE_ZOOM = 3 - def __init__(self, pose, size, scale, target=None): + def __init__( + self, + pose: ArrayLike, + size: ArrayLike, + scale: Floating, + target: Optional[ArrayLike] = 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. + An initial camera-to-world pose for the trackball. size : (float, float) - The width and height of the camera image in pixels. + The width and height of the camera image in pixels. scale : float The diagonal of the scene's bounding box -- @@ -75,12 +84,20 @@ def __init__(self, pose, size, scale, target=None): self._state = Trackball.STATE_ROTATE @property - def pose(self): - """autolab_core.RigidTransform : The current camera-to-world pose.""" + def pose(self) -> NDArray[np.float64]: + """ + The current camera-to-world pose. + + Returns + --------- + pose : (4, 4) + The transformation from camera to world coordinates. + """ return self._n_pose - def set_state(self, state): - """Set the state of the trackball in order to change the effect of + def set_state(self, state: Literal[0, 1, 2, 3]): + """ + Set the state of the trackball in order to change the effect of dragging motions. Parameters diff --git a/trimesh/viewer/widget.py b/trimesh/viewer/widget.py index 3636a3da2..be07d59b6 100644 --- a/trimesh/viewer/widget.py +++ b/trimesh/viewer/widget.py @@ -7,14 +7,25 @@ Check out an example in `examples/widget.py` """ +import warnings + import glooey import numpy as np import pyglet from pyglet import gl -from .. import rendering from .trackball import Trackball -from .windowed import SceneViewer, _geometry_hash +from .windowed.pyglet1 import SceneViewer, _geometry_hash, conversion + +warnings.warn( + """ + `trimesh.viewer.widget` is going to be removed from the installed library + and moved into `examples/widget.py` on 3/1/2026. If you are using it you + should copy ("vendor") `widget.py` into your own project structure before then. + """, + category=DeprecationWarning, + stacklevel=2, +) class SceneGroup(pyglet.graphics.Group): @@ -88,7 +99,7 @@ def set_state(self): gl.glPushMatrix() gl.glLoadIdentity() gl.glMultMatrixf( - rendering.matrix_to_gl(np.linalg.inv(self.scene.camera_transform)) + conversion.matrix_to_gl(np.linalg.inv(self.scene.camera_transform)) ) def unset_state(self): @@ -109,7 +120,7 @@ def __init__(self, transform=None, texture=None, parent=None): def set_state(self): gl.glPushMatrix() - gl.glMultMatrixf(rendering.matrix_to_gl(self.transform)) + gl.glMultMatrixf(conversion.matrix_to_gl(self.transform)) if self.texture: gl.glEnable(self.texture.target) @@ -253,7 +264,7 @@ def _update_node(self, node_name, geometry_name, geometry, transform): 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) + tex = conversion.material_to_texture(geometry.visual.material) if tex is not None: self.textures[geometry_name] = tex @@ -274,7 +285,7 @@ def _update_node(self, node_name, geometry_name, geometry, transform): self.vertex_list[geometry_name].delete() # convert geometry to constructor args - args = rendering.convert_to_vertexlist( + args = conversion.convert_to_vertexlist( geometry, group=mesh_group, smooth=self._smooth ) # create the indexed vertex list