Initial import
authorAntonio Ospite <ao2@ao2.it>
Mon, 29 Jan 2018 14:31:26 +0000 (15:31 +0100)
committerAntonio Ospite <ao2@ao2.it>
Tue, 30 Jan 2018 09:55:40 +0000 (10:55 +0100)
17 files changed:
.gitignore [new file with mode: 0644]
Makefile [new file with mode: 0644]
README.md [new file with mode: 0644]
TODO [new file with mode: 0644]
contrib/affine_composition.py [new file with mode: 0755]
src/cairo_hexaflexagon_template.py [new file with mode: 0755]
src/diagram/__init__.py [new file with mode: 0644]
src/diagram/cairo_diagram.py [new file with mode: 0755]
src/diagram/diagram.py [new file with mode: 0755]
src/diagram/gimp_diagram.py [new file with mode: 0755]
src/diagram/svgwrite_diagram.py [new file with mode: 0755]
src/flexagon/__init__.py [new file with mode: 0644]
src/flexagon/hexaflexagon_diagram.py [new file with mode: 0755]
src/flexagon/trihexaflexagon.py [new file with mode: 0755]
src/gimp_diagram_test.py [new file with mode: 0755]
src/gimp_hexaflexagon.py [new file with mode: 0755]
src/svg_hexaflexagon_editor.py [new file with mode: 0755]

