From: Antonio Ospite Date: Mon, 29 Jan 2018 14:31:26 +0000 (+0100) Subject: Initial import X-Git-Url: https://git.ao2.it/flexagon-toolkit.git/commitdiff_plain/b0d293a76ed3a595754b643f48b2f2d1b03395f6?ds=inline Initial import --- b0d293a76ed3a595754b643f48b2f2d1b03395f6 diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..47072ae --- /dev/null +++ b/.gitignore @@ -0,0 +1,2 @@ +*.pyc +hexaflexagon-template.svg diff --git a/Makefile b/Makefile new file mode 100644 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 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 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 index 0000000..8f8db2e --- /dev/null +++ b/contrib/affine_composition.py @@ -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 index 0000000..ce44ca9 --- /dev/null +++ b/src/cairo_hexaflexagon_template.py @@ -0,0 +1,43 @@ +#!/usr/bin/env python +# +# Draw a hexaflexagon template with cairo. +# +# 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 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 index 0000000..e69de29 diff --git a/src/diagram/cairo_diagram.py b/src/diagram/cairo_diagram.py new file mode 100755 index 0000000..9aab30e --- /dev/null +++ b/src/diagram/cairo_diagram.py @@ -0,0 +1,308 @@ +#!/usr/bin/env python +# +# A Diagram abstraction based on Cairo +# +# 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 +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 index 0000000..eee9fc6 --- /dev/null +++ b/src/diagram/diagram.py @@ -0,0 +1,88 @@ +#!/usr/bin/env python +# +# A Diagram base class +# +# 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, 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 index 0000000..30dcbb5 --- /dev/null +++ b/src/diagram/gimp_diagram.py @@ -0,0 +1,163 @@ +#!/usr/bin/env python +# +# A diagram class to draw in Gimp +# +# 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 . + +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 index 0000000..bf94541 --- /dev/null +++ b/src/diagram/svgwrite_diagram.py @@ -0,0 +1,150 @@ +#!/usr/bin/env python +# +# A Diagram abstraction based on svgwrite +# +# 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 . + +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 index 0000000..a4951d8 --- /dev/null +++ b/src/flexagon/__init__.py @@ -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 index 0000000..b7e50fb --- /dev/null +++ b/src/flexagon/hexaflexagon_diagram.py @@ -0,0 +1,188 @@ +#!/usr/bin/env python +# +# An class to draw hexaflexagons +# +# 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 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 index 0000000..d79875e --- /dev/null +++ b/src/flexagon/trihexaflexagon.py @@ -0,0 +1,132 @@ +#!/usr/bin/env python +# +# A generic model for a tri-hexaflexagon +# +# 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 + + +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 index 0000000..20180c4 --- /dev/null +++ b/src/gimp_diagram_test.py @@ -0,0 +1,83 @@ +#!/usr/bin/env python + +''' +Gimp plugin "TestGimpDiagram" + +Test the GImpDiagram class + +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 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 ", + "Copyright (C) 2018 Antonio Ospite ", + "2017", + N_("TestGImpDiagram..."), + "RGB*, GRAY*", + [ + (PF_IMAGE, "image", "Input image", None), + ], + [], + test_diagram_main, + menu="/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 index 0000000..f9028bb --- /dev/null +++ b/src/gimp_hexaflexagon.py @@ -0,0 +1,146 @@ +#!/usr/bin/env python + +''' +Gimp plugin "Hexaflexagon" + +Create Hexaflexagons. + +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 . +''' + +# 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 ", + "Copyright (C) 2018 Antonio Ospite ", + "2015", + N_("Hexaflexagon..."), + "RGB*, GRAY*", + [ + (PF_IMAGE, "image", "Input image", None), + ], + [], + hexaflexagon_main, + menu="/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 index 0000000..f86ad13 --- /dev/null +++ b/src/svg_hexaflexagon_editor.py @@ -0,0 +1,152 @@ +#!/usr/bin/env python3 +# +# Draw an SVG hexaflexagon which 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 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()