#
# CanvasObject.py -- base class for shapes drawn on ginga canvases.
#
# This is open-source software licensed under a BSD license.
# Please see the file LICENSE.txt for details.
#
import numpy as np
from collections import namedtuple
import copy
from ginga.misc import Callback, Bunch
from ginga import trcalc, colors
from . import coordmap
__all__ = ['CanvasObjectBase', 'get_canvas_type', 'get_canvas_types',
'register_canvas_type', 'register_canvas_types']
colors_plus_none = [None] + colors.get_colors()
coord_names = ['data', 'wcs', 'cartesian', 'percentage', 'window']
Point = namedtuple('Point', ['x', 'y'])
class EditPoint(Point):
edit_color = 'yellow'
class MovePoint(EditPoint):
edit_color = 'orangered'
class ScalePoint(EditPoint):
edit_color = 'green'
class RotatePoint(EditPoint):
edit_color = 'skyblue'
class CanvasObjectError(Exception):
pass
[docs]
class CanvasObjectBase(Callback.Callbacks):
"""This is the abstract base class for a CanvasObject. A CanvasObject
is an item that can be placed on a Ginga canvas.
This class defines common methods used by all such objects.
"""
def __init__(self, **kwdargs):
if not hasattr(self, 'cb'):
Callback.Callbacks.__init__(self)
self.cap = 'ball'
self.cap_radius = 4
self.editable = True
self.pickable = False
self.coord = None
self.ref_obj = None
self.__dict__.update(kwdargs)
self.data = None
self.crdmap = None
self.tag = None
if not hasattr(self, 'kind'):
self.kind = None
# For debugging
self.name = kwdargs.get('name', None)
self.viewer = None
# For callbacks
for name in ('edited', 'pick-down', 'pick-move', 'pick-up',
'pick-hover', 'pick-enter', 'pick-leave',
'pick-key'):
self.enable_callback(name)
[docs]
def initialize(self, canvas, viewer, logger):
self.viewer = viewer
self.logger = logger
if self.crdmap is None:
if self.coord is None:
if canvas is not None and canvas.crdmap is not None:
# inherit crdmap of canvas/compound obj if there is one
self.crdmap = canvas.crdmap
else:
# default mapping is to data coordinates
self.crdmap = coordmap.DataMapper(viewer)
else:
#<-- named coordinate scheme
if self.coord == 'offset':
self.crdmap = coordmap.OffsetMapper(viewer, self.ref_obj)
else:
self.crdmap = viewer.get_coordmap(self.coord)
[docs]
def sync_state(self):
"""This method called when changes are made to the parameters.
subclasses should override if they need any special state handling.
"""
pass
[docs]
def set_data(self, **kwdargs):
if self.data is None:
self.data = Bunch.Bunch(kwdargs)
else:
self.data.update(kwdargs)
[docs]
def get_data(self, *args):
if len(args) == 0:
return self.data
elif len(args) == 1:
return self.data[args[0]]
elif len(args) == 2:
try:
return self.data[args[0]]
except KeyError:
return args[1]
else:
raise CanvasObjectError(
"method get_data() takes at most 2 arguments")
[docs]
def use_coordmap(self, mapobj):
self.crdmap = mapobj
[docs]
def canvascoords(self, viewer, data_x, data_y):
return viewer.get_canvas_xy(data_x, data_y)
[docs]
def is_compound(self):
return False
[docs]
def copy(self, share=[]):
obj = copy.copy(self)
obj.viewer = None
if 'data' not in share:
obj.data = None
return obj
[docs]
def contains_pts(self, points):
contains = np.asarray([False] * len(points))
return contains
[docs]
def contains_pt(self, pt):
pts = np.asarray([pt])
res = self.contains_pts(pts)
if res is None:
# TODO: this *should* never be None, because contains_pts
# should always return an array (which might be 0 length,
# if there are no points)
# Need to figure out why we receive this at times
#raise ValueError("Received None for contains_pts")
return False
if len(res) == 0:
return False
return res[0]
[docs]
def select_contains_pt(self, viewer, pt):
return self.contains_pt(pt)
[docs]
def draw_arrowhead(self, cr, x1, y1, x2, y2):
i1, j1, i2, j2 = self.calc_vertexes(x1, y1, x2, y2)
alpha = getattr(self, 'alpha', 1.0)
fill = cr.get_fill(self.color, alpha=alpha)
cr.draw_polygon(((x2, y2), (i1, j1), (i2, j2)), fill=fill)
[docs]
def draw_caps(self, cr, cap, points, radius=None):
alpha = getattr(self, 'alpha', 1.0)
bfill = cr.get_fill('black', alpha=alpha)
if radius is None:
radius = self.cap_radius
for pt in points:
cx, cy = pt[:2]
if cap == 'ball':
color = getattr(self, 'color', 'black')
# Draw edit control points in different colors than the others
if isinstance(pt, EditPoint):
r = cr.renderer.calc_const_len(radius + 2.0)
cr.draw_circle(cx, cy, r, fill=bfill)
color = pt.edit_color
cr.set_fill(color, alpha=alpha)
r = cr.renderer.calc_const_len(radius)
cr.draw_circle(cx, cy, r, fill=cr.fill)
[docs]
def draw_edit(self, cr, viewer):
bbox = self.get_bbox()
cpoints = self.get_cpoints(viewer, points=bbox, no_rotate=True)
bline = cr.set_line(color='cyan', linewidth=1, linestyle='dash')
cr.draw_polygon(cpoints, line=bline)
points = self.get_edit_points(viewer)
cpoints = self.get_cpoints(viewer, points=points)
# preserve point types for coloring
def _map_cpt(pt, cpt):
if isinstance(pt, EditPoint):
return pt.__class__(*cpt)
return cpt
cpoints = tuple([_map_cpt(points[i], cpoints[i])
for i in range(len(points))])
self.draw_caps(cr, 'ball', cpoints)
[docs]
def calc_radius(self, viewer, p1, p2):
x1, y1 = p1[:2]
x2, y2 = p2[:2]
# TODO: the accuracy of this calculation of radius might be improved?
radius = np.sqrt(abs(y2 - y1)**2 + abs(x2 - x1)**2)
return (x1, y1, radius)
[docs]
def calc_vertexes(self, start_cx, start_cy, end_cx, end_cy,
arrow_length=10, arrow_degrees=0.35):
# TODO: make adjustments to allow for linewidth
angle = np.arctan2(end_cy - start_cy, end_cx - start_cx) + np.pi
cx1 = end_cx + arrow_length * np.cos(angle - arrow_degrees)
cy1 = end_cy + arrow_length * np.sin(angle - arrow_degrees)
cx2 = end_cx + arrow_length * np.cos(angle + arrow_degrees)
cy2 = end_cy + arrow_length * np.sin(angle + arrow_degrees)
return (cx1, cy1, cx2, cy2)
[docs]
def swapxy(self, x1, y1, x2, y2):
if x2 < x1:
x1, x2 = x2, x1
if y2 < y1:
y1, y2 = y2, y1
return (x1, y1, x2, y2)
[docs]
def scale_font(self, viewer):
scale = viewer.get_scale_max()
basesize = getattr(self, 'fontsize', 12.0)
if basesize is None:
basesize = 12.0
min_size = getattr(self, 'fontsize_min', 2.0)
n = 1.4
fontsize = max(min_size, basesize + np.log(scale) / np.log(n))
max_size = getattr(self, 'fontsize_max', None)
if max_size is not None:
fontsize = min(max_size, fontsize)
return fontsize
[docs]
def get_points(self):
"""Get the set of points that is used to draw the object.
Points are returned in *data* coordinates.
"""
if hasattr(self, 'points'):
points = self.crdmap.to_data(self.points)
else:
points = []
return points
[docs]
def get_data_points(self, points=None):
"""Points returned are in data coordinates."""
if points is None:
points = self.points
points = self.crdmap.to_data(points)
return points
[docs]
def set_data_points(self, points):
"""
Input `points` must be in data coordinates, will be converted
to the coordinate space of the object and stored.
"""
self.points = np.asarray(self.crdmap.data_to(points))
[docs]
def rotate_deg(self, thetas, offset):
points = np.asarray(self.get_data_points(), dtype=np.double)
points = trcalc.rotate_coord(points, thetas, offset)
self.set_data_points(points)
[docs]
def rotate_by_deg(self, thetas):
ref_pt = self.get_reference_pt()
self.rotate_deg(thetas, ref_pt)
[docs]
def rerotate_by_deg(self, thetas, detail):
ref_pt = detail.center_pos
points = np.asarray(detail.points, dtype=np.double)
points = trcalc.rotate_coord(points, thetas, ref_pt)
self.set_data_points(points)
[docs]
def move_delta_pt(self, off_pt):
points = np.asarray(self.get_data_points(), dtype=np.double)
points = np.add(points, off_pt)
self.set_data_points(points)
[docs]
def move_to_pt(self, dst_pt):
ref_pt = self.get_reference_pt()
off_pt = np.subtract(dst_pt, ref_pt)
self.move_delta_pt(off_pt)
[docs]
def get_num_points(self):
return len(self.points)
[docs]
def set_point_by_index(self, i, pt):
#self.points[i] = self.crdmap.data_to(pt)
# Can we eventually use something like the above?
points = np.asarray(self.points, dtype=float)
points[i] = self.crdmap.data_to(pt)
self.points = points
[docs]
def get_point_by_index(self, i):
return self.crdmap.to_data(self.points[i])
[docs]
def scale_by_factors(self, factors):
ctr_pt = self.get_center_pt()
pts = np.asarray(self.get_data_points(), dtype=np.double)
pts = np.add(np.multiply(np.subtract(pts, ctr_pt), factors), ctr_pt)
self.set_data_points(pts)
[docs]
def rescale_by_factors(self, factors, detail):
ctr_pt = detail.center_pos
pts = np.asarray(detail.points, dtype=np.double)
pts = np.add(np.multiply(np.subtract(pts, ctr_pt), factors), ctr_pt)
self.set_data_points(pts)
[docs]
def setup_edit(self, detail):
"""subclass should override as necessary."""
detail.center_pos = self.get_center_pt()
[docs]
def calc_rotation_from_pt(self, pt, detail):
x, y = pt[:2]
ctr_x, ctr_y = detail.center_pos[:2]
start_x, start_y = detail.start_pos[:2]
# calc angle of starting point wrt origin
deg1 = np.degrees(np.arctan2(start_y - ctr_y,
start_x - ctr_x))
# calc angle of current point wrt origin
deg2 = np.degrees(np.arctan2(y - ctr_y, x - ctr_x))
delta_deg = deg2 - deg1
return delta_deg
[docs]
def calc_scale_from_pt(self, pt, detail):
x, y = pt[:2]
ctr_x, ctr_y = detail.center_pos[:2]
start_x, start_y = detail.start_pos[:2]
dx, dy = start_x - ctr_x, start_y - ctr_y
# calc distance of starting point wrt origin
dist1 = np.sqrt(dx**2.0 + dy**2.0)
dx, dy = x - ctr_x, y - ctr_y
# calc distance of current point wrt origin
dist2 = np.sqrt(dx**2.0 + dy**2.0)
scale_f = dist2 / dist1
return scale_f
[docs]
def calc_dual_scale_from_pt(self, pt, detail):
x, y = pt[:2]
ctr_x, ctr_y = detail.center_pos[:2]
start_x, start_y = detail.start_pos[:2]
# calc distance of starting point wrt origin
dx, dy = start_x - ctr_x, start_y - ctr_y
# calc distance of current point wrt origin
ex, ey = x - ctr_x, y - ctr_y
scale_x, scale_y = float(ex) / dx, float(ey) / dy
return scale_x, scale_y
[docs]
def convert_mapper(self, tomap):
"""
Converts our object from using one coordinate map to another.
NOTE: In some cases this only approximately preserves the
equivalent point values when transforming between coordinate
spaces.
"""
frommap = self.crdmap
if frommap == tomap:
return
# mild hack to convert radii on objects that have them
if hasattr(self, 'radius'):
# get coordinates of a point radius away from center
# under current coordmap
x0, y0 = frommap.offset_pt((self.x, self.y), (self.radius, 0))
pts = frommap.to_data(((self.x, self.y), (x0, y0)))
pts = tomap.data_to(pts)
self.radius = np.fabs(pts[1][0] - pts[0][0])
elif hasattr(self, 'xradius'):
# similar to above case, but there are 2 radii
x0, y0 = frommap.offset_pt((self.x, self.y), (self.xradius,
self.yradius))
pts = frommap.to_data(((self.x, self.y), (x0, y0)))
pts = tomap.data_to(pts)
self.xradius = np.fabs(pts[1][0] - pts[0][0])
self.yradius = np.fabs(pts[1][1] - pts[0][1])
# mild hack to convert width on objects that have them
if hasattr(self, 'width'):
# get coordinates of a point 'width' unit away from center
# under current coordmap
x0, y0 = frommap.offset_pt((self.x, self.y), (self.width, 0))
pts = frommap.to_data(((self.x, self.y), (x0, y0)))
pts = tomap.data_to(pts)
self.width = np.fabs(pts[1][0] - pts[0][0])
elif hasattr(self, 'xwidth'):
# similar to above case, but there are 2 widths, for X and Y
x0, y0 = frommap.offset_pt((self.x, self.y), (self.xwidth,
self.ywidth))
pts = frommap.to_data(((self.x, self.y), (x0, y0)))
pts = tomap.data_to(pts)
self.xwidth = np.fabs(pts[1][0] - pts[0][0])
self.ywidth = np.fabs(pts[1][1] - pts[0][1])
data_pts = self.get_data_points()
# set our map to the new map
self.crdmap = tomap
self.set_data_points(data_pts)
[docs]
def point_within_radius(self, points, pt, canvas_radius,
scales=(1.0, 1.0)):
"""Points `points` and point `pt` are in data coordinates.
Return True for points within the circle defined by
a center at point `pt` and within canvas_radius.
"""
scale_x, scale_y = scales
x, y = pt
a_arr, b_arr = np.asarray(points).T
dx = np.fabs(x - a_arr) * scale_x
dy = np.fabs(y - b_arr) * scale_y
new_radius = np.sqrt(dx**2 + dy**2)
res = (new_radius <= canvas_radius)
return res
[docs]
def within_radius(self, viewer, points, pt, canvas_radius):
"""Points `points` and point `pt` are in data coordinates.
Return True for points within the circle defined by
a center at point `pt` and within canvas_radius.
The distance between points is scaled by the canvas scale.
"""
scales = viewer.get_scale_xy()
return self.point_within_radius(points, pt, canvas_radius,
scales)
[docs]
def get_pt(self, viewer, points, pt, canvas_radius=None):
"""Takes an array of points `points` and a target point `pt`.
Returns the first index of the point that is within the
radius of the target point. If none of the points are within
the radius, returns None.
"""
if canvas_radius is None:
canvas_radius = self.cap_radius
if hasattr(self, 'rot_deg'):
# rotate point back to cartesian alignment for test
ctr_pt = self.get_center_pt()
pt = trcalc.rotate_coord(pt, [-self.rot_deg], ctr_pt)
res = self.within_radius(viewer, points, pt, canvas_radius)
return np.flatnonzero(res)
[docs]
def point_within_line(self, points, p_start, p_stop, canvas_radius):
# TODO: is there an algorithm with the cross and dot products
# that is more efficient?
r = canvas_radius
x1, y1 = p_start
x2, y2 = p_stop
xmin, xmax = min(x1, x2) - r, max(x1, x2) + r
ymin, ymax = min(y1, y2) - r, max(y1, y2) + r
div = np.sqrt((x2 - x1)**2 + (y2 - y1)**2)
a_arr, b_arr = np.asarray(points).T
d = np.fabs((x2 - x1) * (y1 - b_arr) - (x1 - a_arr) * (y2 - y1)) / div
contains = np.logical_and(
np.logical_and(xmin <= a_arr, a_arr <= xmax),
np.logical_and(d <= canvas_radius,
np.logical_and(ymin <= b_arr, b_arr <= ymax)))
return contains
[docs]
def within_line(self, viewer, points, p_start, p_stop, canvas_radius):
"""Points `points` and line endpoints `p_start`, `p_stop` are in
data coordinates.
Return True for points within the line defined by a line from
p_start to p_end and within `canvas_radius`.
The distance between points is scaled by the viewer's canvas scale.
"""
scale_x, scale_y = viewer.get_scale_xy()
new_radius = canvas_radius * 1.0 / min(scale_x, scale_y)
return self.point_within_line(points, p_start, p_stop, new_radius)
[docs]
def get_center_pt(self):
"""Return the geometric average of points as data_points.
"""
P = np.asarray(self.get_data_points(), dtype=np.double)
x = P[:, 0]
y = P[:, 1]
ctr_x = np.sum(x) / float(len(x))
ctr_y = np.sum(y) / float(len(y))
return ctr_x, ctr_y
[docs]
def get_reference_pt(self):
return self.get_center_pt()
[docs]
def get_move_scale_rotate_pts(self, viewer):
"""Returns 3 edit control points for editing this object: a move
point, a scale point and a rotate point. These points are all in
data coordinates.
"""
scale = viewer.get_scale_min()
ref_x, ref_y = self.get_center_pt()
xl, yl, xu, yu = self.get_llur()
offset = 8.0 / scale
scl_x, scl_y = xl - offset, yl - offset
rot_x, rot_y = xu + offset, yu + offset
if hasattr(self, 'rot_deg'):
# if this is an object with a rotation attribute, pre rotate
# the control points in the opposite direction, because they
# will be rotated back
theta = -self.rot_deg
scl_x, scl_y = trcalc.rotate_pt(scl_x, scl_y, theta,
xoff=ref_x, yoff=ref_y)
rot_x, rot_y = trcalc.rotate_pt(rot_x, rot_y, theta,
xoff=ref_x, yoff=ref_y)
move_pt = MovePoint(ref_x, ref_y)
scale_pt = ScalePoint(scl_x, scl_y)
rotate_pt = RotatePoint(rot_x, rot_y)
return (move_pt, scale_pt, rotate_pt)
[docs]
def get_cpoints(self, viewer, points=None, no_rotate=False):
# If points are passed, they are assumed to be in data space
if points is None:
points = self.get_points()
if (not no_rotate) and hasattr(self, 'rot_deg') and self.rot_deg != 0.0:
# rotate vertices according to rotation
ctr_x, ctr_y = self.get_center_pt()
points = trcalc.rotate_coord(points, [self.rot_deg], (ctr_x, ctr_y))
crdmap = viewer.get_coordmap('native')
return crdmap.data_to(points)
[docs]
def get_bbox(self, points=None):
"""
Get bounding box of this object.
Returns
-------
(p1, p2, p3, p4): a 4-tuple of the points in data coordinates,
beginning with the lower-left and proceeding counter-clockwise.
"""
if points is None:
x1, y1, x2, y2 = self.get_llur()
return ((x1, y1), (x1, y2), (x2, y2), (x2, y1))
else:
return trcalc.strip_z(trcalc.get_bounds(points))
[docs]
def get_llur(self):
a, b = trcalc.get_bounds(self.get_data_points())
return (a[0], a[1], b[0], b[1])
# this is the data structure to which drawing classes are registered
drawCatalog = Bunch.Bunch(caseless=True)
[docs]
def get_canvas_types():
# force registration of all canvas types
import ginga.canvas.types.all # noqa
return drawCatalog
[docs]
def get_canvas_type(name):
# force registration of all canvas types
import ginga.canvas.types.all # noqa
return drawCatalog[name]
[docs]
def register_canvas_type(name, klass):
global drawCatalog
drawCatalog[name] = klass
[docs]
def register_canvas_types(klass_dict):
global drawCatalog
drawCatalog.update(klass_dict)
# funky boolean converter
_bool = lambda st: str(st).lower() == 'true' # noqa
# color converter
_color = lambda name: name # noqa
# END