Source code for svg3d.svg3d

# Adapted from https://prideout.net/blog/svg_wireframes/
# Copyright (c) 2019 Philip Rideout. Modified 2024 by Jenna Bradley.
# Distributed under the MIT License, see bottom of file.

"""Three-dimensional vector rendering software in Python.
This primary package contains object primitives (:obj:`~.Mesh`) and the rendering engine
:obj:`~.Engine` itself.


"""

import warnings
from collections.abc import Callable
from typing import TYPE_CHECKING, Self

import numpy as np
import svgwrite

from svg3d.utils import _stable_normalize

if TYPE_CHECKING:
    import coxeter

EXAMPLE_COLOR = "#71618D"
EXAMPLE_STYLE = {
    "fill": EXAMPLE_COLOR,
    "fill_opacity": "0.85",
    "stroke": "black",
    "stroke_linejoin": "round",
    "stroke_width": "0.005",
}  # Sample style dictionary for use in examples.


def _pad_arrays(arrays):
    # Find the length of the longest array
    max_length = max(len(arr) for arr in arrays)

    # Pad each array to the length of the longest array
    padded_array = [
        np.concatenate((arr, np.full((max_length - len(arr), 3), arr[0])), axis=0)
        for arr in arrays
    ]
    return np.array(padded_array)


