#!/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()