Commits

Robert Kern committed d00179e

HCL colormap designer.

  • Participants
  • Parent commits 3ea5ebe

Comments (0)

Files changed (2)

File colormap_explorer/hcl_design.py

+""" Tools for building colormaps using the HCL colorspace.
+"""
+
+import pprint
+import textwrap
+
+import numpy as np
+
+from enthought.chaco2 import api as chaco
+from enthought.chaco2.tools import api as tools
+from enthought.chaco2.chaco2_plot_container_editor import PlotContainerEditor
+from enthought.enable2.api import black_color_trait, ColorTrait, LineStyle
+from enthought.traits.api import (Any, Array, Enum, Event, Float, HasTraits,
+    Instance, Int, Property, Str, on_trait_change)
+from enthought.traits.ui import api as tui
+
+from colormap_explorer import conversion, hcl_opt
+
+
+def round256(palette):
+    return np.round(palette * 255).astype(np.uint8)
+
+class CLBezier(chaco.BaseXYPlot):
+    """ Create a colormap by selecting a Bezier curve through Chroma-Luminance
+    space (constant Hue).
+    """
+
+    # The control points of the cubic Bezier curve.
+    c0 = Array(value=np.array([0.0, 100.0]))
+    c1 = Array(value=np.array([50./3, 250./3]))
+    c2 = Array(value=np.array([100./3, 200./3]))
+    cf = Array(value=np.array([50.0, 50.0]))
+
+    # The number of points to sample along the curve.
+    n = Int(64)
+
+    # The evenly-sampled points along the curve.
+    even_sample = Array()
+
+
+    #### Drawing style traits ##################################################
+
+    # The color of the Bezier curve.
+    color = ColorTrait("black")
+
+    # The color of the lines between control points.
+    control_line_color = ColorTrait("gray")
+
+    # The style of the selected line.
+    selected_line_style = LineStyle("solid")
+
+    # The thickness of the line.
+    line_width = Float(1.0)
+
+    # The line dash style.
+    line_style = LineStyle
+
+    # The pixel size of the marker, not including the thickness of the outline.
+    marker_size = Int(4)
+
+
+    #### View traits ###########################################################
+
+    arc_length = Property(Float, depends_on=['even_sample'])
+
+
+    #### Private traits ########################################################
+
+    # The control points in screen space.
+    _cached_control_pts = Array()
+
+
+    def cl_points(self, resample=False):
+        """ Compute the Chroma-Luminance points.
+        """
+        if np.allclose(self.c0, self.cf):
+            cl = np.vstack([self.c0] * self.n)
+            return cl
+        if resample:
+            n = 2000
+        else:
+            n = self.n
+        t = np.linspace(0, 1, n)
+        cl = hcl_opt.cubic_bezier(t, self.c0, self.c1, self.c2, self.cf)
+        if resample:
+            u = hcl_opt.resample_bezier(cl, n=self.n)
+            cl = hcl_opt.cubic_bezier(u, self.c0, self.c1, self.c2, self.cf)
+        return cl
+
+    def _gather_points(self):
+        if self._cache_valid:
+            return
+        cl = self.cl_points(resample=True)
+        self._cached_data_pts = cl
+        self._cached_screen_pts = self.map_screen(cl)
+        self._cached_control_pts = self.map_screen(np.vstack([self.c0, self.c1, self.c2, self.cf]))
+
+        self._cache_valid = True
+
+    def _render(self, gc, points):
+        if len(points) == 0:
+            return
+
+        gc.save_state()
+        gc.set_antialias(True)
+
+        gc.clip_to_rect(self.x, self.y, self.width, self.height)
+
+        gc.set_stroke_color(self.color_)
+        gc.set_fill_color(self.color_)
+        gc.set_line_width(self.line_width)
+        gc.set_line_dash(self.line_style_)
+
+        # Draw the curve.
+        if len(points) > 0:
+            gc.begin_path()
+            gc.lines(points)
+            gc.stroke_path()
+
+        # Draw the lines between control points.
+        gc.save_state()
+        gc.set_stroke_color(self.control_line_color_)
+        gc.begin_path()
+        gc.move_to(*self._cached_control_pts[0])
+        gc.line_to(*self._cached_control_pts[1])
+        gc.move_to(*self._cached_control_pts[2])
+        gc.line_to(*self._cached_control_pts[3])
+        gc.stroke_path()
+        gc.restore_state()
+
+        # Draw the control points.
+        for sx, sy in self._cached_control_pts:
+            gc.begin_path()
+            gc.arc(sx, sy, self.marker_size, 0.0, 2*np.pi)
+            gc.fill_path()
+
+        gc.restore_state()
+
+    def _even_sample_default(self):
+        return self.cl_points(resample=True)
+
+    def _get_arc_length(self):
+        cl = self.even_sample
+        dcl = cl[1:] - cl[:-1]
+        darc = np.hypot(*dcl.T)
+        return darc.sum()
+
+    def _get_c0(self):
+        if self.start_color == 'White':
+            return np.array([0.0, 100.0])
+        else:
+            return np.array([0.0, 0.0])
+
+    def _c0_changed(self, name, old, new):
+        """ Move the middle control points, too.
+        """
+        c1 = hcl_opt.move_final_cubic(self.cf, old, new, self.c1)
+        c2 = hcl_opt.move_final_cubic(self.cf, old, new, self.c2)
+        self.trait_set(c1=c1, c2=c2, trait_change_notify=False)
+        self._recalculate()
+
+    def _cf_changed(self, name, old, new):
+        """ Move the middle control points, too.
+        """
+        c1 = hcl_opt.move_final_cubic(self.c0, old, new, self.c1)
+        c2 = hcl_opt.move_final_cubic(self.c0, old, new, self.c2)
+        self.trait_set(c1=c1, c2=c2, trait_change_notify=False)
+        self._recalculate()
+
+    def flip(self):
+        """ Flip the control points on the luminance axis.
+        """
+        c0 = np.array([self.c0[0], 100.0-self.c0[1]])
+        c1 = np.array([self.c1[0], 100.0-self.c1[1]])
+        c2 = np.array([self.c2[0], 100.0-self.c2[1]])
+        cf = np.array([self.cf[0], 100.0-self.cf[1]])
+        self.trait_set(c0=c0, c1=c1, c2=c2, cf=cf, trait_change_notify=False)
+        self._recalculate()
+
+    @on_trait_change('n,c1,c2')
+    def _recalculate(self):
+        self._cache_valid = False
+        self.even_sample = self.cl_points(resample=True)
+        self.invalidate_draw()
+        self.request_redraw()
+
+
+class DragControlPoint(tools.DragTool):
+    """ Drag the final control point.
+    """
+
+    # The control point being dragged.
+    icontrol_point = Any()
+
+    def is_draggable(self, x, y):
+        """ Returns whether the (x,y) position is in a region that is OK to 
+        drag.
+        """
+        if self.component:
+            self.component._gather_points()
+            for i, (cx, cy) in enumerate(self.component._cached_control_pts):
+                r = np.hypot(x-cx, y-cy)
+                if r <= self.component.marker_size:
+                    self.icontrol_point = i
+                    return True
+        return False
+
+    def drag_start(self, event):
+        """ Called when the drag operation starts.  
+        """
+        if self.component is not None:
+            event.window.set_mouse_owner(self, event.net_transform())
+            event.handled = True
+    
+    def dragging(self, event):
+        """ This method is called for every mouse_move event that the tool 
+        receives while the user is dragging the mouse. 
+        """
+        if self.component is not None:
+            key = {0: 'c0', 1: 'c1', 2: 'c2', 3: 'cf'}.get(self.icontrol_point, None)
+            if key is None:
+                return
+            plot = self.component
+            cxy = getattr(plot, key)
+            x = event.x
+            y = event.y
+            cl = plot.map_data([x, y], all_values=True)
+            plot.trait_set(**{key: cl})
+            event.handled = True
+
+    def drag_end(self, event):
+        """ Called when a mouse event causes the drag operation to end.
+        """
+        if self.component is not None:
+            self.icontrol_point = None
+
+
+class HCLDesigner(HasTraits):
+    """ Tool for building colormaps using the HCL colorspace.
+    """
+
+    # The current hue.
+    hue = Float(0.0)
+
+    # The current color mapper.
+    color_mapper = Any()
+
+    #### Private traits ########################################################
+
+    # The plot.
+    _colorbar = Any()
+    _plot = Any()
+    _plot_data = Instance(chaco.ArrayPlotData, args=())
+
+    # The Bezier design renderer.
+    _bezier = Any()
+
+    # The resolution of the grid for the image of the colorspace.
+    _n_luminance = Int(256)
+    _n_chroma = Int(256)
+    _luminance_bounds = Any((0.0, 100.0))
+    _chroma_bounds = Any((0.0, 250.0))
+
+    # Semi-delegates to the Bezier tool.
+    c0_0 = Property(Float, depends_on=['_bezier.c0'])
+    c0_1 = Property(Float, depends_on=['_bezier.c0'])
+    c1_0 = Property(Float, depends_on=['_bezier.c1', '_bezier.c0', '_bezier.cf'])
+    c1_1 = Property(Float, depends_on=['_bezier.c1', '_bezier.c0', '_bezier.cf'])
+    c2_0 = Property(Float, depends_on=['_bezier.c2', '_bezier.c0', '_bezier.cf'])
+    c2_1 = Property(Float, depends_on=['_bezier.c2', '_bezier.c0', '_bezier.cf'])
+    cf_0 = Property(Float, depends_on=['_bezier.cf'])
+    cf_1 = Property(Float, depends_on=['_bezier.cf'])
+
+    # Some events for buttons.
+    c0_black_event = Event()
+    c0_white_event = Event()
+    cf_black_event = Event()
+    cf_white_event = Event()
+    flip_event = Event()
+    straight_event = Event()
+    see_code_event = Event()
+
+    traits_view = tui.View(
+        tui.Group(
+            tui.Item('hue', editor=tui.RangeEditor(low=0.0, high=360.0,
+                mode='slider')),
+            tui.HGroup(
+                tui.Item('straight_event', show_label=False,
+                    editor=tui.ButtonEditor(label='Straight')),
+                tui.Item('flip_event', show_label=False,
+                    editor=tui.ButtonEditor(label='Flip')),
+                tui.spring,
+                tui.Item('object._bezier.arc_length', label='Perceptual distance', style='readonly'),
+            ),
+            tui.VGrid(
+                tui.Label('c0'),
+                tui.Item('c0_0', show_label=False),
+                tui.Item('c0_1', show_label=False),
+                tui.Item('c0_black_event', show_label=False,
+                    editor=tui.ButtonEditor(label='Black')),
+                tui.Item('c0_white_event', show_label=False,
+                    editor=tui.ButtonEditor(label='White')),
+
+                tui.Label('c1'),
+                tui.Item('c1_0', show_label=False),
+                tui.Item('c1_1', show_label=False),
+                tui.spring,
+                tui.spring,
+
+                tui.Label('c2'),
+                tui.Item('c2_0', show_label=False),
+                tui.Item('c2_1', show_label=False),
+                tui.spring,
+                tui.spring,
+                tui.Label('cf'),
+                tui.Item('cf_0', show_label=False),
+                tui.Item('cf_1', show_label=False),
+                tui.Item('cf_black_event', show_label=False,
+                    editor=tui.ButtonEditor(label='Black')),
+                tui.Item('cf_white_event', show_label=False,
+                    editor=tui.ButtonEditor(label='White')),
+                columns=5,
+            ),
+            tui.HGroup(
+                tui.Item('object._bezier.n', springy=True,
+                    editor=tui.RangeEditor(low=2, high=512, mode='slider')),
+                tui.Item('see_code_event', show_label=False,
+                    editor=tui.ButtonEditor(label='See Code')),
+            ),
+        ),
+        tui.Item('_colorbar', editor=PlotContainerEditor(), show_label=False, height=0.1),
+        tui.Item('_plot', editor=PlotContainerEditor(), show_label=False, height=0.8),
+
+        width=800,
+        height=800,
+        resizable=True,
+        title='HCL Colormap Designer',
+    )
+
+    def __init__(self, **traits):
+        super(HCLDesigner, self).__init__(**traits)
+
+        self._update_plot_for_hue(self.hue)
+        self._new_bezier()
+
+    def __bezier_default(self):
+        bp = CLBezier()
+        bp.on_trait_change(self._bezier_properties_changed, 'even_sample')
+        return bp
+
+    def __plot_default(self):
+        plot = chaco.Plot(self._plot_data)
+        plot.x_axis.title = 'Chroma'
+        plot.y_axis.title = 'Luminance'
+        plot.fill_padding = True
+        plot.bg_color = (1.0, 1.0, 1.0)
+        plot.img_plot('colors', xbounds=self._chroma_bounds,
+            ybounds=self._luminance_bounds, origin='top left')
+
+        # Add the Bezier design tool.
+        imap = chaco.LinearMapper(
+            range=plot.index_range,
+            screen_bounds=plot.index_mapper.screen_bounds,
+        )
+        vmap = chaco.LinearMapper(
+            range=plot.value_range,
+            screen_bounds=plot.value_mapper.screen_bounds,
+        )
+        self._bezier.trait_set(
+            index_mapper=imap,
+            value_mapper=vmap,
+            orientation=plot.orientation,
+            origin=plot.default_origin,
+        )
+        self._bezier.tools.append(DragControlPoint(self._bezier))
+        plot.add(self._bezier)
+        plot.plots['bezier'] = [self._bezier]
+
+        return plot
+
+    def __colorbar_default(self):
+        colorbar = chaco.Plot(self._plot_data, height=50)
+        colorbar.x_axis.visible = False
+        colorbar.y_axis.visible = False
+        colorbar.padding_bottom = 0
+        colorbar.padding_top = 0
+        colorbar.padding_left = 0
+        colorbar.padding_right = 0
+        colorbar.fill_padding = True
+        colorbar.bg_color = (1.0, 1.0, 1.0)
+        colorbar.img_plot('cmap', xbounds=(0.0, 1.0), origin='top left')
+
+        return colorbar
+
+    def colors_for_hue(self, hue):
+        """ Generate an RGBA image representing a plane slice through the HCL
+        colorspace at a given hue (and its opposite hue).
+        """
+        chroma = np.linspace(self._chroma_bounds[0], self._chroma_bounds[1],
+            self._n_chroma)
+        # Note the reversed order. This will correspond with origin='top left'
+        luminance = np.linspace(self._luminance_bounds[1],
+            self._luminance_bounds[0], self._n_luminance)
+        C, L = np.meshgrid(chroma, luminance)
+        H = hue * np.ones_like(C)
+        negative = (C < 0)
+        H[negative] = (H[negative] + 180) % 360
+        C[negative] = -C[negative]
+
+        hcl = np.dstack([H, C, L])
+        xyz = conversion.hcl2xyz(hcl)
+        srgb = conversion.xyz2srgb(xyz)
+        # Find the out-of-bounds values. We'll make them transparent.
+        alpha = np.ones_like(C)
+        out_of_bounds = ((srgb < 0.0) | (srgb > 1.0)).any(axis=-1)
+        alpha[out_of_bounds] = 0.0
+        rgba = np.dstack([srgb, alpha])
+        return rgba
+
+    def _get_c0_0(self):
+        return self._bezier.c0[0]
+    def _set_c0_0(self, val):
+        self._bezier.c0 = np.array([val, self._bezier.c0[1]])
+    def _get_c0_1(self):
+        return self._bezier.c0[1]
+    def _set_c0_1(self, val):
+        self._bezier.c0 = np.array([self._bezier.c0[0], val])
+    def _get_c1_0(self):
+        return self._bezier.c1[0]
+    def _set_c1_0(self, val):
+        self._bezier.c1 = np.array([val, self._bezier.c1[1]])
+    def _get_c1_1(self):
+        return self._bezier.c1[1]
+    def _set_c1_1(self, val):
+        self._bezier.c1 = np.array([self._bezier.c1[0], val])
+    def _get_c2_0(self):
+        return self._bezier.c2[0]
+    def _set_c2_0(self, val):
+        self._bezier.c2 = np.array([val, self._bezier.c2[1]])
+    def _get_c2_1(self):
+        return self._bezier.c2[1]
+    def _set_c2_1(self, val):
+        self._bezier.c2 = np.array([self._bezier.c2[0], val])
+    def _get_cf_0(self):
+        return self._bezier.cf[0]
+    def _set_cf_0(self, val):
+        self._bezier.cf = np.array([val, self._bezier.cf[1]])
+    def _get_cf_1(self):
+        return self._bezier.cf[1]
+    def _set_cf_1(self, val):
+        self._bezier.cf = np.array([self._bezier.cf[0], val])
+
+    def _c0_black_event_fired(self):
+        self._bezier.c0 = np.array([0.0, 0.0])
+    def _c0_white_event_fired(self):
+        self._bezier.c0 = np.array([0.0, 100.0])
+    def _cf_black_event_fired(self):
+        self._bezier.cf = np.array([0.0, 0.0])
+    def _cf_white_event_fired(self):
+        self._bezier.cf = np.array([0.0, 100.0])
+
+    def _straight_event_fired(self):
+        c1 = (2*self._bezier.c0/3.0 + self._bezier.cf/3.0)
+        c2 = (self._bezier.c0/3.0 + 2*self._bezier.cf/3.0)
+        self._bezier.c1 = c1
+        self._bezier.c2 = c2
+
+    def _flip_event_fired(self):
+        self._bezier.flip()
+        # Since the flip() method doesn't trigger trait change notifications to
+        # avoid the rotation behavior of changing c0 and cf, we need to inform
+        # our properties to change.
+        self.trait_property_changed('c0_0', None)
+        self.trait_property_changed('c0_1', None)
+        self.trait_property_changed('c1_0', None)
+        self.trait_property_changed('c1_1', None)
+        self.trait_property_changed('c2_0', None)
+        self.trait_property_changed('c2_1', None)
+        self.trait_property_changed('cf_0', None)
+        self.trait_property_changed('cf_1', None)
+
+    def _see_code_event_fired(self):
+        x = TextColormapDisplay(rgb=self.get_rgb_pallete())
+        x.edit_traits()
+
+    def _new_bezier(self):
+        rgb = self.get_rgb_pallete()
+        rgb256 = round256(rgb)
+        self._plot_data.set_data('cmap', np.array([rgb256]*50))
+
+    def _update_plot_for_hue(self, hue):
+        rgba = self.colors_for_hue(hue)
+        rgba256 = round256(rgba)
+        self._plot_data.set_data('colors', rgba256)
+
+    def _hue_changed(self, new):
+        self._update_plot_for_hue(new)
+        self._new_bezier()
+        self._plot.request_redraw()
+        self._colorbar.request_redraw()
+
+    def _bezier_properties_changed(self):
+        self._new_bezier()
+        self._plot.request_redraw()
+        self._colorbar.request_redraw()
+
+    def get_rgb_pallete(self):
+        """ Get the RGB colors for the current colormap palette.
+        """
+        cl = self._bezier.even_sample
+        hue = self.hue
+        hcl = np.hstack([[[self.hue]] * len(cl), cl])
+        rgb = conversion.rgb2rgbp(conversion.xyz2rgb(conversion.hcl2xyz(hcl)).clip(0,1))
+        return rgb
+
+
+class TextColormapDisplay(HasTraits):
+    """ Display the code for a generated colormap.
+    """
+
+    # The color palette.
+    rgb = Array()
+
+    # The name of the color palette.
+    name = Str('colormap')
+
+    # The kind of colormap.
+    kind = Enum('Chaco', 'Mayavi')
+
+    # The generated code.
+    code = Property(Str, depends_on=['rgb', 'name', 'kind'])
+
+
+    traits_view = tui.View(
+        tui.VGroup(
+            tui.Item('name'),
+            tui.HGroup(
+                tui.Item('kind'),
+                tui.spring,
+            ),
+        ),
+        tui.Item('code', editor=tui.CodeEditor(), style='readonly', show_label=False),
+
+        width=600,
+        height=400,
+        resizable=True,
+        title='Colormap Code',
+    )
+
+    def gen_chaco_cmap(self, name='colormap'):
+        """ Generate Python code for a Chaco colormap.
+        """
+        lrgb = self.rgb.tolist()
+        lines = ['array([']
+        for line in lrgb:
+            lines.append(' '*8 + repr(line) + ',')
+        lines.append('    ])')
+        palette_text = '\n'.join(lines)
+
+        template = textwrap.dedent("""\
+            def %s(range, **traits):
+                from numpy import array
+                from enthought.chaco2.api import ColorMapper
+                cm = ColorMapper.from_palette_array(%s)
+                return cm
+        """)
+        text = template % (name, palette_text)
+        return text
+
+    def gen_mayavi_cmap(self, name='colormap'):
+        """ Generate a .lut file for Mayavi.
+        """
+        lines = ['LOOKUP_TABLE %s %s' % (name, len(self.rgb))]
+        for row in self.rgb:
+            lines.append('%1.16f %1.16f %1.16f 1.0' % tuple(row))
+        text = '\n'.join(lines)
+        return text
+
+
+    def _get_code(self):
+        if self.kind == 'Chaco':
+            code = self.gen_chaco_cmap(self.name)
+        elif self.kind == 'Mayavi':
+            code = self.gen_mayavi_cmap(self.name)
+        else:
+            code = ''
+        return code
+
+
+if __name__ == '__main__':
+    np.seterr(all='warn')
+    hd = HCLDesigner()
+    hd.configure_traits()