[docs] class Mesh: # TODO: rename to PolygonMesh, create Object? base class, and add Sphere
[docs] def __init__( self, faces: list[np.ndarray], shader: Callable[[int, Self], dict] | None = None, circle_radius: float = 0.0, ): self._faces = _pad_arrays(faces) self._compute_normals() self._shader = shader self._circle_radius = circle_radius
@property def faces(self): """np.ndarray: Get or set the faces of the :obj:`~.Mesh`""" return self._faces @faces.setter def faces(self, faces: list[np.ndarray]): self._faces = faces self._compute_normals() @property def shader(self): """:py:obj:`~typing.Callable`: Get or set the :obj:`~.Shader` for the \ :obj:`~.Mesh`""" return self._shader @shader.setter def shader(self, shader): self._shader = shader @property def circle_radius(self): return self._circle_radius @circle_radius.setter def circle_radius(self, circle_radius): self._circle_radius = circle_radius @property def normals(self): """np.ndarray: Get the normals for the faces of the :obj:`~.Mesh`.""" return self._normals def _compute_normals(self): face_simplices = self.faces[:, :3] # Convert each simplex (3 points) into two edge vectors (each 2 points) # These will be an array of [N, (0-1,1-2)=2, 3] vertices face_edge_vectors = np.diff(face_simplices, axis=1) # The LSP is unhappy, but this is correct. Each face has exactly 2 edge vectors normals = np.cross(*np.split(face_edge_vectors, 2, axis=1)).squeeze() self._normals = _stable_normalize(normals) # Return normalized
[docs] @classmethod def from_coxeter( cls, poly: "coxeter.shapes.ConvexPolyhedron", shader: Callable[[int, Self], dict] | None = None, ): """Create a :obj:`~.Mesh` object from a coxeter :class:`~coxeter.shapes.ConvexPolyhedron`.""" return cls( faces=[poly.vertices[face] for face in poly.faces], shader=shader, )
[docs] @classmethod def from_vertices_and_faces( cls, vertices: np.ndarray[float], faces: list[np.ndarray[int]], shader: Callable[[int, Self], dict] | None = None, ): return cls( faces=[vertices[face] for face in faces], shader=shader, )
@classmethod def example_mesh(cls): """Generate a mesh from a cube with integer vertices. This is an internal method used for tests and examples, and should probably not be instantiated by users. :meta private: """ # TODO: define default style dict, vertices, and faces from .shaders import DiffuseShader # Generate the vertices and faces of a cube partial_vertices = np.tile([-0.5, 0.5], (3, 1)) vertices = np.array(np.meshgrid(*partial_vertices)).T.reshape(-1, 3) faces = [ [0, 2, 6, 4], [0, 4, 5, 1], [4, 6, 7, 5], [0, 1, 3, 2], [2, 3, 7, 6], [1, 5, 7, 3], ] return cls( faces=[vertices[face] for face in faces], shader=DiffuseShader(base_style=EXAMPLE_STYLE), )
[docs] class Engine:
[docs] def __init__(self, views, precision: int = 14): """The engine used to render a scene into an image. Example ------- > import svg3d > scene = [svg3d.Mesh.example_mesh()] > view = svg3d.View.isometric(scene) > svg3d.Engine([view]).render("example.svg") Wrote file "example.svg" Parameters ---------- views: list[View] List of :obj:`~.View` objects to render. Each is rendered into the same image, allowing for composite graphics from multiple viewpoints. For simplicity, a single :obj:`~.View` object is often best. precision: int Number of decimal places of precision for numeric quantities in the mesh. Smaller values will reduce file sizes but may result in minor inconsistencies in very small geometries. Default value: 14 """ self._views = views self._precision = precision
@property def views(self): """list[:obj:`~.View`]: Get or set the list of views to render.""" if len(self._views) < 1: warnings.warn( "No views available! Rendered image will be blank.", RuntimeWarning, stacklevel=2, ) return self._views @views.setter def views(self, views): self._views = views @property def precision(self): """int: Get or set the rounding precision for vertices of rendered polygons.""" return self._precision @precision.setter def precision(self, precision): self._precision = precision
[docs] def render(self, filename, size=(512, 512), viewbox="-0.5 -0.5 1.0 1.0", **extra): """ Render the current view or views to a file. Parameters ---------- filename : str The name of the file to save the render to. Should be postfixed with `.svg` size : tuple of int, optional Size of the render in pixels. Default is (512, 512). viewbox : str, optional :class:`~svgwrite.mixins.viewBox` attribute for the SVG. Default is \ "-0.5 -0.5 1.0 1.0". **extra Additional keyword arguments to be passed into :py:mod:`svgwrite`. Raises ------ RuntimeWarning If all faces of a mesh are pruned due to an incorrect projection matrix. RuntimeWarning If :meth:`~.render` is called without any Views to render. """ drawing = svgwrite.Drawing(filename, size, viewBox=viewbox, **extra) self._draw(drawing) drawing.save() print(f"Wrote file {filename}")
def _draw(self, drawing): for view in self.views: projection = np.dot(view.look_at, view.projection) # Initialize clip path. See https://www.w3.org/TR/SVG11/masking.html#ClippingPaths clip_path = drawing.defs.add(drawing.clipPath()) clip_min = view.viewport.minx, view.viewport.miny clip_size = view.viewport.width, view.viewport.height clip_path.add(drawing.rect(clip_min, clip_size)) for mesh in view.scene: group = self._create_group(drawing, projection, view.viewport, mesh) group["clip-path"] = clip_path.get_funciri() drawing.add(group) def _create_group(self, drawing, projection, viewport, mesh): faces = mesh.faces shader = mesh.shader or (lambda face_index, mesh: {}) default_style = {} # Extend each point to a vec4, then transform to clip space. faces = np.dstack([faces, np.ones(faces.shape[:2])]) faces = np.dot(faces, projection) # Reject trivially clipped polygons. xyz, w = faces[:, :, :3], faces[:, :, 3:] accepted = (xyz > -w) & (xyz < +w) accepted = accepted.all(axis=2) # vert is accepted if xyz are all inside accepted = accepted.any(axis=1) # face is accepted if any vert is inside degenerate = (w <= 0)[:, :, 0] # vert is bad if its w <= 0 degenerate = degenerate.any(axis=1) # face is bad if any of its verts are bad accepted = np.logical_and(accepted, np.logical_not(degenerate)) faces = np.compress(accepted, faces, axis=0) if len(faces) == 0: warnings.warn( "All faces were pruned! Check your projection matrix.", RuntimeWarning, stacklevel=2, ) # Apply perspective transformation. xyz, w = faces[:, :, :3], faces[:, :, 3:] faces = xyz / w # Sort faces from back to front. face_indices = self._sort_back_to_front(faces) faces = faces[face_indices] # Apply viewport transform to X and Y. faces[:, :, 0:1] = (1.0 + faces[:, :, 0:1]) * viewport.width / 2 faces[:, :, 1:2] = (1.0 - faces[:, :, 1:2]) * viewport.height / 2 faces[:, :, 0:1] += viewport.minx faces[:, :, 1:2] += viewport.miny group = drawing.g(**default_style) # Create circles. if mesh.circle_radius > 0: for face_index, face in enumerate(faces): style = shader(face_indices[face_index], mesh) if style is None: continue face = face[:, :2].round(self.precision) for pt in face: group.add(drawing.circle(pt, mesh.circle_radius, **style)) return group # Create polygons and lines. for face_index, face in enumerate(faces): style = shader(face_indices[face_index], mesh) if style is None: continue face = np.around(face[:, :2], self.precision) _, indices = np.unique(face, return_index=True, axis=0) face = face[sorted(indices)] if len(face) == 2: group.add(drawing.line(face[0], face[1], **style)) else: group.add(drawing.polygon(face, **style)) return group def _sort_back_to_front(self, faces): z_centroids = -np.sum(faces[:, :, 2], axis=1) return np.argsort(z_centroids)
# 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.