''' The main window of the curver GUI application. '''
import curver
import curver.application
import os
import sys
from math import sin, cos, pi, ceil, sqrt
from random import random
from colorsys import hls_to_rgb
from collections import namedtuple
try:
import Tkinter as TK
import tkFileDialog
import tkMessageBox
except ImportError: # Python 3.
try:
import tkinter as TK
import tkinter.filedialog as tkFileDialog
import tkinter.messagebox as tkMessageBox
except ImportError:
raise ImportError('Tkinter not available.')
try:
import ttk as TTK
except ImportError: # Python 3.
try:
from tkinter import ttk as TTK
except ImportError:
raise ImportError('Ttk not available.')
# Some constants.
if sys.platform in ['darwin']:
COMMAND = {
'close': 'Command+W',
}
COMMAND_KEY = {
'close': '<Command-w>',
}
else:
COMMAND = {
'close': 'Ctrl+W',
}
COMMAND_KEY = {
'close': '<Control-w>',
}
# Vectors to offset a label by to produce backing.
OFFSETS = [(1.5*cos(2 * pi * i / 12), 1.5*sin(2 * pi * i / 12)) for i in range(12)]
# Colours of things.
DEFAULT_EDGE_LABEL_COLOUR = 'black'
DEFAULT_EDGE_LABEL_BG_COLOUR = 'white'
MAX_DRAWABLE = 1000 # Maximum weight of a multicurve to draw fully.
Showable = namedtuple('Showable', ['name', 'item'])
[docs]def dot(a, b):
return a[0] * b[0] + a[1] * b[1]
PHI = (1 + sqrt(5)) / 2
[docs]def get_colours(num_colours):
def colour(state):
hue = (state / PHI) % 1.
lightness = (50 + random() * 10)/100.
saturation = (90 + random() * 10)/100.
r, g, b = hls_to_rgb(hue, lightness, saturation)
return '#%02x%02x%02x' % (int(r * 255), int(g * 255), int(b * 255))
return [colour(i) for i in range(num_colours)]
[docs]class Drawing(object):
def __init__(self, note, canvas, item, options):
self.note = note
self.canvas = canvas
self.options = options
self.vertices = []
self.edges = []
self.triangles = []
self.curve_components = []
self.item = item
self.lamination = None
if isinstance(item, curver.kernel.MappingClassGroup):
self.draw_lamination(item.triangulation.empty_lamination())
if isinstance(item, curver.kernel.Triangulation):
self.draw_lamination(item.empty_lamination())
elif isinstance(item, curver.kernel.Lamination):
self.draw_lamination(item)
elif isinstance(item, curver.kernel.MappingClass):
self.draw_lamination(item(item.source_triangulation.as_lamination()))
[docs] def get_size(self):
# Return the size of the canvas.
# We would like to do:
# return int(self.canvas.winfo_width()), int(self.canvas.winfo_height())
# But the canvas may not have been rendered yet so instead we use the note:
return (self.note.winfo_width() - 54, self.note.winfo_height() - 62)
######################################################################
[docs] def create_vertex(self, point):
assert isinstance(point, curver.application.Vector2)
self.vertices.append(curver.application.CanvasVertex(self.canvas, point, self.options))
return self.vertices[-1]
[docs] def create_edge(self, v1, v2, label, colour, create_inverse=False):
if create_inverse: self.create_edge(v2, v1, ~label, colour)
self.edges.append(curver.application.CanvasEdge(self.canvas, [v1, v2], label, colour, self.options))
return self.edges[-1]
[docs] def create_triangle(self, e1, e2, e3):
self.triangles.append(curver.application.CanvasTriangle(self.canvas, [e1, e2, e3], self.options))
return self.triangles[-1]
[docs] def create_curve_component(self, vertices, thin=True, smooth=False):
assert all(isinstance(vertex, curver.application.Vector2) for vertex in vertices)
self.curve_components.append(curver.application.CurveComponent(self.canvas, vertices, self.options, thin, smooth))
return self.curve_components[-1]
######################################################################
[docs] def translate(self, dx, dy):
dv = curver.application.Vector2(dx, dy)
for vertex in self.vertices:
vertex.vector = vertex.vector + dv
for curve_component in self.curve_components:
for i in range(len(curve_component.vertices)):
curve_component.vertices[i] = curve_component.vertices[i] + dv
self.canvas.move('all', dx, dy)
[docs] def zoom(self, scale):
for vertex in self.vertices:
vertex.vector = scale*vertex.vector
vertex.update()
for edge in self.edges:
edge.update()
for triangle in self.triangles:
triangle.update()
for curve_component in self.curve_components:
for i in range(len(curve_component.vertices)):
curve_component.vertices[i] = scale * curve_component.vertices[i]
curve_component.update()
self.redraw()
[docs] def zoom_centre(self, scale):
cw, ch = self.get_size()
self.translate(-cw / 2, -ch / 2)
self.zoom(scale)
self.translate(cw / 2, ch / 2)
[docs] def zoom_in(self):
self.zoom_centre(1.05)
[docs] def zoom_out(self):
self.zoom_centre(0.95)
[docs] def zoom_to_drawing(self):
box = self.canvas.bbox('all')
if box is not None:
x0, y0, x1, y1 = box
cw, ch = self.get_size()
cr = min(cw, ch)
w, h = x1 - x0, y1 - y0
r = max(w, h)
self.translate(-x0 - w / 2, -y0 - h / 2)
self.zoom(self.options.zoom_fraction * float(cr) / r)
self.translate(cw / 2, ch / 2)
######################################################################
[docs] def draw_triangulation(self, triangulation):
# Get a dual tree.
dual_tree = triangulation.dual_tree()
colours = dict((index, None) for index in triangulation.indices)
outside = [index for index in triangulation.indices if not dual_tree[index]]
for index, colour in zip(outside, get_colours(len(outside))):
colours[index] = colour
components = triangulation.components()
num_components = len(components)
cw, ch = self.get_size()
# We will layout the components in a p x q grid.
# Aim to maximise r := min(cw / p, ch / q) subject to pq >= num_components.
# There is probably a closed formula for the optimal value of p (and so q).
p = max(range(1, num_components+1), key=lambda p: min(cw / p, ch / ceil(float(num_components) / p)))
q = int(ceil(float(num_components) / p))
r = min(cw / p, ch / q) * (1 + self.options.zoom_fraction) / 4
dx = cw / p
dy = ch / q
num_used_vertices = 0
for index, component in enumerate(components):
n = len(component) // 3 # Number of triangles.
ngon = n + 2
# Create the vertices.
for i in range(ngon):
self.create_vertex(
curver.application.Vector2(
dx * (index % p) + dx / 2 + r * sin(2 * pi * (i + 0.5) / ngon),
dy * int(index / p) + dy / 2 + r * cos(2 * pi * (i + 0.5) / ngon)
))
def num_descendants(edge_label):
''' Return the number of triangles that can be reached in the dual tree starting at the given edge_label. '''
corner = triangulation.corner_lookup[edge_label]
left = (1 + sum(num_descendants(~(corner.labels[2])))) if dual_tree[corner.indices[2]] else 0
right = (1 + sum(num_descendants(~(corner.labels[1])))) if dual_tree[corner.indices[1]] else 0
return left, right
initial_edge_index = min(edge.index for edge in component if not dual_tree[edge.index])
to_extend = [(num_used_vertices, num_used_vertices+1, initial_edge_index)]
# Hmmm, need to be more careful here to ensure that we correctly orient the edges.
self.create_edge(self.vertices[num_used_vertices+1], self.vertices[num_used_vertices+0], initial_edge_index, colours[initial_edge_index])
while to_extend:
source_vertex, target_vertex, label = to_extend.pop()
left, right = num_descendants(label)
far_vertex = target_vertex + left + 1
corner = triangulation.corner_lookup[label]
if corner[2].sign() == +1:
self.create_edge(self.vertices[far_vertex], self.vertices[target_vertex], corner[2].label, colours[corner[2].index], left > 0)
else:
self.create_edge(self.vertices[target_vertex], self.vertices[far_vertex], corner[2].label, colours[corner[2].index], left > 0)
if corner[1].sign() == +1:
self.create_edge(self.vertices[source_vertex], self.vertices[far_vertex], corner[1].label, colours[corner[1].index], right > 0)
else:
self.create_edge(self.vertices[far_vertex], self.vertices[source_vertex], corner[1].label, colours[corner[1].index], right > 0)
if left > 0:
to_extend.append((far_vertex, target_vertex, ~(corner[2].label)))
if right > 0:
to_extend.append((source_vertex, far_vertex, ~(corner[1].label)))
num_used_vertices = len(self.vertices)
self.edges = sorted(self.edges, key=lambda e: ((0 if e.label >= 0 else 1), e.label)) # So now self.edges[label] is the edge with label label.
for triangle in triangulation:
self.create_triangle(self.edges[triangle[0].label], self.edges[triangle[1].label], self.edges[triangle[2].label])
[docs] def draw_lamination(self, lamination):
self.draw_triangulation(lamination.triangulation) # This starts with self.initialise().
vb = self.options.vertex_buffer # We are going to use this a lot.
master = float(max(abs(weight) for weight in lamination))
if master > MAX_DRAWABLE:
for triangle in self.triangles:
weights = [max(lamination(edge.label), 0) for edge in triangle.edges]
dual_weights = [lamination.dual_weight(edge.label) for edge in triangle.edges]
parallel_arcs = [max(-lamination(edge.label), 0) for edge in triangle.edges]
parallel_weights = [weight // 2 + (weight % 2 if edge.label >= 0 else 0) for edge, weight in zip(triangle.edges, parallel_arcs)]
for i in range(3):
# Dual arcs.
if dual_weights[i] > 0:
# We first do the edge to the left of the vertex.
# Correction factor to take into account the weight on this edge.
s_a = (1 - 2*vb) * weights[i-2] / master
# The fractions of the distance of the two points on this edge.
scale_a = (1 - s_a) / 2
scale_a2 = scale_a + s_a * dual_weights[i] / weights[i-2]
# Now repeat for the other edge of the triangle.
s_b = (1 - 2*vb) * weights[i-1] / master
scale_b = (1 - s_b) / 2
scale_b2 = scale_b + s_b * dual_weights[i] / weights[i-1]
S1, P1, Q1, E1 = curver.application.interpolate(triangle[i-1].vector, triangle[i].vector, triangle[i-2].vector, scale_a, scale_b)
S2, P2, Q2, E2 = curver.application.interpolate(triangle[i-1].vector, triangle[i].vector, triangle[i-2].vector, scale_a2, scale_b2)
self.create_curve_component([S1, S1, P1, Q1, E1, E1, E2, E2, Q2, P2, S2, S2, S1, S1], thin=False)
elif dual_weights[i] < 0: # Terminal arc.
s_0 = (1 - 2*vb) * weights[i] / master
scale_a = (1 - s_0) / 2 + s_0 * dual_weights[i-2] / weights[i]
scale_a2 = scale_a + s_0 * (-dual_weights[i]) / weights[i]
S1, P1, Q1, E1 = curver.application.interpolate(triangle[i-2].vector, triangle[i-1].vector, triangle[i].vector, scale_a, 1.0)
S2, P2, Q2, E2 = curver.application.interpolate(triangle[i-2].vector, triangle[i-1].vector, triangle[i].vector, scale_a2, 1.0)
self.create_curve_component([S1, S1, P1, E1, E1, P2, S2, S2, S1, S1], thin=False)
else: # dual_weights[i] == 0: # Nothing to draw.
pass
# Parallel arcs.
if parallel_weights[i]:
S, O, E = triangle[i-2].vector, triangle[i].vector, triangle[i-1].vector
M = (S + E) / 2.0
centroid = (S + O + E) / 3.0
P = M + 1.0 * (centroid - M) * (parallel_weights[i]+1) / master
self.create_curve_component([S, S, P, E, E, S, S], thin=False)
else: # Draw everything. Caution, this is is VERY slow (O(n) not O(log(n))) so we only do it when the weight is low.
for triangle in self.triangles:
weights = [max(lamination(edge.label), 0) for edge in triangle.edges]
dual_weights = [lamination.dual_weight(edge.label) for edge in triangle.edges]
parallel_arcs = [max(-lamination(edge.label), 0) for edge in triangle.edges]
parallel_weights = [weight // 2 + (weight % 2 if edge.label >= 0 else 0) for edge, weight in zip(triangle.edges, parallel_arcs)]
for i in range(3): # Dual arcs:
if dual_weights[i] > 0:
s_a = (1 - 2*vb) * weights[i-2] / master
s_b = (1 - 2*vb) * weights[i-1] / master
for j in range(dual_weights[i]):
scale_a = 0.5 if weights[i-2] == 1 else (1 - s_a) / 2 + s_a * j / (weights[i-2] - 1)
scale_b = 0.5 if weights[i-1] == 1 else (1 - s_b) / 2 + s_b * j / (weights[i-1] - 1)
S, P, Q, E = curver.application.interpolate(triangle[i-1].vector, triangle[i].vector, triangle[i-2].vector, scale_a, scale_b)
self.create_curve_component([S, P, Q, E])
elif dual_weights[i] < 0: # Terminal arc.
s_0 = (1 - 2*vb) * weights[i] / master
for j in range(-dual_weights[i]):
scale_a = 0.5 if weights[i] == 1 else (1 - s_0) / 2 + s_0 * dual_weights[i-1] / (weights[i] - 1) + s_0 * j / (weights[i] - 1)
S, P, Q, E = curver.application.interpolate(triangle[i-2].vector, triangle[i-1].vector, triangle[i].vector, scale_a, 1.0)
self.create_curve_component([S, P, E])
else: # dual_weights[i] == 0: # Nothing to draw.
pass
# Parallel arcs:
S, O, E = triangle[i-2].vector, triangle[i].vector, triangle[i-1].vector
M = (S + E) / 2.0
centroid = (S + O + E) / 3.0
for j in range(parallel_weights[i]):
P = M + float(j+1) / (parallel_weights[i] + 1) * (centroid - M) * (parallel_weights[i] + 1) / master
self.create_curve_component([S, P, E])
self.lamination = lamination
self.zoom_to_drawing() # Recheck.
self.redraw() # Install options.
[docs] def redraw(self):
self.create_edge_labels()
self.canvas.itemconfig('line', width=self.options.line_size)
self.canvas.itemconfig('curve', width=self.options.line_size)
# Only put arrows on the start so arrow heads appear in the middle.
for edge in self.edges:
if self.options.show_orientations and (edge.label >= 0 or self.edges[~edge.label].vertices[::-1] != edge.vertices):
self.canvas.itemconfig(edge.drawn[0], arrow='last', arrowshape=self.options.arrow_shape)
else:
self.canvas.itemconfig(edge.drawn[0], arrow='')
for edge in self.edges:
edge.hide(not self.options.show_internals and self.edges[~edge.label].vertices[::-1] == edge.vertices)
self.canvas.tag_raise('polygon')
self.canvas.tag_raise('line')
self.canvas.tag_raise('oval')
self.canvas.tag_raise('curve')
self.canvas.tag_raise('label')
self.canvas.itemconfig('curve', smooth=self.options.smooth)
######################################################################
[docs] def destroy_edge_labels(self):
self.canvas.delete('label')
[docs] def create_edge_labels(self):
self.destroy_edge_labels() # Remove existing labels.
# How to label each edge.
if self.options.label_edges == curver.application.options.LABEL_EDGES_INDEX:
labels = dict((edge.label, edge.index) for edge in self.lamination.triangulation.edges)
elif self.options.label_edges == curver.application.options.LABEL_EDGES_GEOMETRIC:
labels = dict((edge.label, self.lamination(edge)) for edge in self.lamination.triangulation.edges)
elif self.options.label_edges == curver.application.options.LABEL_EDGES_GEOMETRIC_PROJ:
labels = dict((edge.label, self.lamination(edge)) for edge in self.lamination.triangulation.edges)
total = float(sum(labels.values()))
if total != 0:
# Note the "+ 0" to ensure that -0.0 appears as 0.0.
labels = dict((label, round(labels[label] / total, 12) + 0) for label in labels)
else:
labels = dict((label, 0.0) for label in labels)
elif self.options.label_edges == curver.application.options.LABEL_EDGES_NONE:
labels = dict((edge.label, '') for edge in self.lamination.triangulation.edges)
else:
raise ValueError()
for edge in self.edges:
if edge.label >= 0 or self.edges[~edge.label].vertices[::-1] != edge.vertices:
# We start by creating a nice background for the label. This ensures
# that it is always readable, even when on top of a lamination.
# To do this we first draw this label in a different colour with
# slightly different offsets. This creates a nice 'bubble' effect
# rather than having to draw a large bounding box.
for offset in OFFSETS:
self.canvas.create_text(
[a+x for a, x in zip(edge.centre().to_tuple(), offset)],
text=labels[edge.label],
tag='label',
font=self.options.canvas_font,
fill=DEFAULT_EDGE_LABEL_BG_COLOUR
)
self.canvas.create_text(
edge.centre().to_tuple(),
text=labels[edge.label],
tag='label',
font=self.options.canvas_font,
fill=DEFAULT_EDGE_LABEL_COLOUR
)
[docs]class CurverApplication(object):
def __init__(self, parent, items):
self.parent = parent
self.options = curver.application.Options(self)
s = TTK.Style()
s.configure('TNotebook', tabposition='n')
self.note = TTK.Notebook(self.parent)
self.note.pack(fill='both', expand=True, padx=2, pady=2)
self.note.enable_traversal()
if isinstance(items, (list, tuple)) and len(items) == 1 and isinstance(items[0], (list, dict)): # [list / dict].
items = items[0]
if isinstance(items, dict): # dict.
items = [Showable(key, item) for key, item in sorted(dict(items).items(), key=lambda x: x[0])]
try: # list of pairs.
items = [Showable(key, item) for key, item in items]
except (TypeError, ValueError): # list.
items = [Showable(name, item) for name, item in curver.kernel.utilities.name_objects(items)]
self.items = items
self.canvases = [TK.Canvas(self.note, bg='#dcecff') for i in range(len(self.items))]
for canvas, item in zip(self.canvases, self.items):
self.note.add(canvas, text=item.name)
self.parent.wait_visibility(self.note) # Make sure we get the right sizes of things.
self.drawings = [Drawing(self.note, canvas, item.item, self.options) for canvas, item in zip(self.canvases, self.items)]
# Create the menus.
# Make sure to start the Lamination and Mapping class menus disabled.
self.menubar = TK.Menu(self.parent)
self.filemenu = TK.Menu(self.menubar, tearoff=0)
self.filemenu.add_command(label='Export image...', command=self.export_image)
self.filemenu.add_separator()
self.filemenu.add_command(label='Exit', command=self.quit, accelerator=COMMAND['close'])
self.menubar.add_cascade(label='File', menu=self.filemenu)
##########################################
self.settingsmenu = TK.Menu(self.menubar, tearoff=0)
self.sizemenu = TK.Menu(self.menubar, tearoff=0)
self.sizemenu.add_radiobutton(label='Small', var=self.options.size_var, value=curver.application.options.SIZE_SMALL)
self.sizemenu.add_radiobutton(label='Medium', var=self.options.size_var, value=curver.application.options.SIZE_MEDIUM)
self.sizemenu.add_radiobutton(label='Large', var=self.options.size_var, value=curver.application.options.SIZE_LARGE)
# self.sizemenu.add_radiobutton(label='Extra large', var=self.options.size_var, value=curver.application.options.SIZE_XLARGE)
self.edgelabelmenu = TK.Menu(self.menubar, tearoff=0)
self.edgelabelmenu.add_radiobutton(label=curver.application.options.LABEL_EDGES_NONE, var=self.options.label_edges_var)
self.edgelabelmenu.add_radiobutton(label=curver.application.options.LABEL_EDGES_INDEX, var=self.options.label_edges_var)
self.edgelabelmenu.add_radiobutton(label=curver.application.options.LABEL_EDGES_GEOMETRIC, var=self.options.label_edges_var)
self.edgelabelmenu.add_radiobutton(label=curver.application.options.LABEL_EDGES_GEOMETRIC_PROJ, var=self.options.label_edges_var)
self.zoommenu = TK.Menu(self.menubar, tearoff=0)
self.zoommenu.add_command(label='Zoom in', command=self.zoom_in, accelerator='+')
self.zoommenu.add_command(label='Zoom out', command=self.zoom_out, accelerator='-')
self.zoommenu.add_command(label='Zoom to drawing', command=self.zoom_to_drawing, accelerator='0')
self.settingsmenu.add_cascade(label='Sizes', menu=self.sizemenu)
self.settingsmenu.add_cascade(label='Edge label', menu=self.edgelabelmenu)
self.settingsmenu.add_cascade(label='Zoom', menu=self.zoommenu)
self.settingsmenu.add_checkbutton(label='Smooth', var=self.options.smooth_var)
self.settingsmenu.add_checkbutton(label='Show internal edges', var=self.options.show_internals_var)
self.settingsmenu.add_checkbutton(label='Show edge orientations', var=self.options.show_orientations_var)
self.menubar.add_cascade(label='Settings', menu=self.settingsmenu)
self.helpmenu = TK.Menu(self.menubar, tearoff=0)
self.helpmenu.add_command(label='Help', command=self.show_help, accelerator='F1')
self.helpmenu.add_separator()
self.helpmenu.add_command(label='About', command=self.show_about)
self.menubar.add_cascade(label='Help', menu=self.helpmenu)
self.parent.config(menu=self.menubar)
self.parent.bind(COMMAND_KEY['close'], lambda event: self.quit())
self.parent.bind('<Key>', self.parent_key_press)
self.parent.protocol('WM_DELETE_WINDOW', self.quit)
[docs] def current_drawing(self):
return self.drawings[self.note.index('current')]
[docs] def redraw(self):
for drawing in self.drawings:
drawing.redraw()
[docs] def zoom_in(self):
self.current_drawing().zoom_in()
[docs] def zoom_out(self):
self.current_drawing().zoom_out()
[docs] def zoom_to_drawing(self):
self.current_drawing().zoom_to_drawing()
[docs] def translate(self, dx, dy):
self.current_drawing().translate(dx, dy)
[docs] def parent_key_press(self, event):
key = event.keysym
if key == 'F1':
self.show_help()
elif key == 'equal' or key == 'plus':
self.zoom_in()
elif key == 'minus' or key == 'underscore':
self.zoom_out()
elif key == '0':
self.zoom_to_drawing()
elif key == 'Up':
self.translate(0, 5)
elif key == 'Down':
self.translate(0, -5)
elif key == 'Left':
self.translate(5, 0)
elif key == 'Right':
self.translate(-5, 0)
[docs] def export_image(self):
path = tkFileDialog.asksaveasfilename(defaultextension='.ps', filetypes=[('postscript files', '.ps'), ('all files', '.*')], title='Export Image')
if path:
try:
self.current_drawing().canvas.postscript(file=path, colormode='color')
except IOError:
tkMessageBox.showwarning('Export Error', 'Could not open: %s' % path)
[docs] def quit(self):
# Apparantly there are some problems with comboboxes, see:
# http://stackoverflow.com/questions/15448914/python-tkinter-ttk-combobox-throws-exception-on-quit
self.parent.eval('::ttk::CancelRepeat')
self.parent.destroy()
self.parent.quit()
[docs] def show_help(self):
pass
# curver.doc.open_documentation()
[docs] def show_about(self):
tkMessageBox.showinfo('About', 'curver (Version %s).\nCopyright (c) Mark Bell 2017.' % curver.__version__)
[docs]def start(*items):
root = TK.Tk()
root.title('curver')
root.minsize(300, 300)
root.geometry('700x500')
# Set the icon.
# Make sure to get the right path if we are in a cx_Freeze compiled executable.
# See: http://cx-freeze.readthedocs.org/en/latest/faq.html#using-data-files
datadir = os.path.dirname(sys.executable if getattr(sys, 'frozen', False) else __file__)
icon_path = os.path.join(datadir, 'icon', 'icon.gif')
img = TK.PhotoImage(file=icon_path)
try:
root.tk.call('wm', 'iconphoto', root._w, img)
except TK.TclError:
# Give up if we can't set the icon for some reason.
# This seems to be a problem if you start curver within SnapPy.
pass
CurverApplication(root, items)
root.mainloop()
if __name__ == '__main__':
start()