diff --git a/.gitignore b/.gitignore
new file mode 100644 (file)
index 0000000..47072ae
--- /dev/null
@@ -0,0 +1,2 @@
+*.pyc
+hexaflexagon-template.svg
diff --git a/Makefile b/Makefile
new file mode 100644 (file)
index 0000000..da90469
--- /dev/null
+++ b/Makefile
@@ -0,0 +1,16 @@
+all:
+
+install_gimp_plugin:
+       ln -sf $(PWD)/src/gimp_hexaflexagon.py ~/.gimp-2.8/plug-ins/
+       ln -sf $(PWD)/src/gimp_diagram_test.py ~/.gimp-2.8/plug-ins/
+
+uninstall_gimp_plugin:
+       rm -f ~/.gimp-2.8/plug-ins/gimp_hexaflexagon.py
+       rm -f ~/.gimp-2.8/plug-ins/gimp_diagram_test.py
+
+pep8:
+       find . -name "*.py" -print0 | xargs -r0 pep8 --ignore=E501
+
+clean:
+       find . -name "*.pyc" -print0 | xargs -r0 rm
+       find . -type d -name "__pycache__" -print0 | xargs -r0 rm -r
diff --git a/README.md b/README.md
new file mode 100644 (file)
index 0000000..e86f5dc
--- /dev/null
+++ b/README.md
@@ -0,0 +1,39 @@
+# flexagon-toolkit
+
+flexagon-toolkit is a collection of tools to make it easier to build flexagons.
+
+
+## Draw a template
+
+To draw a template for a hexaflexagon execute:
+
+```
+$ ./src/cairo_hexaflexagon_template.py
+```
+
+And check out the generated `hexaflexagon-template.svg' file.
+
+
+## Gimp plugin
+
+To play with the GIMP plugin, install it with the following command:
+
+```
+$ make install_gimp_plugin 
+```
+
+Then launch Gimp and go to `Filters -> Render -> Hexaflexagon`.
+
+
+## Live SVG hexaflexagon editing in Inkscape
+
+Create a new base file with the following command:
+
+```
+$ ./src/svg_hexaflexagon_editor.py
+```
+
+Open the generated file `inkscape-hexaflexagon-editor.svg` in Inkscape and add
+the hexagon content to the layers named *Hexagon 1*, *Hexagon 2*, *Hexagon3*.
+
+See the hexaflexagon being composed automatically and interactively.
diff --git a/TODO b/TODO
new file mode 100644 (file)
index 0000000..34da8b0
--- /dev/null
+++ b/TODO
@@ -0,0 +1,4 @@
+- Draw also a folding template for the flexagon.
+- Complete the implementation of the diagram backends.
+- Revisit the design of the Diagram abstraction, and maybe split it to
+  a separate project.
diff --git a/contrib/affine_composition.py b/contrib/affine_composition.py
new file mode 100755 (executable)
index 0000000..8f8db2e
--- /dev/null
@@ -0,0 +1,31 @@
+#!/usr/bin/env python
+
+from affine import Affine
+from math import sin, cos, radians
+
+src_x = 10
+src_y = 10
+
+angle = 30
+
+dest_x = 20
+dest_y = 30
+
+# Some transform to compose
+T1 = Affine.translation(-src_x, -src_y)
+R = Affine.rotation(angle)
+T2 = Affine.translation(dest_x, dest_y)
+
+# Composition is performed by multiplying from right to left
+matrix = T2 * R * T1
+print [item for item in matrix]
+
+theta = radians(angle)
+
+# This is the equivalent transformation matrix
+matrix = [
+    cos(theta), -sin(theta), -src_x * cos(theta) + src_y * sin(theta) + dest_x,
+    sin(theta),  cos(theta), -src_x * sin(theta) - src_y * cos(theta) + dest_y,
+           0.0,                              0.0,                          1.0
+]
+print matrix
diff --git a/src/cairo_hexaflexagon_template.py b/src/cairo_hexaflexagon_template.py
new file mode 100755 (executable)
index 0000000..ce44ca9
--- /dev/null
@@ -0,0 +1,43 @@
+#!/usr/bin/env python
+#
+# Draw a hexaflexagon template with cairo.
+#
+# 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 diagram.cairo_diagram import CairoDiagram
+from flexagon import HexaflexagonDiagram
+
+
+def draw_cairo_template():
+    # A4 page at 300dpi is 3508x2480 in pixels but cairo expects units to be in
+    # points, so adjust the values.
+    width = 3508 / 1.25
+    height = 2480 / 1.25
+
+    x_border = width / 50
+    font_size = width / 80
+    stroke_width = width / 480
+
+    cairo_backend = CairoDiagram(width, height, font_size=font_size, stroke_width=stroke_width)
+    hexaflexagon = HexaflexagonDiagram(x_border, backend=cairo_backend)
+
+    cairo_backend.clear()
+    hexaflexagon.draw_template()
+    cairo_backend.save_svg('hexaflexagon-template')
+
+
+if __name__ == '__main__':
+    draw_cairo_template()
diff --git a/src/diagram/__init__.py b/src/diagram/__init__.py
new file mode 100644 (file)
index 0000000..e69de29
diff --git a/src/diagram/cairo_diagram.py b/src/diagram/cairo_diagram.py
new file mode 100755 (executable)
index 0000000..9aab30e
--- /dev/null
@@ -0,0 +1,308 @@
+#!/usr/bin/env python
+#
+# A Diagram abstraction based on Cairo
+#
+# 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
+import cairo
+try:
+    from .diagram import Diagram
+except ValueError:
+    from diagram import Diagram
+
+
+class CairoDiagram(Diagram):
+    def __init__(self, width, height, **kwargs):
+        super(CairoDiagram, self).__init__(width, height, **kwargs)
+
+        self.surface = cairo.RecordingSurface(0, (0, 0, width, height))
+        self.cr = cr = cairo.Context(self.surface)
+
+        cr.select_font_face("Georgia", cairo.FONT_SLANT_NORMAL,
+                            cairo.FONT_WEIGHT_NORMAL)
+        cr.set_font_size(self.font_size)
+
+        cr.set_line_width(self.stroke_width)
+        cr.set_line_join(cairo.LINE_JOIN_ROUND)
+
+    def clear(self):
+        cr = self.cr
+
+        r, g, b, a = self.color_to_rgba(self.background)
+        cr.set_source_rgba(r, g, b, a)
+        cr.paint()
+
+    def save_svg(self, filename):
+        surface = cairo.SVGSurface(filename + '.svg', self.width, self.height)
+        # TODO: call surface.set_document_unit() to set units to pixels
+        cr = cairo.Context(surface)
+        cr.set_source_surface(self.surface, 0, 0)
+        cr.paint()
+
+    def save_png(self, filename):
+        surface = cairo.ImageSurface(cairo.FORMAT_ARGB32,
+                                     self.width, self.height)
+        cr = cairo.Context(surface)
+        cr.set_source_surface(self.surface, 0, 0)
+        cr.paint()
+        surface.write_to_png(filename + '.png')
+
+    def show(self):
+        from PIL import Image
+        from io import BytesIO
+        f = BytesIO()
+        surface = cairo.ImageSurface(cairo.FORMAT_ARGB32,
+                                     self.width, self.height)
+        cr = cairo.Context(surface)
+        cr.set_source_surface(self.surface, 0, 0)
+        cr.paint()
+        surface.write_to_png(f)
+        f.seek(0)
+        im = Image.open(f)
+        im.show()
+
+    def _draw_polygon(self, verts):
+        cr = self.cr
+
+        v = verts[0]
+        cr.move_to(v[0], v[1])
+        for v in verts[1:]:
+            cr.line_to(v[0], v[1])
+        cr.close_path()
+
+    def _fill(self, fill_color, preserve=False):
+        if fill_color:
+            cr = self.cr
+            r, g, b, a = self.color_to_rgba(fill_color)
+            cr.set_source_rgba(r, g, b, a)
+            if preserve:
+                cr.fill_preserve()
+            else:
+                cr.fill()
+
+    def _stroke(self, stroke_color, preserve=False):
+        if stroke_color:
+            cr = self.cr
+            r, g, b, a = self.color_to_rgba(stroke_color)
+            cr.set_source_rgba(r, g, b, a)
+            if preserve:
+                cr.stroke_preserve()
+            else:
+                cr.stroke()
+
+    def draw_polygon_by_verts(self, verts,
+                              stroke_color=(0, 0, 0),
+                              fill_color=None):
+        cr = self.cr
+
+        cr.save()
+        self._draw_polygon(verts)
+        self._fill(fill_color, preserve=True)
+        self._stroke(stroke_color)
+        cr.restore()
+
+    def draw_star_by_verts(self, cx, cy, verts, stroke_color=(0, 0, 0)):
+        cr = self.cr
+
+        for v in verts:
+            cr.move_to(cx, cy)
+            cr.line_to(v[0], v[1])
+
+        self._stroke(stroke_color)
+
+    def draw_circle(self, cx, cy, radius=10.0,
+                    stroke_color=None,
+                    fill_color=(0, 0, 0, 0.5)):
+        cr = self.cr
+
+        cr.save()
+
+        cr.arc(cx, cy, radius, 0, 2 * pi)
+        self._fill(fill_color, preserve=True)
+        self._stroke(stroke_color)
+
+        cr.restore()
+
+    def draw_line(self, x1, y1, x2, y2, stroke_color=(0, 0, 0, 1)):
+        cr = self.cr
+        cr.move_to(x1, y1)
+        cr.line_to(x2, y2)
+        self._stroke(stroke_color)
+
+    def draw_rect_from_center(self, cx, cy, width, height, theta=0.0,
+                              stroke_color=None,
+                              fill_color=(1, 1, 1, 0.8)):
+        # the position of the center of a rectangle at (0,0)
+        mx = width / 2.0
+        my = height / 2.0
+
+        # calculate the position of the bottom-left corner after rotating the
+        # rectangle around the center
+        rx = cx - (mx * cos(theta) - my * sin(theta))
+        ry = cy - (mx * sin(theta) + my * cos(theta))
+
+        self.draw_rect(rx, ry, width, height, theta, stroke_color, fill_color)
+
+    def draw_rect(self, x, y, width, height, theta=0,
+                  stroke_color=None,
+                  fill_color=(1, 1, 1, 0.8)):
+        cr = self.cr
+
+        cr.save()
+        cr.translate(x, y)
+        cr.rotate(theta)
+
+        cr.rectangle(0, 0, width, height)
+        self._fill(fill_color, preserve=True)
+        self._stroke(stroke_color)
+
+        cr.restore()
+
+    def draw_centered_text(self, cx, cy, text, theta=0.0,
+                           color=(0, 0, 0),
+                           align_baseline=False,
+                           bb_stroke_color=None,
+                           bb_fill_color=None):
+        cr = self.cr
+
+        x_bearing, y_bearing, width, height, x_advance = cr.text_extents(text)[:5]
+        ascent, descent = cr.font_extents()[:2]
+
+        # The offset of the lower-left corner of the text.
+        tx = width / 2.0 + x_bearing
+
+        if align_baseline:
+            # When aligning to the  baseline it is convenient the make the
+            # bounding box depend on the font vertical extent and not from the
+            # text content.
+            ty = 0
+            bb = [0, descent, width, -ascent]
+        else:
+            ty = height / 2.0 + y_bearing
+            bb = [0, y_bearing, width, height]
+
+        # The coordinate of the lower-left corner of the rotated rectangle
+        rx = cx - tx * cos(theta) + ty * sin(theta)
+        ry = cy - tx * sin(theta) - ty * cos(theta)
+
+        cr.save()
+        cr.translate(rx, ry)
+        cr.rotate(theta)
+
+        if bb_stroke_color or bb_fill_color:
+            self.draw_rect(bb[0], bb[1], bb[2], bb[3], 0,
+                           bb_stroke_color,
+                           bb_fill_color)
+
+        r, g, b, a = self.color_to_rgba(color)
+        cr.set_source_rgba(r, g, b, a)
+
+        cr.move_to(0, 0)
+        cr.show_text(text)
+
+        cr.restore()
+
+        return x_advance
+
+
+def test():
+    diagram = CairoDiagram(400, 400)
+
+    diagram.clear()
+
+    x = 40
+    y = 200
+
+    x_offset = x
+
+    theta = 0
+
+    diagram.draw_line(0, y, 400, y, (1, 0, 0, 1))
+
+    advance = diagram.draw_centered_text(x_offset, y, "Ciao", theta,
+                                         align_baseline=True,
+                                         bb_stroke_color=(0, 0, 0, 0.5),
+                                         bb_fill_color=(1, 1, 1, 0.8))
+    x_offset += advance
+
+    advance = diagram.draw_centered_text(x_offset, y, "____", theta + pi / 4,
+                                         align_baseline=True,
+                                         bb_stroke_color=(0, 0, 0, 0.5),
+                                         bb_fill_color=(1, 1, 1, 0.8))
+    x_offset += advance
+
+    advance = diagram.draw_centered_text(x_offset, y, "jxpqdlf", theta + pi / 2,
+                                         align_baseline=True,
+                                         bb_stroke_color=(0, 0, 0, 0.5),
+                                         bb_fill_color=(1, 1, 1, 0.8))
+    x_offset += advance
+
+    advance = diagram.draw_centered_text(x_offset, y, "pppp", theta + 3 * pi / 4,
+                                         align_baseline=True,
+                                         bb_stroke_color=(0, 0, 0, 0.5),
+                                         bb_fill_color=(1, 1, 1, 0.8))
+    x_offset += advance
+
+    advance = diagram.draw_centered_text(x_offset, y, "dddd", theta + pi,
+                                         align_baseline=True,
+                                         bb_stroke_color=(0, 0, 0, 0.5),
+                                         bb_fill_color=(1, 1, 1, 0.8))
+    x_offset += advance
+
+    advance = diagram.draw_centered_text(x_offset, y, "Jjjj", theta + 5 * pi / 4,
+                                         align_baseline=True,
+                                         bb_stroke_color=(0, 0, 0, 0.5),
+                                         bb_fill_color=(1, 1, 1, 0.8))
+    x_offset += advance
+
+    advance = diagram.draw_centered_text(x_offset, y, "1369", theta + 3 * pi / 2,
+                                         color=(0, 1, 0),
+                                         align_baseline=True,
+                                         bb_stroke_color=(0, 0, 0, 0.5),
+                                         bb_fill_color=(1, 1, 1, 0.8))
+    x_offset += advance
+
+    advance = diagram.draw_centered_text(x_offset, y, "qqqq", theta + 7 * pi / 4,
+                                         align_baseline=True,
+                                         bb_stroke_color=(0, 0, 0, 0.5),
+                                         bb_fill_color=(1, 1, 1, 0.8))
+    x_offset += advance
+
+    diagram.draw_rect(40, 40, 300, 100, stroke_color=(0, 0, 0, 0.8))
+    diagram.draw_rect(40, 40, 300, 100, pi / 30, stroke_color=(0, 0, 0, 0.8))
+
+    verts = diagram.draw_regular_polygon(190, 90, 3, 20)
+
+    diagram.draw_rect(40, 250, 300, 100, stroke_color=(0, 0, 0, 0.8))
+    diagram.draw_rect_from_center(40 + 150, 250 + 50, 300, 100, theta=(pi / 40),
+                                  stroke_color=(1, 0, 0),
+                                  fill_color=None)
+
+    verts = diagram.draw_regular_polygon(190, 300, 6, 20, pi / 3., (0, 0, 1, 0.5), (0, 1, 0.5))
+    diagram.draw_apothem_star(190, 300, 6, 20, 0, (1, 0, 1))
+
+    diagram.draw_star_by_verts(190, 300, verts, (1, 0, 0, 0.5))
+    diagram.draw_star(190, 300, 6, 25, 0, (1, 0, 1, 0.2))
+
+    diagram.draw_circle(190, 300, 30, (0, 1, 0, 0.5), None)
+    diagram.draw_circle(100, 300, 30, (1, 0, 0, 0.5), (0, 1, 1, 0.5))
+
+    diagram.show()
+
+
+if __name__ == "__main__":
+    test()
diff --git a/src/diagram/diagram.py b/src/diagram/diagram.py
new file mode 100755 (executable)
index 0000000..eee9fc6
--- /dev/null
@@ -0,0 +1,88 @@
+#!/usr/bin/env python
+#
+# A Diagram base class
+#
+# 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, fmod
+
+
+class Diagram(object):
+    def __init__(self, width, height, background=(1, 1, 1), font_size=20, stroke_width=2):
+        self.width = width
+        self.height = height
+        self.background = background
+        self.font_size = font_size
+        self.stroke_width = stroke_width
+
+    def clear(self):
+        raise NotImplementedError
+
+    @staticmethod
+    def color_to_rgba(color):
+        assert len(color) >= 3
+
+        color = tuple(float(c) for c in color)
+        if len(color) == 3:
+            color += (1.0,)
+
+        return color
+
+    @staticmethod
+    def normalized_angle_01(theta):
+        return fmod(theta, 2 * pi) / (2 * pi)
+
+    @staticmethod
+    def get_regular_polygon(x, y, sides, r, theta0=0.0):
+        """Calc the coordinates of the regular polygon.
+
+        NOTE: the first point will be in axis with y."""
+        theta = 2 * pi / sides
+
+        verts = []
+        for i in range(sides):
+            px = x + r * sin(theta0 + i * theta)
+            py = y + r * cos(theta0 + i * theta)
+            verts.append((px, py))
+
+        return verts
+
+    def draw_polygon_by_verts(self, verts,
+                              stroke_color=(0, 0, 0),
+                              fill_color=None):
+        raise NotImplementedError
+
+    def draw_regular_polygon(self, cx, cy, sides, r, theta=0.0,
+                             stroke_color=(0, 0, 0),
+                             fill_color=None):
+        verts = self.get_regular_polygon(cx, cy, sides, r, theta)
+        self.draw_polygon_by_verts(verts, stroke_color, fill_color)
+        return verts
+
+    def draw_star_by_verts(self, cx, cy, verts, stroke_color=(0, 0, 0)):
+        raise NotImplementedError
+
+    def draw_star(self, cx, cy, sides, r, theta=0.0, stroke_color=(0, 0, 0)):
+        verts = self.get_regular_polygon(cx, cy, sides, r, theta)
+        self.draw_star_by_verts(cx, cy, verts, stroke_color)
+        return verts
+
+    def draw_apothem_star(self, cx, cy, sides, r, theta=0.0, stroke_color=(0, 0, 0)):
+        """Draw a star but calculate the regular polygon apothem from the passed radius."""
+        apothem = r * cos(pi / sides)
+        apothem_angle = theta + pi / sides
+
+        return self.draw_star(cx, cy, sides, apothem, apothem_angle, stroke_color)
diff --git a/src/diagram/gimp_diagram.py b/src/diagram/gimp_diagram.py
new file mode 100755 (executable)
index 0000000..30dcbb5
--- /dev/null
@@ -0,0 +1,163 @@
+#!/usr/bin/env python
+#
+# A diagram class to draw in Gimp
+#
+# 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/>.
+
+import warnings
+from gimpfu import *
+
+from .diagram import Diagram
+
+
+class GimpDiagram(Diagram):
+    def __init__(self, width, height, image, layer, **kwargs):
+        super(GimpDiagram, self).__init__(width, height, **kwargs)
+
+        self.image = image
+        self.drawable = layer
+
+    def color_to_rgba(self, color):
+        color = super(GimpDiagram, self).color_to_rgba(color)
+        if color[3] != 1.0:
+            warnings.warn("Warning, trasparent colors are not supported", stacklevel=4)
+
+        return color
+
+    def clear(self):
+        pass
+
+    def _polygon_path(self, verts):
+        path = pdb.gimp_vectors_new(self.image, "")
+
+        v0 = verts[0]
+        strokeid = pdb.gimp_vectors_bezier_stroke_new_moveto(path,
+                                                             v0[0], v0[1])
+        for v in verts[1:]:
+            pdb.gimp_vectors_bezier_stroke_lineto(path, strokeid, v[0], v[1])
+
+        pdb.gimp_vectors_stroke_close(path, strokeid)
+
+        return path
+
+    def _fill_path(self, path, fill_color):
+        if fill_color:
+            orig_foreground = pdb.gimp_context_get_foreground()
+            orig_selection = pdb.gimp_selection_save(self.image)
+
+            color = self.color_to_rgba(fill_color)
+            pdb.gimp_context_set_foreground(color)
+
+            pdb.gimp_image_select_item(self.image, CHANNEL_OP_REPLACE, path)
+
+            pdb.gimp_edit_fill(self.drawable, FOREGROUND_FILL)
+
+            pdb.gimp_selection_load(orig_selection)
+            pdb.gimp_image_remove_channel(self.image, orig_selection)
+            pdb.gimp_context_set_foreground(orig_foreground)
+
+    def _stroke_path(self, path, stroke_color):
+        if stroke_color:
+            orig_paint_method = pdb.gimp_context_get_paint_method()
+            orig_foreground = pdb.gimp_context_get_foreground()
+            orig_brush = pdb.gimp_context_get_brush()
+
+            pdb.gimp_context_set_paint_method('gimp-paintbrush')
+
+            color = self.color_to_rgba(stroke_color)
+            pdb.gimp_context_set_foreground(color)
+            pdb.gimp_context_set_brush("1. Pixel")
+            pdb.gimp_context_set_brush_size(self.stroke_width)
+
+            pdb.gimp_edit_stroke_vectors(self.drawable, path)
+
+            pdb.gimp_context_set_brush(orig_brush)
+            pdb.gimp_context_set_foreground(orig_foreground)
+            pdb.gimp_context_set_paint_method(orig_paint_method)
+
+    def draw_star_by_verts(self, cx, cy, verts, stroke_color=(0, 0, 0)):
+        path = pdb.gimp_vectors_new(self.image, "")
+
+        for v in verts:
+            strokeid = pdb.gimp_vectors_bezier_stroke_new_moveto(path, cx, cy)
+            pdb.gimp_vectors_bezier_stroke_lineto(path, strokeid, v[0], v[1])
+
+        pdb.gimp_image_insert_vectors(self.image, path, None, -1)
+        pdb.gimp_image_set_active_vectors(self.image, path)
+
+        self._stroke_path(path, stroke_color)
+
+        pdb.gimp_image_remove_vectors(self.image, path)
+
+    def draw_polygon_by_verts(self, verts,
+                              stroke_color=(0, 0, 0),
+                              fill_color=None):
+        path = self._polygon_path(verts)
+
+        pdb.gimp_image_insert_vectors(self.image, path, None, -1)
+        pdb.gimp_image_set_active_vectors(self.image, path)
+
+        self._fill_path(path, fill_color)
+        self._stroke_path(path, stroke_color)
+
+        pdb.gimp_image_remove_vectors(self.image, path)
+
+    def copy_polygon(self, src_drawable, verts, dest_drawable, matrix):
+        # flatten the verts list to be accepted by gimp_image_select_polygon()
+        segs = [coord for v in verts for coord in v]
+        pdb.gimp_image_select_polygon(self.image, CHANNEL_OP_REPLACE, len(segs), segs)
+
+        pdb.gimp_edit_copy(src_drawable)
+        floating_layer = pdb.gimp_edit_paste(dest_drawable, FALSE)
+
+        pdb.gimp_item_transform_matrix(floating_layer,
+                                       matrix[0], matrix[1], matrix[2],
+                                       matrix[3], matrix[4], matrix[5],
+                                       matrix[6], matrix[7], matrix[8])
+
+        pdb.gimp_floating_sel_anchor(floating_layer)
+
+    def draw_centered_text(self, cx, cy, text, theta, color,
+                           align_baseline=False,
+                           bb_stroke_color=None,
+                           bb_fill_color=None):
+        font_name = "Georgia"
+
+        width, height, ascent, descent = pdb.gimp_text_get_extents_fontname(text,
+                                                                            self.font_size,
+                                                                            PIXELS,
+                                                                            font_name)
+
+        tx = cx - width / 2.0
+        ty = cy - height / 2.0
+
+        floating_selection = pdb.gimp_text_fontname(self.image, self.drawable,
+                                                    tx, ty, text, 0, True,
+                                                    self.font_size,
+                                                    PIXELS,
+                                                    font_name)
+
+        text_color = self.color_to_rgba(color)
+        pdb.gimp_text_layer_set_color(floating_selection, text_color)
+
+        pdb.gimp_item_transform_rotate(floating_selection, theta, FALSE, cx, cy)
+        pdb.gimp_floating_sel_anchor(floating_selection)
+
+        if align_baseline:
+            warnings.warn("The align_baseline option has not been implemented yet.")
+
+        if bb_stroke_color or bb_fill_color:
+            warnings.warn("Drawing the bounding box has not been implemented yet.")
diff --git a/src/diagram/svgwrite_diagram.py b/src/diagram/svgwrite_diagram.py
new file mode 100755 (executable)
index 0000000..bf94541
--- /dev/null
@@ -0,0 +1,150 @@
+#!/usr/bin/env python
+#
+# A Diagram abstraction based on svgwrite
+#
+# 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/>.
+
+import warnings
+from math import degrees
+import svgwrite
+from svgwrite.data.types import SVGAttribute
+from .diagram import Diagram
+
+
+class InkscapeDrawing(svgwrite.Drawing):
+    """An svgwrite.Drawing subclass which supports Inkscape layers"""
+    def __init__(self, *args, **kwargs):
+        super(InkscapeDrawing, self).__init__(*args, **kwargs)
+
+        inkscape_attributes = {
+            'xmlns:inkscape': SVGAttribute('xmlns:inkscape',
+                                           anim=False,
+                                           types=[],
+                                           const=frozenset(['http://www.inkscape.org/namespaces/inkscape'])),
+            'inkscape:groupmode': SVGAttribute('inkscape:groupmode',
+                                               anim=False,
+                                               types=[],
+                                               const=frozenset(['layer'])),
+            'inkscape:label': SVGAttribute('inkscape:label',
+                                           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')
+        elements['svg'].valid_attributes = frozenset(svg_attributes)
+
+        g_attributes = set(elements['g'].valid_attributes)
+        g_attributes.add('inkscape:groupmode')
+        g_attributes.add('inkscape:label')
+        elements['g'].valid_attributes = frozenset(g_attributes)
+
+        self['xmlns:inkscape'] = 'http://www.inkscape.org/namespaces/inkscape'
+
+    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 SvgwriteDiagram(Diagram):
+    def __init__(self, width, height, **kwargs):
+        super(SvgwriteDiagram, self).__init__(width, height, **kwargs)
+
+        self.svg = InkscapeDrawing(None, profile='full', size=(str(width) + "px", str(height) + "px"))
+        self.active_group = self.svg
+
+    def save_svg(self, filename):
+        self.svg.saveas(filename)
+
+    def add(self, element):
+        self.active_group.add(element)
+
+    def color_to_rgba(self, color):
+        color = super(SvgwriteDiagram, self).color_to_rgba(color)
+
+        return color[0] * 255, color[1] * 255, color[2] * 255, color[3]
+
+    def _fill(self, element, fill_color):
+        if fill_color:
+            r, g, b, a = self.color_to_rgba(fill_color)
+            fill_color = svgwrite.utils.rgb(r, g, b, mode='RGB')
+            element['fill'] = fill_color
+            element['fill-opacity'] = a
+        else:
+            element['fill'] = 'none'
+
+    def _stroke(self, element, stroke_color):
+        if stroke_color:
+            r, g, b, a = self.color_to_rgba(stroke_color)
+            stroke_color = svgwrite.utils.rgb(r, g, b, mode='RGB')
+            element['stroke'] = stroke_color
+            element['stroke-opacity'] = a
+            element['stroke-linejoin'] = 'round'
+        else:
+            element['stroke'] = 'none'
+
+    def draw_polygon_by_verts(self, verts,
+                              stroke_color=(0, 0, 0),
+                              fill_color=None):
+        polygon = self.svg.polygon(verts, stroke_width=self.stroke_width)
+
+        self._fill(polygon, fill_color)
+        self._stroke(polygon, stroke_color)
+
+        self.add(polygon)
+
+    def draw_star_by_verts(self, cx, cy, verts, stroke_color=(0, 0, 0)):
+        for v in verts:
+            line = self.svg.line((cx, cy), v, stroke_width=self.stroke_width)
+            self._stroke(line, stroke_color)
+            self.add(line)
+
+    def draw_centered_text(self, cx, cy, text, theta=0.0,
+                           color=(0, 0, 0),
+                           align_baseline=False,
+                           bb_stroke_color=None,
+                           bb_fill_color=None):
+
+        # Using font_size to calculate dy is not optimal as the font _height_ may
+        # be different from the font_size, but it's better than nothing.
+        text_element = self.svg.text(text, x=[cx], y=[cy], dy=[self.font_size / 2.])
+        self._fill(text_element, color)
+        text_element['font-size'] = self.font_size
+        text_element['text-anchor'] = 'middle'
+        text_element['transform'] = 'rotate(%f, %f, %f)' % (degrees(theta), cx, cy)
+        self.add(text_element)
+
+        if align_baseline:
+            warnings.warn("The align_baseline option has not been implemented yet.")
+
+        if bb_stroke_color or bb_fill_color:
+            warnings.warn("Drawing the bounding box has not been implemented yet.")
diff --git a/src/flexagon/__init__.py b/src/flexagon/__init__.py
new file mode 100644 (file)
index 0000000..a4951d8
--- /dev/null
@@ -0,0 +1,3 @@
+#!/usr/bin/env python
+
+from .hexaflexagon_diagram import HexaflexagonDiagram
diff --git a/src/flexagon/hexaflexagon_diagram.py b/src/flexagon/hexaflexagon_diagram.py
new file mode 100755 (executable)
index 0000000..b7e50fb
--- /dev/null
@@ -0,0 +1,188 @@
+#!/usr/bin/env python
+#
+# An class to draw hexaflexagons
+#
+# 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 sin, cos, pi
+from .trihexaflexagon import TriHexaflexagon
+
+
+class HexaflexagonDiagram(object):
+    def __init__(self, x_border, backend=None):
+        self.x_border = x_border
+        self.backend = backend
+
+        self.hexaflexagon = TriHexaflexagon()
+
+        num_hexagons = len(self.hexaflexagon.hexagons)
+        self.hexagon_radius = (self.backend.width - (x_border * (num_hexagons + 1))) / (num_hexagons * 2)
+
+        # The hexagon apothem is the triangle height
+        hexagon_apothem = self.hexagon_radius * cos(pi / 6.)
+
+        # the triangle radius is 2/3 of its height
+        self.triangle_radius = hexagon_apothem * 2. / 3.
+
+        self._init_centers()
+
+        # draw the plan centered wrt. the hexagons
+        self.plan_origin = (self.x_border * 2. + self.hexagon_radius / 2.,
+                            self.x_border + self.triangle_radius / 3.)
+
+        self.hexagons_color_map = [(1, 0, 0), (0, 1, 0), (0, 0, 1)]
+
+    def _init_centers(self):
+        # Preallocate the lists to be able to access them by indices in the
+        # loops below.
+        self.hexagons_centers = [None for h in self.hexaflexagon.hexagons]
+        self.triangles_centers = [[None for t in h.triangles] for h in self.hexaflexagon.hexagons]
+
+        cy = self.backend.height - (self.hexagon_radius + self.x_border)
+        for hexagon in self.hexaflexagon.hexagons:
+            cx = self.x_border + self.hexagon_radius + (2 * self.hexagon_radius + self.x_border) * hexagon.index
+            self.hexagons_centers[hexagon.index] = (cx, cy)
+
+            triangles_centers = self.backend.get_regular_polygon(cx, cy, 6, self.triangle_radius)
+            for triangle in hexagon.triangles:
+                self.triangles_centers[hexagon.index][triangle.index] = triangles_centers[triangle.index]
+
+    def get_hexagon_center(self, hexagon):
+        return self.hexagons_centers[hexagon.index]
+
+    def get_triangle_center(self, triangle):
+        return self.triangles_centers[triangle.hexagon.index][triangle.index]
+
+    def get_triangle_center_in_plan(self, triangle):
+        x0, y0 = self.plan_origin
+        i, j = self.hexaflexagon.get_triangle_plan_position(triangle)
+        x, y = triangle.calc_plan_coordinates(self.triangle_radius, i, j)
+        return x0 + x, y0 + y
+
+    def get_triangle_verts(self, triangle):
+        cx, cy = self.get_triangle_center(triangle)
+        theta = triangle.get_angle_in_hexagon()
+        verts = self.backend.get_regular_polygon(cx, cy, 3, self.triangle_radius, theta)
+        return verts
+
+    def get_triangle_transform(self, triangle):
+        """Calculate the transformation matrix from a triangle in an hexagon to
+        the correspondent triangle in the plan.
+
+        Return the matrix as a list of values sorted in row-major order."""
+
+        src_x, src_y = self.get_triangle_center(triangle)
+        dest_x, dest_y = self.get_triangle_center_in_plan(triangle)
+        theta = triangle.get_angle_in_plan_relative_to_hexagon()
+
+        # The transformation from a triangle in the hexagon to the correspondent
+        # triangle in the plan is composed by these steps:
+        #
+        #   1. rotate by 'theta' around (src_x, src_y);
+        #   2. move to (dest_x, dest_y).
+        #
+        # Step 1 can be expressed by these sub-steps:
+        #
+        #  1a. translate by (-src_x, -src_y)
+        #  1b. rotate by 'theta'
+        #  1c. translate by (src_x, src_y)
+        #
+        # Step 2. can be expressed by a translation like:
+        #
+        #  2a. translate by (dest_x - src_x, dest_y - src_y)
+        #
+        # The consecutive translations 1c and 2a can be easily combined, so
+        # the final steps are:
+        #
+        #  T1 -> translate by (-src_x, -src_y)
+        #  R  -> rotate by 'theta'
+        #  T2 -> translate by (dest_x, dest_y)
+        #
+        # Using affine transformations these are expressed as:
+        #
+        #      | 1  0  -src_x |
+        # T1 = | 0  1  -src_y |
+        #      | 0  0       1 |
+        #
+        #      | cos(theta)  -sin(theta)  0 |
+        # R  = | sin(theta)   con(theta)  0 |
+        #      |          0            0  1 |
+        #
+        #      | 1  0  dest_x |
+        # T2 = | 0  1  dest_y |
+        #      | 0  0       1 |
+        #
+        # Composing these transformations into one is achieved by multiplying
+        # the matrices from right to left:
+        #
+        #   T = T2 * R * T1
+        #
+        # NOTE: To remember this think about composing functions: T2(R(T1())),
+        # the inner one is performed first.
+        #
+        # The resulting  T matrix is the one below.
+        matrix = [
+            cos(theta), -sin(theta), -src_x * cos(theta) + src_y * sin(theta) + dest_x,
+            sin(theta),  cos(theta), -src_x * sin(theta) - src_y * cos(theta) + dest_y,
+                     0,           0,                                                 1
+        ]
+
+        return matrix
+
+    def draw_hexagon_template(self, hexagon):
+        for triangle in hexagon.triangles:
+            cx, cy = self.get_triangle_center(triangle)
+            theta = triangle.get_angle_in_hexagon()
+            self.draw_triangle_template(triangle, cx, cy, theta)
+
+    def draw_triangle_template(self, triangle, cx, cy, theta):
+        radius = self.triangle_radius
+        color = self.hexagons_color_map[triangle.hexagon.index]
+
+        tverts = self.backend.draw_regular_polygon(cx, cy, 3, radius, theta, color)
+
+        self.backend.draw_apothem_star(cx, cy, 3, radius, theta, color)
+
+        # Because of how draw_regular_polygon() is implemented, triangles are
+        # drawn by default with the base on the top, so the text need to be
+        # rotated by 180 to look like it is in the same orientation as
+        # a triangle with the base on the bottom.
+        text_theta = pi - theta
+
+        # Draw the text closer to the vertices of the element
+        t = 0.3
+
+        corners_labels = "ABC"
+        for i, v in enumerate(tverts):
+            tx = (1 - t) * v[0] + t * cx
+            ty = (1 - t) * v[1] + t * cy
+            corner_text = str(triangle.index + 1) + corners_labels[i]
+            self.backend.draw_centered_text(tx, ty, corner_text, text_theta, color)
+
+    def draw_plan_template(self):
+        x0, y0 = self.plan_origin
+        for hexagon in self.hexaflexagon.hexagons:
+            for triangle in hexagon.triangles:
+                i, j = self.hexaflexagon.get_triangle_plan_position(triangle)
+                x, y = triangle.calc_plan_coordinates(self.triangle_radius, i, j)
+                theta = triangle.get_angle_in_plan()
+                self.draw_triangle_template(triangle, x0 + x, y0 + y, theta)
+
+    def draw_template(self):
+        for hexagon in self.hexaflexagon.hexagons:
+            self.draw_hexagon_template(hexagon)
+
+        self.draw_plan_template()
diff --git a/src/flexagon/trihexaflexagon.py b/src/flexagon/trihexaflexagon.py
new file mode 100755 (executable)
index 0000000..d79875e
--- /dev/null
@@ -0,0 +1,132 @@
+#!/usr/bin/env python
+#
+# A generic model for a tri-hexaflexagon
+#
+# 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
+
+
+class Triangle(object):
+    def __init__(self, hexagon, index):
+        self.hexagon = hexagon
+        self.index = index
+
+    @staticmethod
+    def calc_plan_coordinates(radius, i, j):
+        apothem = radius * cos(pi / 3.)
+        side = 2. * radius * sin(pi / 3.)
+        width = side
+        height = apothem * 3.
+
+        xoffset = (j + 1) * width / 2.
+        yoffset = (i + (((i + j + 1) % 2) + 1) / 3.) * height
+
+        return xoffset, yoffset
+
+    def get_angle_in_plan(self):
+        """The angle of a triangle in the hexaflexagon plan."""
+        return - ((self.index + 1) % 2) * pi / 3.
+
+    def get_angle_in_plan_relative_to_hexagon(self):
+        """"Get the angle of the triangle in the plan relative to the rotation
+        of the same triangle in the hexagon."""
+        return ((self.index + 4) % 6 // 2) * pi * 2. / 3.
+
+    def get_angle_in_hexagon(self):
+        """Get the angle of the triangle in the hexagons.
+
+        NOTE: the angle is rotated by pi to have the first triangle with the
+        base on the bottom."""
+        return pi + self.index * pi / 3.
+
+    def __str__(self):
+        return "%d,%d" % (self.hexagon.index, self.index)
+
+
+class Hexagon(object):
+    def __init__(self, index):
+        self.index = index
+        self.triangles = []
+        for i in range(6):
+            triangle = Triangle(self, i)
+            self.triangles.append(triangle)
+
+    def __str__(self):
+        output = ""
+        for i in range(0, 6):
+            output += str(self.triangles[i])
+            output += "\t"
+
+        return output
+
+
+class TriHexaflexagon(object):
+    def __init__(self):
+        self.hexagons = []
+        for i in range(0, 3):
+            hexagon = Hexagon(i)
+            self.hexagons.append(hexagon)
+
+        # A plan is described by a mapping of the triangles in the hexagons,
+        # repositioned on a 2d grid.
+        #
+        # In the map below, the grid has two rows, each element of the grid is
+        # a pair (h, t), where 'h' is the index of the hexagon, and 't' is the
+        # index of the triangle in that hexagon.
+        plan_map = [
+            [(0, 0), (1, 5), (1, 4), (2, 3), (2, 2), (0, 3), (0, 2), (1, 1), (1, 0)],
+            [(2, 5), (2, 4), (0, 5), (0, 4), (1, 3), (1, 2), (2, 1), (2, 0), (0, 1)]
+        ]
+
+        # Preallocate a bi-dimensional array for an inverse mapping, this is
+        # useful to retrieve the position in the plan given a triangle.
+        self.plan_map_inv = [[-1 for t in h.triangles] for h in self.hexagons]
+
+        self.plan = []
+        for i, plan_map_row in enumerate(plan_map):
+            plan_row = []
+            for j, mapping in enumerate(plan_map_row):
+                hexagon_index, triangle_index = mapping
+                hexagon = self.hexagons[hexagon_index]
+                triangle = hexagon.triangles[triangle_index]
+                plan_row.append(triangle)
+
+                self.plan_map_inv[hexagon_index][triangle_index] = (i, j)
+
+            self.plan.append(plan_row)
+
+    def get_triangle_plan_position(self, triangle):
+        return self.plan_map_inv[triangle.hexagon.index][triangle.index]
+
+    def __str__(self):
+        output = ""
+
+        for row in self.plan:
+            for triangle in row:
+                output += "%s\t" % str(triangle)
+            output += "\n"
+
+        return output
+
+
+def test():
+    trihexaflexagon = TriHexaflexagon()
+    print(trihexaflexagon)
+
+
+if __name__ == "__main__":
+    test()
diff --git a/src/gimp_diagram_test.py b/src/gimp_diagram_test.py
new file mode 100755 (executable)
index 0000000..20180c4
--- /dev/null
@@ -0,0 +1,83 @@
+#!/usr/bin/env python
+
+'''
+Gimp plugin "TestGimpDiagram"
+
+Test the GImpDiagram class
+
+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 pi
+import gimpfu
+from gimpfu import *
+
+from diagram.gimp_diagram import GimpDiagram
+
+gettext.install("gimp20-python", gimp.locale_directory, unicode=True)
+
+
+def test_diagram_main(image):
+    pdb.gimp_image_undo_group_start(image)
+    pdb.gimp_context_push()
+    pdb.gimp_context_set_defaults()
+
+    template_layer_name = "TestGimpDiagram"
+    template_layer = pdb.gimp_image_get_layer_by_name(image,
+                                                      template_layer_name)
+    if not template_layer:
+        template_layer = pdb.gimp_layer_new(image, image.width, image.height,
+                                            gimpfu.RGBA_IMAGE,
+                                            template_layer_name,
+                                            100,
+                                            gimpfu.NORMAL_MODE)
+        pdb.gimp_image_add_layer(image, template_layer, -1)
+
+    diagram = GimpDiagram(image.width, image.height, image, template_layer,
+                          font_size=10, stroke_size=2)
+
+    diagram.draw_regular_polygon(200, 200, 6, 100, 0,
+                                 fill_color=(1, 1, 0))
+
+    diagram.draw_regular_polygon(200, 200, 6, 100, pi / 12.,
+                                 stroke_color=(1, 0, 0, 0.2))
+
+    diagram.draw_centered_text(200, 200, "__30__", pi / 6., (0, 0, 0))
+
+    pdb.gimp_context_pop()
+    pdb.gimp_image_undo_group_end(image)
+
+
+if __name__ == "__main__":
+    register(
+        "python_fu_test_gimp_diagram",
+        N_("Test GimpDiagram"),
+        "Test GimpDiagram",
+        "Antonio Ospite <ao2@ao2.it>",
+        "Copyright (C) 2018  Antonio Ospite <ao2@ao2.it>",
+        "2017",
+        N_("TestGImpDiagram..."),
+        "RGB*, GRAY*",
+        [
+            (PF_IMAGE, "image", "Input image", None),
+        ],
+        [],
+        test_diagram_main,
+        menu="<Image>/Filters/Render",
+        domain=("gimp20-python", gimp.locale_directory)
+    )
+
+    main()
diff --git a/src/gimp_hexaflexagon.py b/src/gimp_hexaflexagon.py
new file mode 100755 (executable)
index 0000000..f9028bb
--- /dev/null
@@ -0,0 +1,146 @@
+#!/usr/bin/env python
+
+'''
+Gimp plugin "Hexaflexagon"
+
+Create Hexaflexagons.
+
+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/>.
+'''
+
+# The plugin is inspired to flexagon.scm by Andrea Rossetti:
+# http://andrear.altervista.org/home/gimp_flexagon.php
+#
+# It has been rewritten in python in the hope to simplify it and attract more
+# contributors.
+
+from gimpfu import *
+
+from diagram.gimp_diagram import GimpDiagram
+from flexagon import HexaflexagonDiagram
+
+gettext.install("gimp20-python", gimp.locale_directory, unicode=True)
+
+
+def build_plan(template_layer, hexaflexagon_layer, diagram):
+    for hexagon in diagram.hexaflexagon.hexagons:
+        for triangle in hexagon.triangles:
+            verts = diagram.get_triangle_verts(triangle)
+
+            matrix = diagram.get_triangle_transform(triangle)
+            diagram.backend.copy_polygon(template_layer, verts, hexaflexagon_layer, matrix)
+
+
+def hexaflexagon_main(image):
+    x_border = image.width / 50
+    font_size = image.width / 80
+    stroke_width = image.width / 480
+
+    template_layer_name = "HexaflexagonTemplate"
+    content_layer_name = "Hexagons"
+    hexaflexagon_layer_name = "Hexaflexagon"
+
+    message = "Draw the hexagons content into the '%s' layer.\n" % content_layer_name
+    message += "Then call this script again."
+
+    pdb.gimp_image_undo_group_start(image)
+    pdb.gimp_context_push()
+    pdb.gimp_context_set_defaults()
+
+    template_layer = pdb.gimp_image_get_layer_by_name(image,
+                                                      template_layer_name)
+
+    content_layer = pdb.gimp_image_get_layer_by_name(image,
+                                                     content_layer_name)
+    if not content_layer:
+        content_layer = pdb.gimp_layer_new(image, image.width, image.height,
+                                           RGBA_IMAGE, content_layer_name,
+                                           100, NORMAL_MODE)
+        if template_layer:
+            template_layer_position = pdb.gimp_image_get_item_position(image, template_layer)
+            content_layer_position = template_layer_position - 1
+            pdb.gimp_image_insert_layer(image, content_layer, None, content_layer_position)
+
+            pdb.gimp_message(message)
+
+            pdb.gimp_context_pop()
+            pdb.gimp_image_undo_group_end(image)
+            return
+        else:
+            content_layer_position = -1
+            pdb.gimp_image_insert_layer(image, content_layer, None, content_layer_position)
+
+    if not template_layer:
+        template_layer = pdb.gimp_layer_new(image, image.width, image.height,
+                                            RGBA_IMAGE, template_layer_name,
+                                            100, NORMAL_MODE)
+        pdb.gimp_image_insert_layer(image, template_layer, None, -1)
+
+        gimp_backend = GimpDiagram(image.width, image.height,
+                                   image, template_layer,
+                                   font_size=font_size, stroke_width=stroke_width)
+        diagram = HexaflexagonDiagram(x_border, backend=gimp_backend)
+        diagram.draw_template()
+
+        pdb.gimp_message(message)
+        pdb.gimp_context_pop()
+        pdb.gimp_image_undo_group_end(image)
+        return
+
+    hexaflexagon_layer = pdb.gimp_image_get_layer_by_name(image,
+                                                          hexaflexagon_layer_name)
+    if hexaflexagon_layer:
+        pdb.gimp_message("There is already a generated hexaflexagon.")
+        pdb.gimp_context_pop()
+        pdb.gimp_image_undo_group_end(image)
+        return
+
+    hexaflexagon_layer = pdb.gimp_layer_new(image, image.width,
+                                            image.height, RGBA_IMAGE,
+                                            hexaflexagon_layer_name, 100,
+                                            NORMAL_MODE)
+    pdb.gimp_image_insert_layer(image, hexaflexagon_layer, None, -1)
+
+    gimp_backend = GimpDiagram(image.width, image.height,
+                               image, content_layer,
+                               font_size=font_size, stroke_width=stroke_width)
+    diagram = HexaflexagonDiagram(x_border, backend=gimp_backend)
+    build_plan(content_layer, hexaflexagon_layer, diagram)
+
+    pdb.gimp_context_pop()
+    pdb.gimp_image_undo_group_end(image)
+
+
+if __name__ == "__main__":
+    register(
+        "python_fu_hexaflexagon",
+        N_("Create Hexaflexagons"),
+        "Create Hexaflexagons",
+        "Antonio Ospite <ao2@ao2.it>",
+        "Copyright (C) 2018  Antonio Ospite <ao2@ao2.it>",
+        "2015",
+        N_("Hexaflexagon..."),
+        "RGB*, GRAY*",
+        [
+            (PF_IMAGE, "image", "Input image", None),
+        ],
+        [],
+        hexaflexagon_main,
+        menu="<Image>/Filters/Render",
+        domain=("gimp20-python", gimp.locale_directory)
+    )
+
+    main()
diff --git a/src/svg_hexaflexagon_editor.py b/src/svg_hexaflexagon_editor.py
new file mode 100755 (executable)
index 0000000..f86ad13
--- /dev/null
@@ -0,0 +1,152 @@
+#!/usr/bin/env python3
+#
+# Draw an SVG hexaflexagon which 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 pi
+import svgwrite
+
+from diagram.svgwrite_diagram import SvgwriteDiagram
+from flexagon.hexaflexagon_diagram import HexaflexagonDiagram
+
+
+class SvgwriteHexaflexagonDiagram(HexaflexagonDiagram):
+    def __init__(self, *args, **kwargs):
+        super(SvgwriteHexaflexagonDiagram, self).__init__(*args, **kwargs)
+
+        svg = self.backend.svg
+
+        # create some layers and groups
+        layers = {
+            "Hexagons": svg.layer(label="Hexagons"),
+            "Hexaflexagon": svg.layer(label="Hexaflexagon"),
+            "Folding guide": svg.layer(label="Folding guide"),
+            "Template": svg.layer(label="Template")
+        }
+        for layer in layers.values():
+            svg.add(layer)
+
+        self.groups = layers
+
+        for hexagon in self.hexaflexagon.hexagons:
+            name = "hexagon%d-content" % hexagon.index
+            layer = svg.layer(id=name, label="Hexagon %d" % (hexagon.index + 1))
+            self.groups[name] = layer
+            layers['Hexagons'].add(layer)
+
+            for triangle in hexagon.triangles:
+                name = "hexagon%d-triangle%d" % (hexagon.index, triangle.index)
+                group = svg.g(id=name)
+                self.groups[name] = group
+                layers['Template'].add(group)
+
+    def draw(self):
+        for hexagon in self.hexaflexagon.hexagons:
+            cx, cy = self.get_hexagon_center(hexagon)
+
+            # Draw some default content
+            old_active_group = self.backend.active_group
+            self.backend.active_group = self.groups["hexagon%d-content" % hexagon.index]
+            self.backend.draw_regular_polygon(cx, cy, 6, self.hexagon_radius, pi / 6., fill_color=(0.5, 0.5, 0.5, 0.2), stroke_color=None)
+            self.backend.active_group = old_active_group
+
+            # Add folding guides
+            old_active_group = self.backend.active_group
+            self.backend.active_group = self.groups["Folding guide"]
+
+            for triangle in hexagon.triangles:
+                cx, cy = self.get_triangle_center(triangle)
+                theta = triangle.get_angle_in_hexagon()
+                self.backend.draw_regular_polygon(cx, cy, 3, self.triangle_radius, theta, (0, 0, 0, 0.2))
+                polygon = self.backend.active_group.elements[-1]
+                polygon['id'] = "hexagon%d-triangle%d-folding" % (triangle.hexagon.index, triangle.index)
+
+            self.backend.active_group = old_active_group
+
+        # Draw the normal template for hexagons
+        for hexagon in self.hexaflexagon.hexagons:
+            self.draw_hexagon_template(hexagon)
+
+        # draw plan using references
+        for hexagon in self.hexaflexagon.hexagons:
+            for triangle in hexagon.triangles:
+                m = self.get_triangle_transform(triangle)
+                svg_matrix = "matrix(%f, %f, %f, %f, %f, %f)" % (m[0], m[3],
+                                                                 m[1], m[4],
+                                                                 m[2], m[5])
+
+                # Reuse the hexagons triangle for the hexaflexagon template
+                group = self.groups["Template"]
+                triangle_href = "#hexagon%d-triangle%d" % (hexagon.index, triangle.index)
+                ref = self.backend.svg.use(triangle_href)
+                ref['transform'] = svg_matrix
+                group.add(ref)
+
+                # Reuse the folding guides
+                group = self.groups["Folding guide"]
+                folding_href = "#hexagon%d-triangle%d-folding" % (hexagon.index, triangle.index)
+                ref = self.backend.svg.use(folding_href)
+                ref['transform'] = svg_matrix
+                group.add(ref)
+
+                # Reuse the content to draw the final hexaflexagon
+                group = self.groups["Hexaflexagon"]
+                content_href = "#hexagon%d-content" % hexagon.index
+                ref = self.backend.svg.use(content_href)
+                ref['transform'] = svg_matrix
+                ref['clip-path'] = "url(%s)" % (triangle_href + '-clip-path')
+                group.add(ref)
+
+    def draw_triangle_template(self, triangle, cx, cy, theta):
+        old_active_group = self.backend.active_group
+        group_name = "hexagon%d-triangle%d" % (triangle.hexagon.index, triangle.index)
+        self.backend.active_group = self.groups[group_name]
+
+        super(SvgwriteHexaflexagonDiagram, self).draw_triangle_template(triangle, cx, cy, theta)
+
+        # The triangle outline in the active group's element is the only polygon
+        # element, so get it and set its id so that it can be reused as
+        # a clip-path
+        for element in self.backend.active_group.elements:
+            if isinstance(element, svgwrite.shapes.Polygon):
+                element['id'] = group_name + "-outline"
+                break
+
+        clip_path = self.backend.svg.clipPath(id=group_name + '-clip-path')
+        self.backend.svg.defs.add(clip_path)
+        ref = self.backend.svg.use('#%s-outline' % group_name)
+        clip_path.add(ref)
+
+        self.backend.active_group = old_active_group
+
+
+def main():
+    width = 3508
+    height = 2480
+
+    x_border = width / 50
+    font_size = width / 80
+    stroke_width = width / 480
+
+    svg_backend = SvgwriteDiagram(width, height, font_size=font_size, stroke_width=stroke_width)
+    hexaflexagon = SvgwriteHexaflexagonDiagram(x_border, backend=svg_backend)
+    hexaflexagon.draw()
+    svg_backend.save_svg("inkscape-hexaflexagon-editor.svg")
+
+
+if __name__ == "__main__":
+    main()