--- /dev/null
+#!/usr/bin/env python3
+#
+# Draw an isometric cube that can be edited live in Inkscape.
+#
+# Copyright (C) 2018 Antonio Ospite <ao2@ao2.it>
+#
+# This program is free software: you can redistribute it and/or modify
+# it under the terms of the GNU General Public License as published by
+# the Free Software Foundation, either version 3 of the License, or
+# (at your option) any later version.
+#
+# This program is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+# GNU General Public License for more details.
+#
+# You should have received a copy of the GNU General Public License
+# along with this program. If not, see <http://www.gnu.org/licenses/>.
+
+from math import cos, sin, pi, radians
+
+import svgwrite
+from svgwrite.data.types import SVGAttribute
+
+
+class InkscapeDrawing(svgwrite.Drawing):
+ """An svgwrite.Drawing subclass which supports Inkscape layers"""
+ INKSCAPE_NAMESPACE = 'http://www.inkscape.org/namespaces/inkscape'
+ SODIPODI_NAMESPACE = 'http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd'
+
+ def __init__(self, *args, **kwargs):
+ super(InkscapeDrawing, self).__init__(*args, **kwargs)
+
+ inkscape_attributes = {
+ 'xmlns:inkscape': SVGAttribute('xmlns:inkscape',
+ anim=False,
+ types=[],
+ const=frozenset([self.INKSCAPE_NAMESPACE])),
+ 'xmlns:sodipodi': SVGAttribute('xmlns:sodipodi',
+ anim=False,
+ types=[],
+ const=frozenset([self.SODIPODI_NAMESPACE])),
+ 'inkscape:groupmode': SVGAttribute('inkscape:groupmode',
+ anim=False,
+ types=[],
+ const=frozenset(['layer'])),
+ 'inkscape:label': SVGAttribute('inkscape:label',
+ anim=False,
+ types=frozenset(['string']),
+ const=[]),
+ 'sodipodi:insensitive': SVGAttribute('sodipodi:insensitive',
+ anim=False,
+ types=frozenset(['string']),
+ const=[])
+ }
+
+ self.validator.attributes.update(inkscape_attributes)
+
+ elements = self.validator.elements
+
+ svg_attributes = set(elements['svg'].valid_attributes)
+ svg_attributes.add('xmlns:inkscape')
+ svg_attributes.add('xmlns:sodipodi')
+ elements['svg'].valid_attributes = frozenset(svg_attributes)
+
+ g_attributes = set(elements['g'].valid_attributes)
+ g_attributes.add('inkscape:groupmode')
+ g_attributes.add('inkscape:label')
+ g_attributes.add('sodipodi:insensitive')
+ elements['g'].valid_attributes = frozenset(g_attributes)
+
+ self['xmlns:inkscape'] = self.INKSCAPE_NAMESPACE
+ self['xmlns:sodipodi'] = self.SODIPODI_NAMESPACE
+
+ def layer(self, **kwargs):
+ """Create an inkscape layer.
+
+ An optional 'label' keyword argument can be passed to set a user
+ friendly name for the layer."""
+ label = kwargs.pop('label', None)
+
+ new_layer = self.g(**kwargs)
+ new_layer['inkscape:groupmode'] = 'layer'
+
+ if label:
+ new_layer['inkscape:label'] = label
+
+ return new_layer
+
+
+class IsometricDrawing(InkscapeDrawing):
+ def __init__(self, *args, **kwargs):
+ super(IsometricDrawing, self).__init__(*args, **kwargs)
+
+ self.size = kwargs["size"]
+
+ # create some layers and groups
+ layers = {
+ "Faces": self.layer(label="Faces"),
+ "Cube": self.layer(label="Cube")
+ }
+ layers["Cube"]["sodipodi:insensitive"] = "true"
+
+ for layer in layers.values():
+ self.add(layer)
+
+ self.groups = layers
+
+ self.faces = ['left', 'right', 'top']
+
+ # Reverse the layer order so that the Top layer is on the bottom of the
+ # stack to represent the fact that the top face is "farther" than the
+ # other faces.
+ for face_name in reversed(self.faces):
+ name = "face-%s-content" % face_name
+ layer = self.layer(id=name, label=face_name.capitalize())
+ self.groups[name] = layer
+ layers['Faces'] .add(layer)
+
+ # Precomputed values for sine, cosine, and tangent of 30°.
+ # Code taken from http://jeroenhoek.nl/articles/svg-and-isometric-projection.html
+ rad_30 = radians(30)
+ cos_30 = cos(rad_30)
+ sin_30 = 0.5 # No point in using sin for 30°.
+
+ # Combined affine transformation matrices. The bottom row of these 3×3
+ # matrices is omitted; it is always [0, 0, 1].
+ self.face_map = {
+ # From 2D to isometric left-hand side view:
+ # * scale horizontally by cos(30°)
+ # * shear vertically by -30°
+ 'left': [cos_30, 0, 0,
+ sin_30, 1, 0],
+
+ # From 2D to isometric right-hand side view:
+ # * scale horizontally by cos(30°)
+ # * shear vertically by 30°
+ 'right': [cos_30, 0, 0,
+ -sin_30, 1, 0],
+
+ # From 2D to isometric top down view:
+ # * scale vertically by cos(30°)
+ # * shear horizontally by -30°
+ # * rotate clock-wise 30°
+ 'top': [cos_30, -cos_30, 0,
+ sin_30, sin_30, 0]
+ }
+
+ def get_isometric_transform_string(self, src_x, src_y, dest_x, dest_y, face_name):
+ m = self.face_map[face_name]
+ svg_matrix = "matrix(%f, %f, %f, %f, %f, %f)" % (m[0], m[3],
+ m[1], m[4],
+ m[2], m[5])
+
+ # SVG transforms are applied right to left, the code below means:
+ #
+ # 1. move the face center to (0,0) translating it by (-src_x, -src_y);
+ # 2. apply the isometric projection;
+ # 3. move it to the position in the cube.
+ transform = "translate(%f %f)" % (dest_x, dest_y)
+ transform += svg_matrix
+ transform += "translate(%f %f)" % (-src_x, -src_y)
+
+ return transform
+
+ def draw(self):
+ width, height = self.size
+ side = width / 4.5
+ x_border = (width - side * 3) / 4
+ y_border = 20
+
+ cube_center = width / 2, (height - y_border - side)
+
+ # Print some instructions
+ group = self.groups["Faces"]
+ font_size = (width / 20)
+ text_elem = self.text("", x=[0], y=[(height + font_size + y_border)])
+ text_elem["style"] = "font-size:%d;" % font_size
+ group.add(text_elem)
+
+ text = ["Edit the layers named 'Left', 'Right', 'Top'",
+ "and watch the changes projected onto the",
+ "isometric cube automatically and interactively."]
+ for i, line in enumerate(text):
+ line_elem = self.tspan(line, x=[0], dy=["%dem" % ((i > 0) * 1.2)])
+ text_elem.add(line_elem)
+
+ for i, face_name in enumerate(self.faces):
+ cx, cy = (x_border + side / 2 + (x_border + side) * i,
+ y_border + side / 2)
+ rx, ry = (cx - side / 2,
+ cy - side / 2)
+
+ # Draw some default content
+ group = self.groups["face-%s-content" % face_name]
+ rect = self.rect((rx, ry), (side, side))
+ color = [0, 0, 0]
+ color[i] = 255
+ rect['fill'] = svgwrite.utils.rgb(color[0], color[1], color[2], mode='RGB')
+ group.add(rect)
+
+ group = self.groups["Faces"]
+
+ # The radius of the hexagon representing the isometric cube is equal
+ # to the side of the original faces.
+ #
+ # And the centers of the projected faces fall in the middle of the
+ # radius, this is where the (side / 2) below comes from
+ cube_face_center = (cube_center[0] + side / 2 * sin(pi / 3 * (i * 2 - 1)),
+ cube_center[1] + side / 2 * cos(pi / 3 * (i * 2 - 1)))
+
+ # Reuse the content to draw the isometric cube
+ group = self.groups["Cube"]
+ content_href = "#face-%s-content" % face_name
+ ref = self.use(content_href)
+ svg_transform = self.get_isometric_transform_string(cx, cy,
+ cube_face_center[0],
+ cube_face_center[1],
+ face_name)
+ ref['transform'] = svg_transform
+ group.add(ref)
+
+
+def main():
+ svg = IsometricDrawing('inkscape-isometric-editor.svg', profile='full', size=(640, 480))
+ svg.draw()
+ svg.save()
+
+
+if __name__ == "__main__":
+ main()