3 # Draw an isometric cube that can be edited live in Inkscape.
5 # Copyright (C) 2018 Antonio Ospite <ao2@ao2.it>
7 # This program is free software: you can redistribute it and/or modify
8 # it under the terms of the GNU General Public License as published by
9 # the Free Software Foundation, either version 3 of the License, or
10 # (at your option) any later version.
12 # This program is distributed in the hope that it will be useful,
13 # but WITHOUT ANY WARRANTY; without even the implied warranty of
14 # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
15 # GNU General Public License for more details.
17 # You should have received a copy of the GNU General Public License
18 # along with this program. If not, see <http://www.gnu.org/licenses/>.
20 from math import cos, sin, pi, radians
23 from svgwrite.data.types import SVGAttribute
26 class InkscapeDrawing(svgwrite.Drawing):
27 """An svgwrite.Drawing subclass which supports Inkscape layers"""
28 INKSCAPE_NAMESPACE = 'http://www.inkscape.org/namespaces/inkscape'
29 SODIPODI_NAMESPACE = 'http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd'
31 def __init__(self, *args, **kwargs):
32 super(InkscapeDrawing, self).__init__(*args, **kwargs)
34 inkscape_attributes = {
35 'xmlns:inkscape': SVGAttribute('xmlns:inkscape',
38 const=frozenset([self.INKSCAPE_NAMESPACE])),
39 'xmlns:sodipodi': SVGAttribute('xmlns:sodipodi',
42 const=frozenset([self.SODIPODI_NAMESPACE])),
43 'inkscape:groupmode': SVGAttribute('inkscape:groupmode',
46 const=frozenset(['layer'])),
47 'inkscape:label': SVGAttribute('inkscape:label',
49 types=frozenset(['string']),
51 'sodipodi:insensitive': SVGAttribute('sodipodi:insensitive',
53 types=frozenset(['string']),
57 self.validator.attributes.update(inkscape_attributes)
59 elements = self.validator.elements
61 svg_attributes = set(elements['svg'].valid_attributes)
62 svg_attributes.add('xmlns:inkscape')
63 svg_attributes.add('xmlns:sodipodi')
64 elements['svg'].valid_attributes = frozenset(svg_attributes)
66 g_attributes = set(elements['g'].valid_attributes)
67 g_attributes.add('inkscape:groupmode')
68 g_attributes.add('inkscape:label')
69 g_attributes.add('sodipodi:insensitive')
70 elements['g'].valid_attributes = frozenset(g_attributes)
72 self['xmlns:inkscape'] = self.INKSCAPE_NAMESPACE
73 self['xmlns:sodipodi'] = self.SODIPODI_NAMESPACE
75 def layer(self, **kwargs):
76 """Create an inkscape layer.
78 An optional 'label' keyword argument can be passed to set a user
79 friendly name for the layer."""
80 label = kwargs.pop('label', None)
82 new_layer = self.g(**kwargs)
83 new_layer['inkscape:groupmode'] = 'layer'
86 new_layer['inkscape:label'] = label
91 class IsometricDrawing(InkscapeDrawing):
92 def __init__(self, *args, **kwargs):
93 super(IsometricDrawing, self).__init__(*args, **kwargs)
95 self.size = kwargs["size"]
97 # create some layers and groups
99 "Faces": self.layer(label="Faces"),
100 "Cube": self.layer(label="Cube")
102 layers["Cube"]["sodipodi:insensitive"] = "true"
104 for layer in layers.values():
109 self.faces = ['left', 'right', 'top']
111 # Reverse the layer order so that the Top layer is on the bottom of the
112 # stack to represent the fact that the top face is "farther" than the
114 for face_name in reversed(self.faces):
115 name = "face-%s-content" % face_name
116 layer = self.layer(id=name, label=face_name.capitalize())
117 self.groups[name] = layer
118 layers['Faces'] .add(layer)
120 # Precomputed values for sine, cosine, and tangent of 30°.
121 # Code taken from http://jeroenhoek.nl/articles/svg-and-isometric-projection.html
124 sin_30 = 0.5 # No point in using sin for 30°.
126 # Combined affine transformation matrices. The bottom row of these 3×3
127 # matrices is omitted; it is always [0, 0, 1].
129 # From 2D to isometric left-hand side view:
130 # * scale horizontally by cos(30°)
131 # * shear vertically by -30°
132 'left': [cos_30, 0, 0,
135 # From 2D to isometric right-hand side view:
136 # * scale horizontally by cos(30°)
137 # * shear vertically by 30°
138 'right': [cos_30, 0, 0,
141 # From 2D to isometric top down view:
142 # * scale vertically by cos(30°)
143 # * shear horizontally by -30°
144 # * rotate clock-wise 30°
145 'top': [cos_30, -cos_30, 0,
149 def get_isometric_transform_string(self, src_x, src_y, dest_x, dest_y, face_name):
150 m = self.face_map[face_name]
151 svg_matrix = "matrix(%f, %f, %f, %f, %f, %f)" % (m[0], m[3],
155 # SVG transforms are applied right to left, the code below means:
157 # 1. move the face center to (0,0) translating it by (-src_x, -src_y);
158 # 2. apply the isometric projection;
159 # 3. move it to the position in the cube.
160 transform = "translate(%f %f)" % (dest_x, dest_y)
161 transform += svg_matrix
162 transform += "translate(%f %f)" % (-src_x, -src_y)
167 width, height = self.size
169 x_border = (width - side * 3) / 4
172 cube_center = width / 2, (height - y_border - side)
174 # Print some instructions
175 group = self.groups["Faces"]
176 font_size = (width / 20)
177 text_elem = self.text("", x=[0], y=[(height + font_size + y_border)])
178 text_elem["style"] = "font-size:%d;" % font_size
181 text = ["Edit the layers named 'Left', 'Right', 'Top'",
182 "and watch the changes projected onto the",
183 "isometric cube automatically and interactively."]
184 for i, line in enumerate(text):
185 line_elem = self.tspan(line, x=[0], dy=["%dem" % ((i > 0) * 1.2)])
186 text_elem.add(line_elem)
188 for i, face_name in enumerate(self.faces):
189 cx, cy = (x_border + side / 2 + (x_border + side) * i,
191 rx, ry = (cx - side / 2,
194 # Draw some default content
195 group = self.groups["face-%s-content" % face_name]
196 rect = self.rect((rx, ry), (side, side))
199 rect['fill'] = svgwrite.utils.rgb(color[0], color[1], color[2], mode='RGB')
202 group = self.groups["Faces"]
204 # The radius of the hexagon representing the isometric cube is equal
205 # to the side of the original faces.
207 # And the centers of the projected faces fall in the middle of the
208 # radius, this is where the (side / 2) below comes from
209 cube_face_center = (cube_center[0] + side / 2 * sin(pi / 3 * (i * 2 - 1)),
210 cube_center[1] + side / 2 * cos(pi / 3 * (i * 2 - 1)))
212 # Reuse the content to draw the isometric cube
213 group = self.groups["Cube"]
214 content_href = "#face-%s-content" % face_name
215 ref = self.use(content_href)
216 svg_transform = self.get_isometric_transform_string(cx, cy,
220 ref['transform'] = svg_transform
225 svg = IsometricDrawing('inkscape-isometric-editor.svg', profile='full', size=(640, 480))
230 if __name__ == "__main__":