From: Antonio Ospite Date: Fri, 8 May 2015 14:45:15 +0000 (+0200) Subject: Initial import X-Git-Url: https://git.ao2.it/experiments/RadialSymmetry.git/commitdiff_plain/aad1044b2a0ad5ca47d21f74da7066708b9c584a?ds=inline Initial import --- aad1044b2a0ad5ca47d21f74da7066708b9c584a diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..0d20b64 --- /dev/null +++ b/.gitignore @@ -0,0 +1 @@ +*.pyc diff --git a/Diagram.py b/Diagram.py new file mode 100755 index 0000000..0bc3925 --- /dev/null +++ b/Diagram.py @@ -0,0 +1,277 @@ +#!/usr/bin/env python +# +# A Diagram abstraction based on Cairo +# +# Copyright (C) 2015 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 cairo +from math import * + + +class Diagram(object): + + def __init__(self, width, height, background=[1, 1, 1]): + self.width = width + self.height = height + self.background = background + + # TODO: use a RecordingSurface + self.surface = cairo.SVGSurface(None, width, height) + self.cr = cr = cairo.Context(self.surface) + + # convert to left-bottom-origin cartesian coordinates + cr.translate(0, self.height) + cr.scale(1, -1) + + cr.select_font_face("Georgia", cairo.FONT_SLANT_NORMAL, + cairo.FONT_WEIGHT_NORMAL) + cr.set_font_size(20) + + # Adjust the font matrix to left-bottom origin + M = cr.get_font_matrix() + M.scale(1, -1) + cr.set_font_matrix(M) + + 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) + 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): + import Image + import StringIO + f = StringIO.StringIO() + 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 get_regular_polygon(self, x, y, sides, r, theta0=0.0): + theta = 2 * pi / sides + + verts = [] + for i in range(0, sides): + px = x + r * sin(theta0 + i * theta) + py = y + r * cos(theta0 + i * theta) + verts.append((px, py)) + + return verts + + def color_to_rgba(self, color): + if len(color) == 3: + return color[0], color[1], color[2], 1.0 + elif len(color) == 4: + return color[0], color[1], color[2], color[3] + else: + return None + + def draw_polygon(self, verts, stroke_color=[0, 0, 0], fill_color=None): + cr = self.cr + + if fill_color: + 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() + + r, g, b, a = self.color_to_rgba(fill_color) + cr.set_source_rgba(r, g, b, a) + cr.fill() + + n = len(verts) + for i in range(0, n): + v1 = verts[i] + v2 = verts[(i + 1) % n] + cr.move_to(v1[0], v1[1]) + cr.line_to(v2[0], v2[1]) + + r, g, b, a = self.color_to_rgba(stroke_color) + cr.set_source_rgba(r, g, b, a) + cr.stroke() + + def draw_star(self, cx, cy, verts, stroke_color=[0, 0, 0]): + cr = self.cr + + v = verts[0] + cr.move_to(cx, cy) + for v in verts: + cr.line_to(v[0], v[1]) + cr.move_to(cx, cy) + + r, g, b, a = self.color_to_rgba(stroke_color) + cr.set_source_rgba(r, g, b, a) + cr.stroke() + + def draw_dot(self, cx, cy, size=10.0, fill_color=[0, 0, 0, 0.5]): + cr = self.cr + + cr.save() + cr.set_source_rgba(fill_color[0], fill_color[1], fill_color[2], + fill_color[3]) + cr.arc(cx, cy, size, 0, 2 * pi) + cr.fill() + cr.restore() + + def normalized_angle_01(self, theta): + return fmod(theta, 2 * pi) / (2 * pi) + + def draw_line(self, x1, y1, x2, y2, stroke_color=[0, 0, 0, 1]): + cr = self.cr + r, g, b, a = self.color_to_rgba(stroke_color) + cr.set_source_rgba(r, g, b, a) + cr.move_to(x1, y1) + cr.line_to(x2, y2) + cr.stroke() + + def draw_rect_from_center(self, cx, cy, width, height, theta=0, + fill=True, fill_color=[1, 1, 1], + stroke=False, stroke_color=[0, 0, 0]): + cr = self.cr + + mx = width / 2.0 + my = height / 2.0 + + rx = cx - (mx * cos(theta) - my * sin(theta)) + ry = cy - (mx * sin(theta) + my * cos(theta)) + + cr.save() + cr.translate(rx, ry) + cr.rotate(theta) + + if fill: + cr.rectangle(0, 0, width, height) + cr.set_source_rgba(fill_color[0], fill_color[1], fill_color[2], 0.8) + cr.fill() + + if stroke: + cr.rectangle(0, 0, width, height) + cr.set_source_rgba(stroke_color[0], stroke_color[1], stroke_color[2], 0.5) + cr.stroke() + self.draw_dot(0, 0, 3.0, list(stroke_color) + [0.5]) + + cr.restore() + + def draw_rect(self, x, y, width, height, fill=True, fill_color=[1, 1, 1], + stroke=False, stroke_color=[0, 0, 0]): + cr = self.cr + + cr.save() + cr.translate(x, y) + + if fill: + cr.rectangle(0, 0, width, height) + cr.set_source_rgba(fill_color[0], fill_color[1], fill_color[2], 0.8) + cr.fill() + + if stroke: + cr.rectangle(0, 0, width, height) + cr.set_source_rgba(stroke_color[0], stroke_color[1], stroke_color[2], 0.5) + cr.stroke() + self.draw_dot(0, 0, 3.0, list(stroke_color) + [0.5]) + + cr.restore() + + def draw_centered_text(self, cx, cy, text, theta=0, color=[0, 0, 0], bounding_box=False): + 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. + # NOTE: y is kept on the baseline + tx = width / 2.0 + x_bearing + ty = 0 + + # Angles are intended clockwise by the caller, but the trigonometric + # functions below consider angles counter-clockwise + theta = -theta + + # 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) + self.draw_rect(0, -descent, width, ascent, fill_color=[1, 1, 1, 0.1], stroke=bounding_box) + cr.set_source_rgba(color[0], color[1], color[2], 0.8) + cr.move_to(0, 0) + cr.show_text(text) + cr.fill() + cr.restore() + + return x_advance + + +if __name__ == "__main__": + diagram = Diagram(400, 400) + + diagram.clear() + + x = 40 + y = 200 + + x_offset = x + + theta = 0 + + advance = diagram.draw_centered_text(x_offset, y, "Ciao", theta, bounding_box=True) + x_offset += advance + + advance = diagram.draw_centered_text(x_offset, y, "____", theta + pi / 4, bounding_box=True) + x_offset += advance + + advance = diagram.draw_centered_text(x_offset, y, "jxpqdlf", theta + pi / 2, bounding_box=True) + x_offset += advance + + advance = diagram.draw_centered_text(x_offset, y, "pppp", theta + 3 * pi / 4, bounding_box=True) + x_offset += advance + + advance = diagram.draw_centered_text(x_offset, y, "dddd", theta + pi, bounding_box=True) + x_offset += advance + + advance = diagram.draw_centered_text(x_offset, y, "Jjjj", theta + 5 * pi / 4, bounding_box=True) + x_offset += advance + + advance = diagram.draw_centered_text(x_offset, y, "1369", theta + 3 * pi / 2, bounding_box=True) + x_offset += advance + + advance = diagram.draw_centered_text(x_offset, y, "qqqq", theta + 7 * pi / 4, bounding_box=True) + x_offset += advance + + diagram.draw_line(0, y, 400, y, [0, 0, 1, 0.2]) + + diagram.show() diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..9fab454 --- /dev/null +++ b/Makefile @@ -0,0 +1,11 @@ +all: + ./RadialSymmetry.py + +test: + ./RadialSymmetryDiagram.py + +pep8: + pep8 --ignore=E501 *.py + +clean: + rm -f *.pyc radial_symmetry_test.svg radial_symmetry_test.png diff --git a/RadialSymmetry.py b/RadialSymmetry.py new file mode 100755 index 0000000..aba522e --- /dev/null +++ b/RadialSymmetry.py @@ -0,0 +1,260 @@ +#!/usr/bin/env python +# +# An app to interactively change RadialSymmetryDiagram properies +# +# Copyright (C) 2015 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 gi.repository import Gtk, Gio +import sys +import math + +import RadialSymmetryDiagram + +APPLICATION_ID = "it.ao2.RadialSymmetryApp" +WINDOW_TITLE = "Radial Symmetry" +CANVAS_WIDTH = 600 +CANVAS_HEIGHT = 600 + + +class RadialSymmetryWindow(Gtk.ApplicationWindow): + + def __init__(self): + Gtk.Window.__init__(self, title=WINDOW_TITLE) + self.set_border_width(10) + self.set_property('resizable', False) + + self.model = model = RadialSymmetryDiagram.RadialSymmetryModel() + self.diagram = RadialSymmetryDiagram.RadialSymmetryDiagram(CANVAS_WIDTH, + CANVAS_HEIGHT) + model.base_polygon_sides = 6 + ad = Gtk.Adjustment(model.base_polygon_sides, 1, 32, 1, 0, 0) + self.spin_base_polygon_sides = Gtk.SpinButton(adjustment=ad, climb_rate=1, digits=0) + self.spin_base_polygon_sides.connect("value-changed", self.get_base_polygon_sides) + + model.element_sides = 4 + ad = Gtk.Adjustment(model.element_sides, 2, 16, 1, 0, 0) + self.spin_elem_sides = Gtk.SpinButton(adjustment=ad, climb_rate=1, digits=0) + self.spin_elem_sides.connect("value-changed", self.get_elem_sides) + + max_element_radius = min(CANVAS_WIDTH, CANVAS_HEIGHT) + model.element_radius = max_element_radius / 4.0 + ad = Gtk.Adjustment(model.element_radius, 0, max_element_radius, 1, 0, 0) + self.spin_element_radius = Gtk.SpinButton(adjustment=ad, climb_rate=1, digits=0) + self.spin_element_radius.connect("value-changed", self.get_element_radius) + + model.radial_orientate = True + self.checkbox_radial_orientate = Gtk.CheckButton(label="Radial orientation") + self.checkbox_radial_orientate.set_active(model.radial_orientate) + self.checkbox_radial_orientate.connect("toggled", self.get_radial_orientate) + + model.show_base_polygon = False + self.checkbox_base_polygon = Gtk.CheckButton(label="Draw base polygon") + self.checkbox_base_polygon.set_active(model.show_base_polygon) + self.checkbox_base_polygon.connect("toggled", self.get_base_polygon) + + max_base_polygon_radius = min(CANVAS_WIDTH, CANVAS_HEIGHT) + model.base_polygon_radius = max_base_polygon_radius / 4.0 + ad = Gtk.Adjustment(model.base_polygon_radius, 0, max_base_polygon_radius, 1, 0, 0) + self.spin_base_polygon_radius = Gtk.SpinButton(adjustment=ad, climb_rate=1, digits=0) + self.spin_base_polygon_radius.connect("value-changed", self.get_base_polygon_radius) + + model.show_stars = False + self.checkbox_stars = Gtk.CheckButton(label="Draw stars") + self.checkbox_stars.set_active(model.show_stars) + self.checkbox_stars.connect("toggled", self.get_draw_stars) + + model.show_elements = True + self.checkbox_elements = Gtk.CheckButton(label="Draw elements") + self.checkbox_elements.set_active(model.show_elements) + self.checkbox_elements.connect("toggled", self.get_draw_elements) + + model.show_labels = False + self.checkbox_labels = Gtk.CheckButton(label="Draw labels") + self.checkbox_labels.set_active(model.show_labels) + self.checkbox_labels.connect("toggled", self.get_draw_labels) + + model.element_angle_offset = 0 + ad2 = Gtk.Adjustment(math.degrees(model.element_angle_offset), -1, 360, 1, 0, 0) + self.spin_element_angle = Gtk.SpinButton(adjustment=ad2, climb_rate=1, digits=0) + self.spin_element_angle.connect("value-changed", self.get_element_angle) + + model.base_polygon_angle = 0 + ad = Gtk.Adjustment(math.degrees(model.base_polygon_angle), -1, 360, 1, 0, 0) + self.spin_global_angle = Gtk.SpinButton(adjustment=ad, climb_rate=1, digits=0) + self.spin_global_angle.connect("value-changed", self.get_global_angle) + + self.darea = Gtk.DrawingArea() + self.darea.connect("draw", self.draw) + self.darea.set_size_request(CANVAS_WIDTH, CANVAS_HEIGHT) + + self.export_button = Gtk.Button(label="Export diagram") + self.export_button.connect("clicked", self.on_export_diagram) + + controls = Gtk.VBox(spacing=10) + + vbox = Gtk.VBox() + vbox.pack_start(Gtk.Label("Elements"), False, False, 0) + vbox.pack_start(self.spin_base_polygon_sides, False, False, 0) + controls.pack_start(vbox, False, False, 0) + + vbox = Gtk.VBox() + vbox.pack_start(Gtk.Label("Element sides"), False, False, 0) + vbox.pack_start(self.spin_elem_sides, False, False, 0) + controls.pack_start(vbox, False, False, 0) + + vbox = Gtk.VBox() + vbox.pack_start(Gtk.Label("Element radius"), False, False, 0) + vbox.pack_start(self.spin_element_radius, False, False, 0) + controls.pack_start(vbox, False, False, 0) + + controls.pack_start(self.checkbox_radial_orientate, False, False, 0) + + vbox = Gtk.VBox() + vbox.pack_start(Gtk.Label("Element angle"), False, False, 0) + vbox.pack_start(self.spin_element_angle, False, False, 0) + controls.pack_start(vbox, False, False, 0) + + vbox = Gtk.VBox() + vbox.pack_start(Gtk.Label("Global angle"), False, False, 0) + vbox.pack_start(self.spin_global_angle, False, False, 0) + controls.pack_start(vbox, False, False, 0) + + controls.pack_start(self.checkbox_base_polygon, False, False, 0) + + vbox = Gtk.VBox() + vbox.pack_start(Gtk.Label("Base polygon radius"), False, False, 0) + vbox.pack_start(self.spin_base_polygon_radius, False, False, 0) + controls.pack_start(vbox, False, False, 0) + + controls.pack_start(self.checkbox_elements, False, False, 0) + controls.pack_start(self.checkbox_stars, False, False, 0) + controls.pack_start(self.checkbox_labels, False, False, 0) + controls.pack_end(self.export_button, False, False, 0) + + main_container = Gtk.HBox(spacing=10) + main_container.add(controls) + main_container.add(self.darea) + + self.add(main_container) + + def normalize_angle(self, angle): + if angle == 360: + angle = 0 + elif angle == -1: + angle = 359 + + return angle + + def get_base_polygon_sides(self, event): + self.model.base_polygon_sides = self.spin_base_polygon_sides.get_value_as_int() + self.darea.queue_draw() + + def get_elem_sides(self, event): + self.model.element_sides = self.spin_elem_sides.get_value_as_int() + self.darea.queue_draw() + + def get_element_radius(self, event): + self.model.element_radius = self.spin_element_radius.get_value_as_int() + self.darea.queue_draw() + + def get_radial_orientate(self, event): + self.model.radial_orientate = self.checkbox_radial_orientate.get_active() + self.darea.queue_draw() + + def get_base_polygon(self, event): + self.model.show_base_polygon = self.checkbox_base_polygon.get_active() + self.darea.queue_draw() + + def get_base_polygon_radius(self, event): + self.model.base_polygon_radius = self.spin_base_polygon_radius.get_value_as_int() + self.darea.queue_draw() + + def get_draw_elements(self, event): + self.model.show_elements = self.checkbox_elements.get_active() + self.darea.queue_draw() + + def get_draw_stars(self, event): + self.model.show_stars = self.checkbox_stars.get_active() + self.darea.queue_draw() + + def get_draw_labels(self, event): + self.model.show_labels = self.checkbox_labels.get_active() + self.darea.queue_draw() + + def get_global_angle(self, event): + angle = self.normalize_angle(self.spin_global_angle.get_value_as_int()) + self.spin_global_angle.set_value(angle) + self.model.base_polygon_angle = math.radians(angle) + self.darea.queue_draw() + + def get_element_angle(self, event): + angle = self.normalize_angle(self.spin_element_angle.get_value_as_int()) + self.spin_element_angle.set_value(angle) + self.model.element_angle_offset = math.radians(angle) + self.darea.queue_draw() + + def on_export_diagram(self, event): + dialog = Gtk.FileChooserDialog("Export Diagram", self, + Gtk.FileChooserAction.SAVE, + (Gtk.STOCK_CANCEL, Gtk.ResponseType.CANCEL, + Gtk.STOCK_SAVE, Gtk.ResponseType.OK)) + dialog.set_property('do-overwrite-confirmation', True) + + filter_svg = Gtk.FileFilter() + filter_svg.set_name("SVG files") + filter_svg.add_mime_type("image/svg+xml") + dialog.add_filter(filter_svg) + + filter_png = Gtk.FileFilter() + filter_png.set_name("PNG files") + filter_png.add_mime_type("image/png") + dialog.add_filter(filter_png) + + response = dialog.run() + if response == Gtk.ResponseType.OK: + filename = dialog.get_filename() + if filename.endswith(".svg"): + self.diagram.save_svg(filename[:-4]) + elif filename.endswith(".png"): + self.diagram.save_png(filename[:-4]) + + dialog.destroy() + + def draw(self, darea, cr): + self.diagram.draw(self.model) + + src_surface = self.diagram.surface + cr.set_source_surface(src_surface, 0, 0) + cr.paint() + + +class RadialSymmetryApp(Gtk.Application): + + def __init__(self): + Gtk.Application.__init__(self, + application_id=APPLICATION_ID, + flags=Gio.ApplicationFlags.FLAGS_NONE) + self.connect("activate", self.activateCb) + + def activateCb(self, app): + window = RadialSymmetryWindow() + app.add_window(window) + window.show_all() + +if __name__ == "__main__": + app = RadialSymmetryApp() + exit_status = app.run(sys.argv) + sys.exit(exit_status) diff --git a/RadialSymmetryDiagram.py b/RadialSymmetryDiagram.py new file mode 100755 index 0000000..e6bc383 --- /dev/null +++ b/RadialSymmetryDiagram.py @@ -0,0 +1,119 @@ +#!/usr/bin/env python +# +# A study on radial symmetry based +# +# Copyright (C) 2015 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 * +import colorsys +import Diagram + + +class RadialSymmetryModel(): + def __init__(self, base_polygon_sides=6, base_polygon_radius=None, + base_polygon_angle=0, show_base_polygon=False, + element_sides=3, element_radius=None, element_angle_offset=0, + radial_orientation=True, show_elements=True, show_stars=False, + show_labels=False): + self.base_polygon_sides = base_polygon_sides + self.base_polygon_radius = base_polygon_radius + self.base_polygon_angle = base_polygon_angle + self.show_base_polygon = show_base_polygon + self.element_sides = element_sides + self.element_radius = element_radius + self.element_angle_offset = element_angle_offset + self.radial_orientate = radial_orientation + self.show_elements = show_elements + self.show_stars = show_stars + self.show_labels = show_labels + + +class RadialSymmetryDiagram(Diagram.Diagram): + + def __init__(self, width, height, background=[1, 1, 1]): + Diagram.Diagram.__init__(self, width, height, background) + + def draw(self, model): + cx = self.width / 2.0 + cy = self.height / 2.0 + + # internal radius + if model.base_polygon_radius is None: + model.base_polygon_radius = min(self.width, self.height) / 4.0 + + if model.element_radius is None: + model.element_radius = model.base_polygon_radius + + self.clear() + self.draw_elements(cx, cy, model) + + def draw_elements(self, cx, cy, model): + central_angle = 2 * pi / model.base_polygon_sides + + base_polygon_orientation = central_angle + model.base_polygon_angle + + verts = self.get_regular_polygon(cx, cy, model.base_polygon_sides, model.base_polygon_radius, + base_polygon_orientation) + + if model.show_base_polygon: + self.draw_polygon(verts, [0, 0, 0]) + + for i, v in enumerate(verts[:]): + radial_orientation_angle = (i + 1) * central_angle + rotated_radial_orientation_angle = radial_orientation_angle + model.base_polygon_angle + + # the element orientation may depend the base polygon rotation + if model.radial_orientate: + element_angle = rotated_radial_orientation_angle + else: + element_angle = 2 * pi / model.element_sides + + element_angle += model.element_angle_offset + + # the element color depends oly on the element index + a = self.normalized_angle_01(radial_orientation_angle) + color = colorsys.hsv_to_rgb(a, 1.0, 1.0) + + self.draw_element(v[0], v[1], model, element_angle, color) + + if model.show_labels: + ta = self.normalized_angle_01(rotated_radial_orientation_angle) + text = ("%.2f" % (ta * 360)).rstrip('0').rstrip('.') + color = colorsys.hsv_to_rgb(a, 1.0, 1.0) + self.draw_centered_text(v[0], v[1], text, + rotated_radial_orientation_angle, color) + + def draw_element(self, x, y, model, theta, color=[0, 0, 0]): + if model.show_elements: + tverts = self.get_regular_polygon(x, y, model.element_sides, model.element_radius, theta) + self.draw_polygon(tverts, color) + + if model.show_stars: + apothem = model.element_radius * cos(pi / model.element_sides) + apothem_angle = theta + pi / model.element_sides + + sverts = self.get_regular_polygon(x, y, model.element_sides, apothem, + apothem_angle) + self.draw_star(x, y, sverts, color) + + +if __name__ == '__main__': + h = RadialSymmetryDiagram(800, 600) + h.draw(RadialSymmetryModel(base_polygon_sides=10, element_sides=4, show_stars=True, show_labels=False)) + h.draw(RadialSymmetryModel(base_polygon_sides=31, element_sides=4, show_stars=False, show_labels=False)) + h.show() + h.save_svg("radial_symmetry_test") + h.save_png("radial_symmetry_test") diff --git a/TODO b/TODO new file mode 100644 index 0000000..a6523f7 --- /dev/null +++ b/TODO @@ -0,0 +1 @@ +- Saving to SVG only saves a bitmap version of the diagram