File colormap_explorer/hcl_opt.py

+
+import numpy as np
+from scipy import interpolate, optimize
+
+from enthought.traits.api import Float, HasTraits
+from enthought.traits.ui import api as tui
+from enthought.chaco2.shell import plot
+from enthought.chaco2 import api as chaco
+
+from colormap_explorer import conversion
+
+
+h360 = np.linspace(0, 360, 361)
+c175 = np.linspace(0, 175, 176)
+
+def hcl_rainbow(chroma, lightness):
+    hcl = np.empty((len(h360), 3))
+    hcl[:,0] = h360
+    hcl[:,1] = chroma
+    hcl[:,2] = lightness
+    return hcl
+
+
+def out_of_gamut(hcl):
+    rgb = conversion.xyz2rgb(conversion.hcl2xyz(hcl))
+    extent = np.maximum((rgb - 1.0).max(-1), (-rgb).max(-1))
+    return extent
+
+def find_chroma(lightness):
+    def f(chroma):
+        hcl = hcl_rainbow(chroma, lightness)
+        return out_of_gamut(hcl).max()
+    chroma = optimize.bisect(f, 0., 175.)
+    return chroma
+
+def cubic_bezier(t, xy0, xy1, xy2, xy3):
+    t = t[:,np.newaxis]
+    t2 = t*t
+    t3 = t2*t
+
+    u = 1.0 - t
+    u2 = u*u
+    u3 = u2*u
+
+    xy = xy0*u3 + 3*(xy1*t*u2 + xy2*t2*u) + xy3*t3
+    return xy
+
+def quad_bezier(t, xy0, xyc, xyf):
+    xy0 = np.asarray(xy0)
+    xyc = np.asarray(xyc)
+    xyf = np.asarray(xyf)
+    # Convert it to a cubic.
+    xy1 = (xy0 + xyc + xyc) / 3.0
+    xy2 = (xyc + xyc + xyf) / 3.0
+    return cubic_bezier(t, xy0, xy1, xy2, xyf)
+
+def cl_bezier(cl0, clf, alpha, off, n=256, resample=False, left=True):
+    """ Evaluate a quadratic Bezier curve in chroma-luminance space.
+
+    Parameters
+    ----------
+    cl0 : float array (2,)
+    clf : float array (2,)
+        Initial and final point.
+    alpha : float in [0.0, 1.0]
+    off : float >= 0
+        The middle control point of the quadratic Bezier is specified by the
+        average of the two points and a perpendicular offset from the line.
+    n : int, optional
+        The number of points to evaluate.
+    resample : bool, optional
+        If True, then resample to get an even-arc-length sampling.
+    left : bool, optional
+        The middle control point is on the left of the vector from cl0 to clf.
+
+    Returns
+    -------
+    cl : float array (n, 2)
+        Chroma, Luminance pairs.
+    clc : float array (2,)
+        The center control point.
+    """
+    cl0 = np.asarray(cl0)
+    clf = np.asarray(clf)
+    if resample:
+        m = n*4
+    else:
+        m = n
+    t = np.linspace(0, 1, m)
+    midpoint = (1-alpha) * cl0 + alpha * clf
+    dcl = clf - cl0
+    ncl = dcl / np.hypot(dcl[0], dcl[1])
+    if left:
+        sign = +1
+    else:
+        sign = -1
+    clc = midpoint + sign*off*np.array([-ncl[1], ncl[0]])
+    cl = quadbezier(t, cl0, clc, clf)
+    if resample:
+        t1 = resample_bezier(cl, n)
+        cl = quadbezier(t1, cl0, clc, clf)
+    return cl, clc
+
+def resample_bezier(points, n=256):
+    """ Resample a Bezier curve to be approximately evenly-sampled along the
+    arc-length.
+
+    Parameters
+    ----------
+    points : float array (m, 2)
+    n : int, optional
+        The number of desired points.
+
+    Returns
+    -------
+    t : float array (n,)
+    """
+    m = len(points)
+    dxy = points[1:] - points[:-1]
+    darc = np.hypot(dxy[:,0], dxy[:,1])
+    cumarc = np.hstack([[0], darc.cumsum()])
+    cumarc /= cumarc[-1]
+    intrp = interpolate.interp1d(cumarc, np.linspace(0, 1, len(cumarc)))
+    t = intrp(np.linspace(0, 1, n))
+    return t
+
+def move_final_cubic(c0, cf0, cf1, cm):
+    """ Keeping the first control point fixed and moving the final control
+    point, adjust a middle control point.
+    """
+    dc0 = cf0 - c0
+    len0 = np.hypot(*dc0)
+    dm = cm - c0
+    lenm = np.hypot(*dm)
+    dc1 = cf1 - c0
+    len1 = np.hypot(*dc1)
+    if np.allclose(c0, cf0):
+        alpha = 0.5
+        offset = lenm
+        old0 = True
+    else:
+        n0 = dc0 / len0
+        pn0 = np.array([-n0[1], n0[0]])
+        alpha = np.dot(dm, n0) / len0
+        offset = np.dot(dm, pn0) / len0
+        old0 = False
+
+    if np.allclose(c0, cf1):
+        if old0:
+            # Okay, nothing really changed.
+            return cm
+        n1 = n0
+        pn1 = pn0
+    else:
+        n1 = dc1 / len1
+        pn1 = np.array([-n1[1], n1[0]])
+
+    return c0 + (alpha * n1 + offset * pn1) * len1
+
+