From: Antonio Ospite Date: Mon, 2 Jul 2018 13:10:12 +0000 (+0200) Subject: Initial import X-Git-Url: https://git.ao2.it/experiments/svg_interactive_isometric_editor.git/commitdiff_plain/b33359b5b8a67bb0b3f7efbaffdb4023d0d839e1 Initial import --- b33359b5b8a67bb0b3f7efbaffdb4023d0d839e1 diff --git a/README b/README new file mode 100644 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 index 0000000..ea174f5 --- /dev/null +++ b/svg_interactive_isometric_editor.py @@ -0,0 +1,231 @@ +#!/usr/bin/env python3 +# +# Draw an isometric cube that can be edited live in Inkscape. +# +# Copyright (C) 2018 Antonio Ospite +# +# 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 . + +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()