Add an SVG example file
[experiments/svg_interactive_isometric_editor.git] / svg_interactive_isometric_editor.py
1 #!/usr/bin/env python3
2 #
3 # Draw an isometric cube that can be edited live in Inkscape.
4 #
5 # Copyright (C) 2018  Antonio Ospite <ao2@ao2.it>
6 #
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.
11 #
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.
16 #
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/>.
19
20 from math import cos, sin, pi, radians
21
22 import svgwrite
23 from svgwrite.data.types import SVGAttribute
24
25
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'
30
31     def __init__(self, *args, **kwargs):
32         super(InkscapeDrawing, self).__init__(*args, **kwargs)
33
34         inkscape_attributes = {
35             'xmlns:inkscape': SVGAttribute('xmlns:inkscape',
36                                            anim=False,
37                                            types=[],
38                                            const=frozenset([self.INKSCAPE_NAMESPACE])),
39             'xmlns:sodipodi': SVGAttribute('xmlns:sodipodi',
40                                            anim=False,
41                                            types=[],
42                                            const=frozenset([self.SODIPODI_NAMESPACE])),
43             'inkscape:groupmode': SVGAttribute('inkscape:groupmode',
44                                                anim=False,
45                                                types=[],
46                                                const=frozenset(['layer'])),
47             'inkscape:label': SVGAttribute('inkscape:label',
48                                            anim=False,
49                                            types=frozenset(['string']),
50                                            const=[]),
51             'sodipodi:insensitive': SVGAttribute('sodipodi:insensitive',
52                                                  anim=False,
53                                                  types=frozenset(['string']),
54                                                  const=[])
55         }
56
57         self.validator.attributes.update(inkscape_attributes)
58
59         elements = self.validator.elements
60
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)
65
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)
71
72         self['xmlns:inkscape'] = self.INKSCAPE_NAMESPACE
73         self['xmlns:sodipodi'] = self.SODIPODI_NAMESPACE
74
75     def layer(self, **kwargs):
76         """Create an inkscape layer.
77
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)
81
82         new_layer = self.g(**kwargs)
83         new_layer['inkscape:groupmode'] = 'layer'
84
85         if label:
86             new_layer['inkscape:label'] = label
87
88         return new_layer
89
90
91 class IsometricDrawing(InkscapeDrawing):
92     def __init__(self, *args, **kwargs):
93         super(IsometricDrawing, self).__init__(*args, **kwargs)
94
95         self.size = kwargs["size"]
96
97         # create some layers and groups
98         layers = {
99             "Faces": self.layer(label="Faces"),
100             "Cube": self.layer(label="Cube")
101         }
102         layers["Cube"]["sodipodi:insensitive"] = "true"
103
104         for layer in layers.values():
105             self.add(layer)
106
107         self.groups = layers
108
109         self.faces = ['left', 'right', 'top']
110
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
113         # other faces.
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)
119
120         # Precomputed values for sine, cosine, and tangent of 30°.
121         # Code taken from http://jeroenhoek.nl/articles/svg-and-isometric-projection.html
122         rad_30 = radians(30)
123         cos_30 = cos(rad_30)
124         sin_30 = 0.5  # No point in using sin for 30°.
125
126         # Combined affine transformation matrices. The bottom row of these 3×3
127         # matrices is omitted; it is always [0, 0, 1].
128         self.face_map = {
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,
133                      sin_30,       1,          0],
134
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,
139                       -sin_30,      1,          0],
140
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,
146                      sin_30,       sin_30,     0]
147         }
148
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],
152                                                          m[1], m[4],
153                                                          m[2], m[5])
154
155         # SVG transforms are applied right to left, the code below means:
156         #
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)
163
164         return transform
165
166     def draw(self):
167         width, height = self.size
168         side = width / 4.5
169         x_border = (width - side * 3) / 4
170         y_border = 20
171
172         cube_center = width / 2, (height - y_border - side)
173
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
179         group.add(text_elem)
180
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)
187
188         for i, face_name in enumerate(self.faces):
189             cx, cy = (x_border + side / 2 + (x_border + side) * i,
190                       y_border + side / 2)
191             rx, ry = (cx - side / 2,
192                       cy - side / 2)
193
194             # Draw some default content
195             group = self.groups["face-%s-content" % face_name]
196             rect = self.rect((rx, ry), (side, side))
197             color = [0, 0, 0]
198             color[i] = 255
199             rect['fill'] = svgwrite.utils.rgb(color[0], color[1], color[2], mode='RGB')
200             group.add(rect)
201
202             group = self.groups["Faces"]
203
204             # The radius of the hexagon representing the isometric cube is equal
205             # to the side of the original faces.
206             #
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)))
211
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,
217                                                                 cube_face_center[0],
218                                                                 cube_face_center[1],
219                                                                 face_name)
220             ref['transform'] = svg_transform
221             group.add(ref)
222
223
224 def main():
225     svg = IsometricDrawing('inkscape-isometric-editor.svg', profile='full', size=(640, 480))
226     svg.draw()
227     svg.save()
228
229
230 if __name__ == "__main__":
231     main()