├── .gitignore ├── LICENSE ├── README.md ├── examples ├── .gitignore ├── example01 │ └── sketch_example01.py ├── example02 │ └── sketch_example02.py └── example03 │ └── sketch_example03.py ├── pyproject.toml └── pysometric ├── __init__.py ├── axis.py ├── fill.py ├── matrix.py ├── plane.py ├── render.py ├── scene.py ├── shape.py ├── tests ├── __init__.py ├── matrix_test.py ├── render_test.py ├── scene_test.py └── shape_test.py ├── texture.py ├── vector.py └── volume.py /.gitignore: -------------------------------------------------------------------------------- 1 | venv/ 2 | __pycache__/ 3 | build/ 4 | *.egg-info/ 5 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2023 Sean Voisen 4 | 5 | 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: 6 | 7 | The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. 8 | 9 | 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. -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Pysometric 2 | 3 | Pysometric is a simple Python library for creating isometric 3D line art that can be rendered to SVG, typically for use with pen plotters. 4 | 5 | It is currently intended to be used in conjunction with the [vsketch](https://github.com/abey79/vsketch/) generative toolkit (though this dependency may be removed in the future). 6 | 7 | cube, pyramid and hexagonal prism 8 | 9 | ## Features 10 | 11 | * Renders 2D rectangles and polygons in 3D isometric projection 12 | * Allows creation of complex 3D shapes from groups of polygons (e.g. boxes, pyramids, prisms) 13 | * Supports textures (e.g. hatch and solid fills) on any polygon 14 | * Automatically occludes lines (hidden line removal) based on scene z-order 15 | 16 | # Release Notes 17 | 18 | ## Version 0.0.2 (Current) 19 | * Adds support for circles using the `Circle` class 20 | 21 | ## Version 0.0.1 22 | * Initial release 23 | 24 | # Getting Started 25 | 26 | ## Required dependencies 27 | 28 | `Pysometric` currently requires [vsketch](https://github.com/abey79/vsketch) for rendering. You will need to install `vsketch` before using this library. 29 | 30 | ## Installation 31 | 32 | To get started with Pysometric, clone the repository and install it using `pip`: 33 | 34 | ```bash 35 | $ git clone https://github.com/svoisen/pysometric.git 36 | $ cd pysometric 37 | $ pip install . 38 | ``` 39 | 40 | ## Examples 41 | 42 | Once the library is installed you can create a simple isometric scene using `pysometric` inside a `vsketch` sketch. For example, in the `draw` method of your sketch: 43 | 44 | ```python 45 | # Create a frame for the scene that is the same size as the vsketch page 46 | frame = shapely.box(0, 0, vsk.width, vsk.height) 47 | 48 | # Create a Scene with a single 3D box at the origin and 49 | # a grid pitch size of 100 CSS pixels 50 | box = Box((0, 0, 0)) 51 | scene = Scene(frame, 100, [box]) 52 | 53 | # Render the scene to ths ketch 54 | scene.render(vsk) 55 | ``` 56 | 57 | More examples may be found in the `examples` directory. 58 | 59 | # Running tests 60 | 61 | This project uses pytest. Run tests by executing the following in the root directory of the repository: 62 | 63 | ```bash 64 | $ pytest 65 | ``` 66 | 67 | # Documentation 68 | 69 | ## Using Pysometric 70 | 71 | `Pysometric` is intended for the creation of static isometric 3D scenes as line art. It is not a true 3D system, nor does it support animation. As such, it is intentionally limited by the following constraints: 72 | 73 | 1. `Pysometric`'s API is immutable. Setup your scene once, then render it. That's it. Because it does not support animation, scenes, shapes and volumes in `pysometric` cannot be modified after their initial creation. 74 | 2. `Pysometric` has no concept of light sources. Shadows can be emulated using hatching or fill textures on polygons, but they cannot be generated automatically for a shape or entire scene based on lighting. 75 | 76 | ## API 77 | 78 | ### Scene 79 | 80 | A `Scene` defines an isometric 3D scene that can be rendered to 2D line art. All shapes to be rendered by `pysometric` must be part of a scene. When creating a `Scene` you must specify: 81 | 82 | * `grid_pitch`: The size of single grid coordinate unit for the 3D scene, which controls the overall scale of the scene. 83 | * `frame`: A scene has a `frame` that defines its rendering boundaries, and can be any `shapely` polygon. 84 | * `children`: A list of all of the child `Renderable`s in the scene. The order of this list defines the order in which the objects are rendered. Items in the list are rendered from first to last, meaning that items with lower indices will occlude items with higher indices, irrespective of their position in 3D space. 85 | 86 | The `Scene` has a `render` method that accepts a `Vsketch` instance as a parameter and outputs the scene to the given sketch. 87 | 88 | ### Axes, Planes and Coordinates 89 | 90 | All 3D coordinates should be specified as `Vector3` objects defining (x, y, z) positions. Axes in `pysometric` are as follows: 91 | 92 | * x-axis: The axis oriented visually left to right (at an upward diagonal) 93 | * y-axis: The axis oriented visually from back to front (at a downward diagonal) 94 | * z-axis: The vertical axis running from bottom to top 95 | 96 | ### Renderable 97 | 98 | The base class for any object that can be added to a Scene. All `Renderables` have a `compile` method which is used during rendering to compile the object into a `RenderableGeometry.` 99 | 100 | ### RenderableGeometry 101 | 102 | A `RenderableGeometry` is the output of the `compile` method for a `Renderable,` and includes information necessary for rendering the object in 2D, such as its 2D geometry, stroke thickness and stroke color (layer). 103 | 104 | ### Polygon 105 | 106 | A `Polygon` defines a general polygon shape. It is a subclass of `Renderable`. 107 | 108 | #### Initialization 109 | 110 | A `Polygon` is initialized with: 111 | 112 | * `vertices`: A list of `Vector3` objects defining the vertices of the polygon. The vertices should be specified in clockwise or counter-clockwise order. 113 | * `textures`: Optional list of `Texture` objects to apply to the polygon. 114 | * `rotations`: Optional list of `Rotation` objects to rotate the polygon. 115 | * `layer`: The layer for the polygon. Default is 1. 116 | 117 | #### Example 118 | 119 | The following example creates a square polygon with vertices at (0, 0, 0), (10, 0, 0), (10, 10, 0) and (0, 10, 0): 120 | 121 | ```python 122 | polygon = Polygon([ 123 | (0, 0, 0), 124 | (10, 0, 0), 125 | (10, 10, 0), 126 | (0, 10, 0) 127 | ]) 128 | ``` 129 | 130 | ### RegularPolygon 131 | 132 | The `RegularPolygon` defines a symmetrical polygon with a specific number of sides. It is a subclass of `Polygon` and `Renderable`. 133 | 134 | #### Initialization 135 | 136 | A `RegularPolygon` is initialized with: 137 | 138 | * `origin`: The (x, y, z) coordinates of the center of the polygon defined as a `Vector3`. 139 | * `num_vertices`: The number of vertices/sides for the polygon. Must be 3 or greater. 140 | * `radius`: The radius/distance from the center to each vertex. 141 | * `orientation`: The plane in which the polygon lies. Must be one of Plane.XY, Plane.XZ or Plane.YZ. 142 | * `textures`: Optional list of `Texture` objects to apply to the polygon. 143 | * `rotations`: Optional list of `Rotation` objects to rotate the polygon. 144 | * `layer`: The layer for the polygon. Default is 1. 145 | 146 | #### Example: 147 | 148 | The following example creates a hexagon with radius 10 at the origin on the plane defined by the X and Y axes. 149 | 150 | ```python 151 | polygon = RegularPolygon((0, 0, 0), 6, 10, Plane.XY) 152 | ``` 153 | 154 | ### Rectangle 155 | 156 | A `Rectangle` defines a rectangular polygon in 3D space. 157 | 158 | By default, rectangles have an orientation parallel to one of the 3 possible planes defined in the `Plane` enumeration. 159 | 160 | #### Initialization 161 | 162 | A `Rectangle` is initialized with: 163 | 164 | * `origin`: The (x, y, z) coordinates of the center of the rectangle defined as a `Vector3`. 165 | * `width`: The width of the rectangle. 166 | * `height`: The height of the rectangle. 167 | * `orientation`: The plane in which the rectangle lies. Must be one of `Plane.XY`, `Plane.XZ` or `Plane.YZ`. 168 | * `textures`: Optional list of `Texture` objects to apply to the rectangle. 169 | * `rotations`: Optional list of `Rotation` objects to rotate the rectangle. 170 | * `layer`: The layer for the rectangle. Default is 1. 171 | 172 | #### Example 173 | 174 | The following example creates a rectangle with width 10 and height 5 centered at the origin on the XZ plane: 175 | 176 | ```python 177 | rectangle = Rectangle((0, 0, 0), 10, 5, Plane.XZ) 178 | ``` 179 | 180 | ### Group 181 | 182 | A `Group` defines a collection of `Renderable` objects that should be treated as a single object. Groups allow you to organize complex shapes in your scene by combining multiple polygons and volumes. 183 | 184 | #### Initialization 185 | 186 | A `Group` is initialized with: 187 | 188 | * `children`: A list of `Renderable` objects (e.g. `Polygon`, `Rectangle`, `Box`, `Prism`) that make up the group. 189 | 190 | #### Example 191 | 192 | The following example creates a `Group` that renders as a cube, defined by 6 `Rectangle` objects (one for each side of the cube): 193 | 194 | ```python 195 | cube = Group([ 196 | Rectangle((0, 0, 0), 10, 10, Plane.XY), # Top face 197 | Rectangle((0, 0, -10), 10, 10, Plane.XY), # Bottom face 198 | Rectangle((0, -10, 0), 10, 10, Plane.YZ), # Front face 199 | Rectangle((0, 10, 0), 10, 10, Plane.YZ), # Back face 200 | Rectangle((-10, 0, 0), 10, 10, Plane.XZ), # Left face 201 | Rectangle((10, 0, 0), 10, 10, Plane.XZ) # Right face 202 | ]) 203 | ``` 204 | 205 | ### Box 206 | 207 | A `Box` defines a rectangular cuboid in 3D space. 208 | 209 | Boxes have a width, depth and height dimension. 210 | 211 | #### Initialization 212 | 213 | A `Box` is initialized with: 214 | 215 | * `origin`: The (x, y, z) coordinates of the bottom left front corner of the box defined as a Vector3. 216 | * `width`: The width of the box. 217 | * `depth`: The depth of the box. 218 | * `height`: The height of the box. 219 | * `top`: Optional dictionary containing: 220 | * `textures`: A list of Texture objects to apply to the top face. 221 | * `layer`: The render layer for the top face. 222 | * `left`: Optional dictionary containing: 223 | * textures: A list of Texture objects to apply to the left face. 224 | * `layer`: The render layer for the left face. 225 | * `right`: Optional dictionary containing: 226 | * textures: A list of Texture objects to apply to the right face. 227 | * layer: The render layer for the right face. 228 | 229 | #### Example 230 | 231 | The following example creates a box with width 2, depth 3 and height 4 centered at (1, 1, 1) with: 232 | 233 | * A hatch texture on the top face 234 | * The left face rendered on layer 2 235 | * A fill texture on the right face 236 | 237 | ```python 238 | box = Box((1, 1, 1), 2, 3, 4, 239 | top={"textures": [HatchTexture(4)]}, 240 | left={"layer": 2}, 241 | right={"textures": [FillTexture()]} 242 | ) 243 | ``` 244 | 245 | ### Circle 246 | 247 | A `Circle` defines an isometric circle in 3D space. 248 | 249 | ### Initialization 250 | 251 | A `Circle` is initialized with: 252 | 253 | * `center`: A Vector3 specifying the 3D center point of the circle. 254 | * `radius`: The radius of the circle. 255 | * `orientation`: The plane in which the circle lies. Must be one of `Plane.XY`, `Plane.XZ` or `Plane.YZ`. 256 | * `num_segments`: Circles are approximated using a series of straight line segments. The higher the number of segments, the smoother the circle at the cost of higher rendering cost. 257 | * `textures`: Optional list of `Texture` objects to apply to the circle. 258 | * `rotations`: Optional list of `Rotation` objects to rotate the circle. 259 | * `layer`: The layer for the rectangle. Default is 1. 260 | 261 | #### Example 262 | 263 | ```python 264 | circle = Circle((5, 10, 15), 5, Plane.XY) 265 | ``` 266 | 267 | # Contributing 268 | 269 | Pull requests are welcome. 270 | 271 | # License 272 | 273 | This project is licensed under the MIT License — see the [LICENSE](LICENSE) file for details. -------------------------------------------------------------------------------- /examples/.gitignore: -------------------------------------------------------------------------------- 1 | config/ 2 | output/ -------------------------------------------------------------------------------- /examples/example01/sketch_example01.py: -------------------------------------------------------------------------------- 1 | import numpy as np 2 | import shapely 3 | import vsketch 4 | 5 | import pysometric as pyso 6 | 7 | 8 | class Example01Sketch(vsketch.SketchClass): 9 | """ 10 | Creates a grid of boxes with fill and hatch texturing. 11 | """ 12 | 13 | unit_size = vsketch.Param(1.0, unit="cm") 14 | 15 | def create_boxes(self): 16 | boxes = [] 17 | top = {"textures": [pyso.HatchTexture(4)]} 18 | right = {"textures": [pyso.FillTexture()]} 19 | for i in np.arange(-5, 5, 2): 20 | for j in np.arange(-5, 5, 2): 21 | boxes.append(pyso.Box((i, j, 0), 1, 1, 1, top, {}, right)) 22 | 23 | return boxes 24 | 25 | def draw(self, vsk: vsketch.Vsketch) -> None: 26 | vsk.size("a4", landscape=False, center=False) 27 | 28 | frame = shapely.box(0, 0, vsk.width, vsk.height) 29 | scene = pyso.Scene(frame, self.unit_size, self.create_boxes()) 30 | scene.render(vsk) 31 | 32 | def finalize(self, vsk: vsketch.Vsketch) -> None: 33 | vsk.vpype("linemerge linesimplify reloop linesort") 34 | 35 | 36 | if __name__ == "__main__": 37 | Example01Sketch.display() 38 | -------------------------------------------------------------------------------- /examples/example02/sketch_example02.py: -------------------------------------------------------------------------------- 1 | import shapely 2 | import vsketch 3 | 4 | import pysometric as psm 5 | 6 | 7 | class Example02Sketch(vsketch.SketchClass): 8 | unit_size = vsketch.Param(1.0, unit="cm") 9 | 10 | def draw(self, vsk: vsketch.Vsketch) -> None: 11 | vsk.size("a4", landscape=False, center=False) 12 | frame = shapely.box(0, 0, vsk.width, vsk.height) 13 | cube = psm.Box( 14 | (0, 0, 0.5), 15 | left={"textures": [psm.HatchTexture(3.5)]}, 16 | right={"textures": [psm.FillTexture()]}, 17 | ) 18 | pyramid = psm.Pyramid( 19 | (-1.5, 0, 0), 20 | sides=[ 21 | {"textures": [psm.HatchTexture(3.5)]}, 22 | {}, 23 | {}, 24 | {"textures": [psm.FillTexture()]}, 25 | ], 26 | ) 27 | prism = psm.Prism( 28 | (1.5, 0, 0.5), 29 | 6, 30 | 0.5, 31 | sides=[ 32 | {}, 33 | {}, 34 | {}, 35 | {"textures": [psm.HatchTexture(3.5)]}, 36 | {"textures": [psm.FillTexture()]}, 37 | {}, 38 | ], 39 | ) 40 | scene = psm.Scene(frame, self.unit_size, [pyramid, cube, prism]) 41 | scene.render(vsk) 42 | 43 | def finalize(self, vsk: vsketch.Vsketch) -> None: 44 | vsk.vpype("linemerge linesimplify reloop linesort") 45 | 46 | 47 | if __name__ == "__main__": 48 | Example02Sketch.display() 49 | -------------------------------------------------------------------------------- /examples/example03/sketch_example03.py: -------------------------------------------------------------------------------- 1 | import math 2 | 3 | import numpy as np 4 | import shapely 5 | import vsketch 6 | 7 | import pysometric as psm 8 | 9 | 10 | class Example03Sketch(vsketch.SketchClass): 11 | unit_size = vsketch.Param(1.0, unit="cm") 12 | sphere_radius = vsketch.Param(5.0) 13 | 14 | def draw(self, vsk: vsketch.Vsketch) -> None: 15 | vsk.size("a4", landscape=False, center=False) 16 | frame = shapely.box(0, 0, vsk.width, vsk.height) 17 | 18 | circles = [] 19 | for z in np.linspace(self.sphere_radius, -self.sphere_radius, 20): 20 | circle_radius = math.sqrt(self.sphere_radius * self.sphere_radius - z * z) 21 | circles.append(psm.Circle((0, 0, z), circle_radius, psm.Plane.XY, 96)) 22 | 23 | scene = psm.Scene(frame, self.unit_size, circles) 24 | scene.render(vsk) 25 | 26 | def finalize(self, vsk: vsketch.Vsketch) -> None: 27 | vsk.vpype("linemerge linesimplify reloop linesort") 28 | 29 | 30 | if __name__ == "__main__": 31 | Example03Sketch.display() 32 | -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | [build-system] 2 | requires = ["setuptools", "wheel"] 3 | build-backend = "setuptools.build_meta" 4 | 5 | [project] 6 | name = "pysometric" 7 | authors = [ 8 | { name = "Sean Voisen", email = "sean@voisen.org" }, 9 | ] 10 | version = "0.0.2" 11 | description = "Isometric SVG drawing library." 12 | requires-python = ">=3.10" 13 | dependencies = [ 14 | "shapely", 15 | "numpy" 16 | ] -------------------------------------------------------------------------------- /pysometric/__init__.py: -------------------------------------------------------------------------------- 1 | from .axis import Axis 2 | from .plane import Plane 3 | from .scene import DIMETRIC_ANGLE, Scene 4 | from .shape import Circle, Group, Polygon, Rectangle, RegularPolygon, Renderable, Rotation 5 | from .texture import FillTexture, HatchTexture 6 | from .vector import Vector2, Vector3 7 | from .volume import Box, Prism, Pyramid 8 | 9 | __all__ = [ 10 | "Axis", 11 | "Box", 12 | "Circle", 13 | "DIMETRIC_ANGLE", 14 | "Group", 15 | "FillTexture", 16 | "HatchTexture", 17 | "Plane", 18 | "Polygon", 19 | "Prism", 20 | "Pyramid", 21 | "Rectangle", 22 | "RegularPolygon", 23 | "Renderable", 24 | "Rotation", 25 | "Scene", 26 | "Vector2", 27 | "Vector3", 28 | "DIMETRIC_ANGLE", 29 | ] 30 | -------------------------------------------------------------------------------- /pysometric/axis.py: -------------------------------------------------------------------------------- 1 | from enum import Enum 2 | 3 | 4 | class Axis(Enum): 5 | X = (0,) 6 | Y = (1,) 7 | Z = 2 8 | -------------------------------------------------------------------------------- /pysometric/fill.py: -------------------------------------------------------------------------------- 1 | from math import radians, tan, pi 2 | 3 | import numpy as np 4 | from shapely import Geometry, LineString, MultiLineString, bounds 5 | 6 | DEFAULT_HATCH_ANGLE = radians(45) 7 | 8 | def hatch_fill( 9 | geometry: Geometry, pitch: float, angle=DEFAULT_HATCH_ANGLE 10 | ) -> MultiLineString: 11 | hatches = [] 12 | min_x, min_y, max_x, max_y = bounds(geometry) 13 | height = max_y - min_y 14 | 15 | if angle % pi == 0: 16 | for i in np.arange(min_y, max_y, pitch): 17 | hatches.append(LineString([(min_x, i), (max_x, i)])) 18 | else: 19 | hatch_width = 0 if angle == radians(90) else abs(height / tan(angle)) 20 | for i in np.arange(min_x - hatch_width, max_x + hatch_width, pitch): 21 | hatches.append(LineString([(i, max_y), (i + height / tan(angle), min_y)])) 22 | 23 | return MultiLineString(hatches).intersection(geometry) 24 | -------------------------------------------------------------------------------- /pysometric/matrix.py: -------------------------------------------------------------------------------- 1 | from math import cos, sin 2 | 3 | import numpy as np 4 | 5 | from .vector import Vector3 6 | 7 | 8 | def x_axis_rot_mat(angle: float) -> np.array: 9 | cosr = cos(angle) 10 | sinr = sin(angle) 11 | return [[1, 0, 0], [0, cosr, -sinr], [0, sinr, cosr]] 12 | 13 | 14 | def y_axis_rot_mat(angle: float) -> np.array: 15 | cosr = cos(angle) 16 | sinr = sin(angle) 17 | return [[cosr, 0, sinr], [0, 1, 0], [-sinr, 0, cosr]] 18 | 19 | 20 | def z_axis_rot_mat(angle: float) -> np.array: 21 | cosr = cos(angle) 22 | sinr = sin(angle) 23 | return [[cosr, -sinr, 0], [sinr, cosr, 0], [0, 0, 1]] 24 | 25 | 26 | def rotate_x(point: Vector3, angle: float, center: Vector3 = (0, 0, 0)) -> Vector3: 27 | px, py, pz = point 28 | cx, cy, cz = center 29 | 30 | translated = (px - cx, py - cy, pz - cz) 31 | matrix = x_axis_rot_mat(angle) 32 | rx, ry, rz = tuple(np.dot(matrix, translated)) 33 | 34 | return (rx + cx, ry + cy, rz + cz) 35 | 36 | 37 | def rotate_y(point: Vector3, angle: float, center: Vector3 = (0, 0, 0)) -> Vector3: 38 | px, py, pz = point 39 | cx, cy, cz = center 40 | 41 | translated = (px - cx, py - cy, pz - cz) 42 | matrix = y_axis_rot_mat(angle) 43 | rx, ry, rz = tuple(np.dot(matrix, translated)) 44 | 45 | return (rx + cx, ry + cy, rz + cz) 46 | 47 | 48 | def rotate_z(point: Vector3, angle: float, center: Vector3 = (0, 0, 0)) -> Vector3: 49 | px, py, pz = point 50 | cx, cy, cz = center 51 | 52 | translated = (px - cx, py - cy, pz - cz) 53 | matrix = z_axis_rot_mat(angle) 54 | rx, ry, rz = tuple(np.dot(matrix, translated)) 55 | 56 | return (rx + cx, ry + cy, rz + cz) 57 | -------------------------------------------------------------------------------- /pysometric/plane.py: -------------------------------------------------------------------------------- 1 | from enum import Enum 2 | 3 | 4 | class Plane(Enum): 5 | """ 6 | Enumeration defining the viewing planes within which a shape can be oriented. 7 | """ 8 | 9 | YZ = (1,) 10 | XZ = (2,) 11 | XY = 3 12 | -------------------------------------------------------------------------------- /pysometric/render.py: -------------------------------------------------------------------------------- 1 | import math 2 | 3 | import shapely 4 | 5 | from .vector import Vector2, Vector3 6 | 7 | 8 | class RenderableGeometry: 9 | """ 10 | Returned by the compile method of any shape to provide information for final rendering. 11 | A compiled shape includes both the 2D geometry of the shape (projected from 3D) and 12 | layer information for the shape. 13 | """ 14 | 15 | def __init__( 16 | self, geometry: shapely.Geometry | shapely.GeometryCollection, layer=1 17 | ) -> None: 18 | self.geometry = geometry 19 | self.layer = layer 20 | 21 | 22 | class RenderContext: 23 | """Provides information needed by shapes for compiling and rendering. 24 | 25 | This information is used by the shape's render method to render the shape as a vector drawing. 26 | 27 | Attributes 28 | ---------- 29 | frame : shapely.Polygon 30 | grid_pitch : float 31 | dimetric_angle : float 32 | origin : Vector2 | str 33 | """ 34 | 35 | def __init__( 36 | self, 37 | frame: shapely.Polygon, 38 | grid_pitch: float, 39 | dimetric_angle: float, 40 | origin="centroid", 41 | ) -> None: 42 | self.frame = frame 43 | self.grid_pitch = grid_pitch 44 | self.dimetric_angle = dimetric_angle 45 | self._origin = origin 46 | 47 | @property 48 | def origin(self) -> Vector2: 49 | """The origin of the grid within the rendering frame.""" 50 | if self._origin == "centroid": 51 | p = shapely.centroid(self.frame) 52 | return (p.x, p.y) 53 | 54 | return self._origin 55 | 56 | 57 | def project_point(point: Vector3, render_context: RenderContext) -> Vector2: 58 | """ 59 | Given a point in 3D space, project it to 2D screen coordinates. 60 | """ 61 | origin_x, origin_y = render_context.origin 62 | grid_pitch = render_context.grid_pitch 63 | dimetric_angle = render_context.dimetric_angle 64 | grid_x, grid_y, grid_z = point 65 | angle_cos = math.cos(dimetric_angle) 66 | angle_sin = math.sin(dimetric_angle) 67 | 68 | screen_x = ( 69 | origin_x - grid_y * grid_pitch * angle_cos + grid_x * grid_pitch * angle_cos 70 | ) 71 | screen_y = ( 72 | origin_y 73 | - grid_y * grid_pitch * angle_sin 74 | - grid_x * grid_pitch * angle_sin 75 | - grid_z * grid_pitch 76 | ) 77 | 78 | return (screen_x, screen_y) 79 | -------------------------------------------------------------------------------- /pysometric/scene.py: -------------------------------------------------------------------------------- 1 | from math import radians 2 | 3 | import vsketch 4 | from shapely import GeometryCollection, Polygon, STRtree, intersection 5 | 6 | from .render import RenderContext 7 | from .shape import RenderableGeometry, Renderable 8 | 9 | DIMETRIC_ANGLE = radians(30) 10 | 11 | 12 | class Scene: 13 | """Defines a 3D isometric scene. 14 | 15 | Scenes may contain zero or more Shape instances describing 3D geometries that can be rendered to 2D. 16 | """ 17 | 18 | def __init__( 19 | self, frame: Polygon, grid_pitch: float, children: list[Renderable], origin="centroid", clip_to_frame=True 20 | ): 21 | super().__init__() 22 | self.render_context = RenderContext(frame, grid_pitch, DIMETRIC_ANGLE, origin) 23 | self._children: list[Renderable] = children 24 | self.__clips_children_to_frame = clip_to_frame 25 | 26 | def compile(self) -> list[RenderableGeometry]: 27 | def flatten_compiled(renderables): 28 | """ 29 | A compiled shape could contain normal Shapely geometries, or a GeometryCollection. 30 | In the case of a GeometryCollection, flatten it to individual geometries. 31 | """ 32 | flattened = [] 33 | for renderable in renderables: 34 | if isinstance(renderable.geometry, GeometryCollection): 35 | flattened.extend( 36 | list( 37 | map( 38 | lambda g: RenderableGeometry(g, renderable.layer), 39 | renderable.geometry.geoms, 40 | ) 41 | ) 42 | ) 43 | else: 44 | flattened.append(renderable) 45 | 46 | return flattened 47 | 48 | compiled = [] 49 | for child in reversed(self._children): 50 | clipped_and_compiled = list( 51 | map( 52 | self.__clip_to_frame, 53 | flatten_compiled(child.compile(self.render_context)), 54 | ) 55 | ) 56 | compiled.extend(clipped_and_compiled) 57 | 58 | return self.__occlude(compiled) 59 | 60 | def render(self, vsk: vsketch.Vsketch): 61 | """Compile and render the scene to the given sketch.""" 62 | renderables = self.compile() 63 | for renderable in renderables: 64 | if renderable.layer == 0: 65 | vsk.noStroke() 66 | else: 67 | vsk.stroke(renderable.layer) 68 | 69 | if isinstance(renderable.geometry, list): 70 | for g in renderable.geometry: 71 | vsk.geometry(g) 72 | else: 73 | vsk.geometry(renderable.geometry) 74 | 75 | @property 76 | def children(self): 77 | return self._children 78 | 79 | def __clip_to_frame(self, renderable: RenderableGeometry) -> RenderableGeometry: 80 | """ 81 | Given a shape, clip it to the scene rendering frame. 82 | """ 83 | if not self.__clips_children_to_frame: 84 | return renderable 85 | 86 | renderable.geometry = intersection( 87 | self.render_context.frame, renderable.geometry 88 | ) 89 | return renderable 90 | 91 | def __occlude(self, renderables: list[RenderableGeometry]) -> list[RenderableGeometry]: 92 | geometries = list(map(lambda renderable: renderable.geometry, renderables)) 93 | tree = STRtree(geometries) 94 | 95 | occluded_geometries = renderables 96 | for i, geometry in enumerate(geometries): 97 | if not isinstance(geometry, Polygon): 98 | continue 99 | 100 | intersecting = list( 101 | filter( 102 | lambda idx: idx < i, tree.query(geometry, predicate="intersects") 103 | ) 104 | ) 105 | for idx in intersecting: 106 | occluded_geometries[idx].geometry = occluded_geometries[ 107 | idx 108 | ].geometry.difference(geometry) 109 | 110 | return occluded_geometries 111 | -------------------------------------------------------------------------------- /pysometric/shape.py: -------------------------------------------------------------------------------- 1 | import math 2 | from abc import abstractmethod 3 | 4 | import numpy as np 5 | import shapely 6 | 7 | from pysometric.vector import Vector3 8 | 9 | from .axis import Axis 10 | from .matrix import rotate_x, rotate_y, rotate_z 11 | from .plane import Plane 12 | from .render import RenderableGeometry, project_point 13 | from .scene import RenderContext 14 | from .texture import Texture 15 | from .vector import Vector2, Vector3 16 | 17 | 18 | def project_to_plane(point: Vector2, plane: Plane) -> Vector3: 19 | """Given a point in 2D space, reverse-project it to 3D on the given plane.""" 20 | x, y = point 21 | 22 | if plane == Plane.XY: 23 | return (x, y, 0) 24 | 25 | if plane == Plane.XZ: 26 | return (x, 0, y) 27 | 28 | if plane == Plane.YZ: 29 | return (0, x, y) 30 | 31 | 32 | def _rotate_vertices_x( 33 | vertices: list[Vector3], angle: float, center: Vector3 34 | ) -> list[Vector3]: 35 | """Rotates the list of vertices around the X-axis for the given angle with the given center of rotation.""" 36 | return list(map(lambda v: rotate_x(v, angle, center), vertices)) 37 | 38 | 39 | def _rotate_vertices_y( 40 | vertices: list[Vector3], angle: float, center: Vector3 41 | ) -> list[Vector3]: 42 | return list(map(lambda v: rotate_y(v, angle, center), vertices)) 43 | 44 | 45 | def _rotate_vertices_z( 46 | vertices: list[Vector3], angle: float, center: Vector3 47 | ) -> list[Vector3]: 48 | return list(map(lambda v: rotate_z(v, angle, center), vertices)) 49 | 50 | 51 | def _regular_polygon_vertices( 52 | origin: Vector3, num_vertices: int, radius: float, orientation: Plane 53 | ) -> list[Vector3]: 54 | angle = 2 * np.pi / num_vertices 55 | vertices = [] 56 | for i in range(num_vertices): 57 | vertices.append((math.cos(i * angle) * radius, math.sin(i * angle) * radius)) 58 | 59 | def project_and_translate(v): 60 | origin_x, origin_y, origin_z = origin 61 | x, y, z = project_to_plane(v, orientation) 62 | return (x + origin_x, y + origin_y, z + origin_z) 63 | 64 | vertices = list(map(project_and_translate, vertices)) 65 | return vertices 66 | 67 | 68 | def _rect_vertices( 69 | origin: Vector3, width: float, height: float, orientation: Plane 70 | ) -> list[Vector3]: 71 | """Returns the vertices for a rectangular plane with an orientation in the given Plane. 72 | 73 | Vertices will always be returned in clockwise order starting at the origin. 74 | """ 75 | x, y, z = origin 76 | hw = width / 2.0 77 | hh = height / 2.0 78 | 79 | if orientation == Plane.YZ: 80 | return [ 81 | (x, y - hw, z - hh), 82 | (x, y + hw, z - hh), 83 | (x, y + hw, z + hh), 84 | (x, y - hw, z + hh), 85 | ] 86 | 87 | if orientation == Plane.XZ: 88 | return [ 89 | (x - hw, y, z - hh), 90 | (x - hw, y, z + hh), 91 | (x + hw, y, z + hh), 92 | (x + hw, y, z - hh), 93 | ] 94 | 95 | if orientation == Plane.XY: 96 | return [ 97 | (x - hw, y - hh, z), 98 | (x - hw, y + hh, z), 99 | (x + hw, y + hh, z), 100 | (x + hw, y - hh, z), 101 | ] 102 | 103 | raise "Unsupported Plane value provided for orientation." 104 | 105 | 106 | class Rotation: 107 | def __init__(self, axis: Axis, angle: float, origin: Vector3): 108 | self._axis = axis 109 | self._angle = angle 110 | self._origin = origin 111 | 112 | @property 113 | def axis(self): 114 | return self._axis 115 | 116 | @property 117 | def angle(self): 118 | return self._angle 119 | 120 | @property 121 | def origin(self): 122 | return self._origin 123 | 124 | 125 | class Renderable: 126 | """Base class for all renderable objects, both individual shapes and groups of shapes.""" 127 | 128 | def __init__(self, rotations: list[Rotation] = [], layer=1): 129 | self._layer = layer 130 | self._rotations = rotations 131 | self._vertices = [] 132 | 133 | @abstractmethod 134 | def compile(self, render_context: RenderContext) -> list[RenderableGeometry]: 135 | return [] 136 | 137 | @property 138 | def layer(self) -> int: 139 | return self._layer 140 | 141 | @property 142 | def rotations(self) -> list[Rotation]: 143 | return self._rotations 144 | 145 | @property 146 | def vertices(self) -> list[Vector3]: 147 | return self._vertices 148 | 149 | 150 | class Polygon(Renderable): 151 | def __init__( 152 | self, 153 | vertices: list[Vector3], 154 | textures: list[Texture] = [], 155 | rotations: list[Rotation] = [], 156 | layer=1, 157 | ): 158 | super().__init__(rotations, layer) 159 | self._vertices = vertices 160 | self._textures = textures 161 | self._apply_rotations() 162 | 163 | def compile(self, render_context: RenderContext) -> list[RenderableGeometry]: 164 | polygon2d = shapely.Polygon( 165 | list(map(lambda v: project_point(v, render_context), self.vertices)) 166 | ) 167 | compiled_textures: list[RenderableGeometry] = [ 168 | texture.compile(polygon2d, render_context) for texture in self.textures 169 | ] 170 | 171 | return [RenderableGeometry(polygon2d, self._layer)] + compiled_textures 172 | 173 | @property 174 | def textures(self) -> list[Texture]: 175 | return self._textures 176 | 177 | def _apply_rotations(self): 178 | for rotation in self.rotations: 179 | match rotation.axis: 180 | case Axis.X: 181 | self._vertices = _rotate_vertices_x( 182 | self._vertices, rotation.angle, rotation.origin 183 | ) 184 | 185 | case Axis.Y: 186 | self._vertices = _rotate_vertices_y( 187 | self._vertices, rotation.angle, rotation.origin 188 | ) 189 | 190 | case Axis.Z: 191 | self._vertices = _rotate_vertices_z( 192 | self._vertices, rotation.angle, rotation.origin 193 | ) 194 | 195 | 196 | class RegularPolygon(Polygon): 197 | """Defines a symmetrical polygon in isometric 3D space.""" 198 | 199 | def __init__( 200 | self, 201 | origin: Vector3, 202 | num_vertices: int, 203 | radius: float, 204 | orientation: Plane, 205 | textures: list[Texture] = [], 206 | rotations: list[Rotation] = [], 207 | layer=1, 208 | ): 209 | vertices = _regular_polygon_vertices(origin, num_vertices, radius, orientation) 210 | super().__init__(vertices, textures, rotations, layer) 211 | 212 | 213 | class Rectangle(Polygon): 214 | """Defines a rectangular polygon in 3D space. 215 | 216 | Rectangles should have an orientation parallel to one of the 3 possible 217 | planes defined in the Plane enumeration. 218 | """ 219 | 220 | def __init__( 221 | self, 222 | origin: Vector3, 223 | width: float, 224 | height: float, 225 | orientation: Plane, 226 | textures: list[Texture] = [], 227 | rotations: list[Rotation] = [], 228 | layer=1, 229 | ): 230 | super().__init__( 231 | _rect_vertices(origin, width, height, orientation), 232 | textures, 233 | rotations, 234 | layer, 235 | ) 236 | self._width = width 237 | self._height = height 238 | 239 | @property 240 | def width(self): 241 | return self._width 242 | 243 | @property 244 | def height(self): 245 | return self._height 246 | 247 | 248 | class Circle(Polygon): 249 | def __init__( 250 | self, 251 | center: Vector3, 252 | radius: float, 253 | orientation: Plane, 254 | num_segments = 64, 255 | textures: list[Texture] = [], 256 | rotations: list[Rotation] = [], 257 | layer=1, 258 | ): 259 | vertices = _regular_polygon_vertices(center, num_segments, radius, orientation) 260 | super().__init__(vertices, textures, rotations, layer) 261 | 262 | 263 | class Group: 264 | def __init__(self, children: list[Renderable]) -> None: 265 | self._children = children 266 | 267 | @property 268 | def children(self) -> list[Renderable]: 269 | return self._children 270 | 271 | def compile(self, render_context: RenderContext) -> list[RenderableGeometry]: 272 | compiled = [] 273 | for child in self.children: 274 | compiled = compiled + child.compile(render_context) 275 | 276 | return compiled 277 | -------------------------------------------------------------------------------- /pysometric/tests/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/svoisen/pysometric/6198f7fcb87517fae9154e4724ab3bffc03e18f1/pysometric/tests/__init__.py -------------------------------------------------------------------------------- /pysometric/tests/matrix_test.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | import numpy as np 3 | 4 | from math import radians, sqrt 5 | from ..matrix import rotate_z, rotate_x 6 | 7 | def test_rotate_z(): 8 | rotated_point = rotate_z((0, 0, 0), radians(45)) 9 | assert rotated_point == (0, 0, 0) 10 | 11 | rotated_point = rotate_z((0, 1, 0), radians(-90)) 12 | assert rotated_point == pytest.approx((1, 0, 0), 0.01) 13 | 14 | rotated_point = rotate_z((0, 1, 0), radians(180)) 15 | assert rotated_point == pytest.approx((0, -1, 0), 0.01) 16 | 17 | rotated_point = rotate_z((1, 1, 0), radians(90)) 18 | assert rotated_point == pytest.approx((-1, 1, 0), 0.01) 19 | 20 | rotated_point = rotate_z((1, 1, 0), radians(45)) 21 | assert rotated_point == pytest.approx((0, sqrt(2), 0), 0.01) 22 | 23 | rotated_point = rotate_z((1, 1, 0), radians(-45)) 24 | assert rotated_point == pytest.approx((sqrt(2), 0, 0), 0.01) 25 | 26 | def test_rotate_x(): 27 | rotated_point = rotate_x((0, 0, 0), radians(45)) 28 | assert rotated_point == (0, 0, 0) 29 | 30 | rotated_point = rotate_x((0, 1, 0), radians(-90)) 31 | assert rotated_point == pytest.approx((0, 0, -1), 0.01) 32 | 33 | rotated_point = rotate_x((0, 1, 0), radians(180)) 34 | assert rotated_point == pytest.approx((0, -1, 0), 0.01) 35 | 36 | rotated_point = rotate_x((1, 1, 0), radians(90)) 37 | assert rotated_point == pytest.approx((1, 0, 1), 0.01) -------------------------------------------------------------------------------- /pysometric/tests/render_test.py: -------------------------------------------------------------------------------- 1 | import math 2 | 3 | import pytest 4 | import shapely 5 | 6 | from ..render import RenderContext, project_point 7 | from ..scene import DIMETRIC_ANGLE 8 | 9 | 10 | @pytest.fixture 11 | def frame(): 12 | return shapely.box(0, 0, 100, 100) 13 | 14 | 15 | def test_project_point(frame): 16 | """Test project_point()""" 17 | render_context = RenderContext(frame, 100, math.radians(DIMETRIC_ANGLE)) 18 | point = (0, 0, 0) 19 | expected = (50, 50) 20 | actual = project_point(point, render_context) 21 | assert expected == actual 22 | 23 | point = (10, 10, 0) 24 | expected = (50, 31.723) 25 | actual = project_point(point, render_context) 26 | assert actual == pytest.approx(expected, 0.001) 27 | -------------------------------------------------------------------------------- /pysometric/tests/scene_test.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | import shapely 3 | 4 | from ..scene import Scene, RenderableGeometry 5 | from ..volume import Box 6 | 7 | @pytest.fixture 8 | def frame(): 9 | return shapely.box(0, 0, 500, 500) 10 | 11 | class TestScene: 12 | def test_init(self, frame): 13 | """Test Scene initialization""" 14 | box = Box((0, 0, 0)) 15 | grid_pitch = 1 16 | children = [box] 17 | scene = Scene(frame, grid_pitch, children) 18 | 19 | assert scene.render_context.frame == frame 20 | assert scene.render_context.grid_pitch == grid_pitch 21 | assert scene.children == children 22 | 23 | def test_compile(self, frame): 24 | """Test Scene.compile()""" 25 | box = Box((0, 0, 0)) 26 | scene = Scene(frame, 1, [box]) 27 | result = scene.compile() 28 | assert len(result) == 3 # 3 faces 29 | assert all([isinstance(r, RenderableGeometry) for r in result]) 30 | -------------------------------------------------------------------------------- /pysometric/tests/shape_test.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | 3 | from ..shape import Circle, Plane, Rectangle, project_to_plane 4 | 5 | 6 | def test_project_to_plane_xy(): 7 | point = (5, 10) 8 | expected = (5, 10, 0) 9 | actual = project_to_plane(point, Plane.XY) 10 | assert expected == actual 11 | 12 | 13 | def test_project_to_plane_xz(): 14 | point = (5, 10) 15 | expected = (5, 0, 10) 16 | actual = project_to_plane(point, Plane.XZ) 17 | assert expected == actual 18 | 19 | 20 | def test_project_to_plane_yz(): 21 | point = (5, 10) 22 | expected = (0, 5, 10) 23 | actual = project_to_plane(point, Plane.YZ) 24 | assert expected == actual 25 | 26 | 27 | class TestRectangle: 28 | def test_xy_plane_rectangle_vertices(self): 29 | expected_vertices = [ 30 | (-0.5, -0.5, 0), 31 | (-0.5, 0.5, 0), 32 | (0.5, 0.5, 0), 33 | (0.5, -0.5, 0), 34 | ] 35 | actual = Rectangle((0, 0, 0), 1, 1, Plane.XY).vertices 36 | assert expected_vertices == actual 37 | 38 | 39 | class TestCircle: 40 | def test_circle_vertices(self): 41 | """Test that a Circle generates the expected number of vertices""" 42 | circle = Circle((0, 0, 0), 1, Plane.XY, 64) 43 | assert len(circle.vertices) == 64 44 | -------------------------------------------------------------------------------- /pysometric/texture.py: -------------------------------------------------------------------------------- 1 | from abc import abstractmethod 2 | from math import radians 3 | 4 | import shapely 5 | from vsketch.fill import generate_fill 6 | 7 | from .fill import hatch_fill 8 | from .render import RenderableGeometry, RenderContext 9 | 10 | 11 | class Texture: 12 | """ 13 | Base class for a texture, which is a 2D skin that can be applied to any Polygon. 14 | """ 15 | 16 | def __init__(self, layer=1) -> None: 17 | self._layer = layer 18 | 19 | @abstractmethod 20 | def compile( 21 | self, polygon2d: shapely.Polygon, render_context: RenderContext 22 | ) -> RenderableGeometry: 23 | """Compiles the texture for rendering.""" 24 | 25 | @property 26 | def layer(self) -> int: 27 | return self._layer 28 | 29 | 30 | class HatchTexture(Texture): 31 | """ 32 | A texture that has repeats a hatch (single or cross) fill across the entire polygon 33 | surface. 34 | """ 35 | 36 | def __init__( 37 | self, 38 | pitch: float, 39 | angle=radians(45), 40 | inset=0, 41 | layer=1, 42 | ) -> None: 43 | super().__init__(layer) 44 | self._pitch = pitch 45 | self._angle = angle 46 | self._inset = inset 47 | 48 | def compile( 49 | self, polygon2d: shapely.Polygon, render_context: RenderContext 50 | ) -> RenderableGeometry: 51 | fill_clip = ( 52 | polygon2d if self._inset == 0 else polygon2d.buffer(self._inset * -1) 53 | ) 54 | fill = hatch_fill(fill_clip, self._pitch, self._angle) 55 | return RenderableGeometry(fill, self._layer) 56 | 57 | 58 | class FillTexture(Texture): 59 | def __init__(self, pen_width=0.5, inset=-0, layer=1) -> None: 60 | super().__init__(layer) 61 | self._pen_width = pen_width 62 | self._inset = inset 63 | 64 | def compile( 65 | self, polygon2d: shapely.Polygon, render_context: RenderContext 66 | ) -> RenderableGeometry: 67 | fill_clip = ( 68 | polygon2d if self._inset == 0 else polygon2d.buffer(self._inset * -1) 69 | ) 70 | fill = generate_fill(fill_clip, self._pen_width, 1.0).as_mls() 71 | return RenderableGeometry(fill, self._layer) 72 | -------------------------------------------------------------------------------- /pysometric/vector.py: -------------------------------------------------------------------------------- 1 | Vector2 = tuple[float, float] 2 | Vector3 = tuple[float, float, float] -------------------------------------------------------------------------------- /pysometric/volume.py: -------------------------------------------------------------------------------- 1 | from functools import cmp_to_key 2 | 3 | import shapely 4 | 5 | from pysometric.render import RenderableGeometry 6 | from pysometric.scene import RenderContext 7 | from pysometric.shape import Renderable 8 | 9 | from .plane import Plane 10 | from .render import RenderableGeometry, project_point 11 | from .shape import Group, Polygon, Rectangle, RegularPolygon 12 | from .vector import Vector3 13 | 14 | 15 | def _sort_visually( 16 | polygons: list[Polygon], render_context: RenderContext 17 | ) -> list[Polygon]: 18 | def zsort(p0: tuple[shapely.Polygon, Polygon], p1: tuple[shapely.Polygon, Polygon]): 19 | # Determine which geometry is visually lower on the 2D y-axis, which 20 | # means it should be drawn first because it appears closer to the viewer 21 | _, _, _, ymax0 = shapely.bounds(p0[0]) 22 | _, _, _, ymax1 = shapely.bounds(p1[0]) 23 | 24 | if ymax0 < ymax1: 25 | return -1 26 | 27 | if ymax0 > ymax1: 28 | return 1 29 | 30 | return 0 31 | 32 | polygons2d = [ 33 | ( 34 | shapely.Polygon( 35 | list(map(lambda v: project_point(v, render_context), p.vertices)) 36 | ), 37 | p, 38 | ) 39 | for p in polygons 40 | ] 41 | 42 | return list(map(lambda p: p[1], sorted(polygons2d, key=cmp_to_key(zsort)))) 43 | 44 | 45 | class Box(Group): 46 | def __init__( 47 | self, 48 | origin: Vector3, 49 | width=1, 50 | depth=1, 51 | height=1, 52 | top: dict = {}, 53 | left: dict = {}, 54 | right: dict = {}, 55 | ): 56 | x, y, z = origin 57 | hw = width / 2.0 58 | hd = depth / 2.0 59 | hh = height / 2.0 60 | left_plane = Rectangle( 61 | (x - hw, y, z), 62 | depth, 63 | height, 64 | Plane.YZ, 65 | left.get("textures") or [], 66 | [], 67 | left.get("layer") or 1, 68 | ) 69 | right_plane = Rectangle( 70 | (x, y - hd, z), 71 | width, 72 | height, 73 | Plane.XZ, 74 | right.get("textures") or [], 75 | [], 76 | right.get("layer") or 1, 77 | ) 78 | top_plane = Rectangle( 79 | (x, y, z + hh), 80 | width, 81 | depth, 82 | Plane.XY, 83 | top.get("textures") or [], 84 | [], 85 | top.get("layer") or 1, 86 | ) 87 | 88 | super().__init__([left_plane, right_plane, top_plane]) 89 | 90 | 91 | class Pyramid(Group): 92 | def __init__( 93 | self, 94 | origin: Vector3, 95 | width=1, 96 | depth=1, 97 | height=1, 98 | sides=[], 99 | rotations=[], 100 | layer=1, 101 | ) -> None: 102 | x, y, z = origin 103 | peak = (x, y, z + height) 104 | self._bottom_face = Rectangle(origin, width, depth, Plane.XY) 105 | self._side_faces = [] 106 | for i in range(4): 107 | v0 = self._bottom_face.vertices[i] 108 | v1 = self._bottom_face.vertices[(i + 1) % 4] 109 | textures = sides[i].get("textures") or [] if len(sides) > i else [] 110 | self._side_faces.append( 111 | Polygon([v0, v1, peak], textures, [], layer) 112 | ) 113 | 114 | super().__init__([self._bottom_face] + self._side_faces) 115 | 116 | def compile(self, render_context: RenderContext) -> list[RenderableGeometry]: 117 | self._children = [self._bottom_face] + _sort_visually( 118 | self._side_faces, render_context 119 | ) 120 | 121 | return super().compile(render_context) 122 | 123 | 124 | class Prism(Group): 125 | def __init__( 126 | self, 127 | origin: Vector3, 128 | num_sides: int, 129 | radius=1, 130 | height=1, 131 | top={}, 132 | bottom={}, 133 | sides=[], 134 | rotations=[], 135 | ) -> None: 136 | x, y, z = origin 137 | self._top_face = RegularPolygon( 138 | (x, y, z + height / 2), 139 | num_sides, 140 | radius, 141 | Plane.XY, 142 | top.get("textures") or [], 143 | rotations, 144 | top.get("layer") or 1, 145 | ) 146 | self._bottom_face = RegularPolygon( 147 | (x, y, z - height / 2), 148 | num_sides, 149 | radius, 150 | Plane.XY, 151 | bottom.get("textures") or [], 152 | rotations, 153 | bottom.get("layer") or 1, 154 | ) 155 | self._side_faces = [] 156 | for i in range(num_sides): 157 | j = (i + 1) % num_sides 158 | textures = sides[i].get("textures") or [] if len(sides) > i else [] 159 | layer = sides[i].get("layer") or 1 if len(sides) > i else 1 160 | vertices = [ 161 | self._top_face.vertices[i], 162 | self._top_face.vertices[j], 163 | self._bottom_face.vertices[j], 164 | self._bottom_face.vertices[i], 165 | ] 166 | self._side_faces.append(Polygon(vertices, textures, [], layer)) 167 | 168 | super().__init__([self._bottom_face] + self._side_faces + [self._top_face]) 169 | 170 | @property 171 | def faces(self): 172 | return [self._bottom_face] + self._side_faces + [self._top_face] 173 | 174 | @property 175 | def top_face(self): 176 | return self._top_face 177 | 178 | @property 179 | def bottom_face(self): 180 | return self._bottom_face 181 | 182 | @property 183 | def side_faces(self): 184 | return self._side_faces 185 | 186 | def compile(self, render_context: RenderContext) -> list[RenderableGeometry]: 187 | self._children = ( 188 | [self._bottom_face] 189 | + _sort_visually(self._side_faces, render_context) 190 | + [self._top_face] 191 | ) 192 | 193 | return super().compile(render_context) 194 | --------------------------------------------------------------------------------