Initial import
authorAntonio Ospite <ao2@ao2.it>
Mon, 2 Jul 2018 13:10:12 +0000 (15:10 +0200)
committerAntonio Ospite <ao2@ao2.it>
Mon, 2 Jul 2018 13:10:12 +0000 (15:10 +0200)
README [new file with mode: 0644]
svg_interactive_isometric_editor.py [new file with mode: 0755]

diff --git a/README b/README
new file mode 100644 (file)
index 0000000..7e3f959
--- /dev/null
+++ b/README
@@ -0,0 +1,9 @@
+An experiment about drawing an isometric cube that can be edited live in
+Inkscape.
+
+The code exploits the fact that Inkscape layers are just groups, so cloning
+a whole layer enables some interesting editing capabilities to Inkscape.
+
+Inspired by:
+    http://jeroenhoek.nl/articles/svg-and-isometric-projection.html
+    https://github.com/jdhoek/inkscape-isometric-projection
diff --git a/svg_interactive_isometric_editor.py b/svg_interactive_isometric_editor.py
new file mode 100755 (executable)
index 0000000..ea174f5
--- /dev/null
@@ -0,0 +1,231 @@
+#!/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()