Parabolic motion for Time Machine (translate + zoom environment)

When moving between two far-away, zoomed-in locations, zoom out first to a "context point" from which both the source and destination points can be seen.


In [6]:
from scipy import *
from pylab import *
from pandas import *
%matplotlib inline
matplotlib.rcParams['figure.figsize'] = [10,8]

In [128]:
def interpolate(a, b, frac):
    return (b - a) * frac + a

class Point3:
    """3D point"""
    def __init__(self, x, y, z):
        self.x = x
        self.y = y
        self.z = z
        
    @staticmethod
    def interpolate(a, b, frac):
        return Point3(interpolate(a.x, b.x, frac),
                      interpolate(a.y, b.y, frac),
                      interpolate(a.z, b.z, frac))
    
    def subtract(self, rhs):
        return Point3(self.x - rhs.x,
                      self.y - rhs.y,
                      self.z - rhs.z)
    
    def length(self):
        return sqrt(self.x * self.x + self.y * self.y + self.z * self.z)
        
    def eval(self, u):
        return Point3(self.x.eval(u), self.y.eval(u), self.z.eval(u))
    
    def __repr__(self):
        return "(%g,%g,%g)" % (self.x, self.y, self.z)
    
class Line2:
    """y = ax + b"""
    def __init__(self, a, b):
        self.a = a
        self.b = b
        
    @staticmethod
    def from_intercepts(x1, y1, x2, y2):
        # y1 = a x1 + b
        # y2 = a x2 + b
        # y1 - a x1 = y2 - a x2
        # a x1 - a x2 = y1 - y2
        # a (x1 - x2) = y1 - y2
        # a = (y1 - y2) / (x1 - x2)
        a = (y1 - y2) / float(x1 - x2)
        b = y1 - a * x1
        return Line2(a, b)
        
    def eval(self, x):
        return self.a * x + self.b
        
    def __repr__(self):
        return "(%gx+%g)" % (self.a, self.b)


class Parabola2:
    # Simplified parabola y = ax^2 + c
    # Vertex is at (0, c)
    # Intersects (+-1, c + a)
    
    def __init__(self, a, c):
        self.a = a
        self.c = c
        
    def eval(self, x):
        return self.a * x ** 2 + self.c
        
    def __repr__(self):
        return "(%g(x^2)+%g)" % (self.a, self.c)

# Compute "zoom length" of segment from p1 to p1 + frac * p2 - p1
# Zoom length is path length scaled inversely with z,
# since apparent motion on screen also varies inversely with z

def fractionalZoomLength(p1, p2, frac):
    length = p1.subtract(p2).length()
    dz = p2.z - p1.z
    if fabs(dz) < 1e-10:
        return frac * length / p1.z;
    else:
        return length / dz * (log(dz * frac + p1.z) - log(p1.z))

# Compute "zoom length" of segment from p1 to p2.
# Zoom length is path length scaled inversely with z,
# since apparent motion on screen also varies inversely with z

def zoomLength(p1, p2):
    return fractionalZoomLength(p1, p2, 1)

# Compute point that's dist zoom distance from p1 along the path to p2.

def zoomInterpolate(p1, p2, dist):
    length = p1.subtract(p2).length()
    dz = p2.z - p1.z
    if fabs(dz) < 1e-10:
        frac = dist / length * p1.z
    else:
        frac = (exp(dz * dist / length + log(p1.z)) - p1.z) / dz
    return Point3.interpolate(p1, p2, frac)

def testZoomInterpolate(p1, p2, dist):
    interp = zoomInterpolate(p1, p2, dist)
    print "Requested dist %g actual %g" % (dist, zoomLength(p1, interp))

In [129]:
testZoomInterpolate(Point3(0,0,2), Point3(2,2,2.0001), 1)


Requested dist 1 actual 1

In [130]:
testZoomInterpolate(Point3(0,0,2), Point3(1,1,5), 1.2)


Requested dist 1.2 actual 1.2

In [131]:
testZoomInterpolate(Point3(1,1,5), Point3(0,0,2), .3)


Requested dist 0.3 actual 0.3

In [132]:
testZoomInterpolate(Point3(5,6,7), Point3(10,20,30), 1)


Requested dist 1 actual 1

In [299]:
def computePath(a, b):
    """Compute path from a to b and return as a list of points"""
    
    # First, compute a "context point" from which both a and b are visible
    xydist = sqrt((a.x - b.x) ** 2 + (a.y - b.y) ** 2)
    zdist = fabs(a.z - b.z)
    ctx_height = 0.5 * (xydist - zdist)

    if ctx_height > 0:
        # Zoom out from a to the context point (parabola vertex) and then zoom in to b
        ctx_z = ctx_height + max(a.z, b.z)
        ctx = Point3.interpolate(a, b, (ctx_z - a.z) / xydist)
        ctx.z = ctx_z
        p1 = Point3(Line2.from_intercepts(-1, a.x, 0, ctx.x), # (-1, a.x) - (0, ctx.x)
                   Line2.from_intercepts(-1, a.y, 0, ctx.y), # (-1, a.y) - (0, ctx.y)
                   Parabola2(a.z - ctx.z, ctx.z)) # (-1, a.z) - (0, ctx.z)
        p2 = Point3(Line2.from_intercepts(0, ctx.x, 1, b.x), # (0, ctx.x) - (1, b.x)
                   Line2.from_intercepts(0, ctx.y, 1, b.y), # (0, ctx.y) - (1, b.y)
                   Parabola2(b.z - ctx.z, ctx.z)) # (0, ctx.z) - (1, b.z)
        return [(p1 if u < 0 else p2).eval(u) for u in linspace(-1, 1, 21)]
    elif 2 * xydist > zdist + 1e-10:
        # No context point, but can follow parabolic zoom in or out without vertex
        if a.z < b.z:
            # This code only works for zooming in;  convert zooming out to zooming in
            return list(reversed(computePath(b, a)))
        c = (xydist ** 2) / (2.0 * xydist - zdist)
        p = Point3(Line2.from_intercepts(c - xydist, a.x, c, b.x),
                  Line2.from_intercepts(c - xydist, a.y, c, b.y),
                  Parabola2(-1.0 / c, c + b.z))
        return [p.eval(u) for u in linspace(c - xydist, c, 11)]
    else:
        # Zoom is much larger than translation;  go in a straight line
        return [a, b]

Translation > zoom; use 2-phase parabolic path with "context point" at vertex


In [300]:
# Zooming out
path = computePath(Point3(2, 1, 1), Point3(6, 2, 2))
plot([pt.x for pt in path], [pt.z for pt in path])
plot([pt.y for pt in path], [pt.z for pt in path])


Out[300]:
[<matplotlib.lines.Line2D at 0x10e090450>]

In [301]:
# Zooming in
path = computePath(Point3(2, 1, 2), Point3(6, 2, 1))
plot([pt.x for pt in path], [pt.z for pt in path])
plot([pt.y for pt in path], [pt.z for pt in path])


Out[301]:
[<matplotlib.lines.Line2D at 0x10e057c10>]

Zoom > translation; no context point, but can follow parabolic zoom in or out without vertex


In [302]:
# Zoom in
path = computePath(Point3(2, 1, 14.7), Point3(3, 2, 13))
plot([pt.x for pt in path], [pt.z for pt in path])
plot([pt.y for pt in path], [pt.z for pt in path])


Out[302]:
[<matplotlib.lines.Line2D at 0x10e799b90>]

In [303]:
# Zoom out
path = computePath(Point3(2, 1, 13), Point3(3, 2, 14.7))
plot([pt.x for pt in path], [pt.z for pt in path])
plot([pt.y for pt in path], [pt.z for pt in path])


Out[303]:
[<matplotlib.lines.Line2D at 0x10e7aa9d0>]

Zoom >> translation; go in a straight line


In [304]:
path = computePath(Point3(2, 1, 20), Point3(6, 2, 2))
plot([pt.x for pt in path], [pt.z for pt in path])
plot([pt.y for pt in path], [pt.z for pt in path])


Out[304]:
[<matplotlib.lines.Line2D at 0x10e7c6f50>]

Translation == 0; go in a straight line


In [305]:
path = computePath(Point3(2, 1, 20), Point3(2, 1, 2))
plot([pt.x for pt in path], [pt.z for pt in path])
plot([pt.y for pt in path], [pt.z for pt in path])
path


Out[305]:
[(2,1,20), (2,1,2)]

Edge case between vertex and no vertex


In [306]:
path = computePath(Point3(0, 0, 14), Point3(0, 1, 13))
plot([pt.x for pt in path], [pt.z for pt in path])
plot([pt.y for pt in path], [pt.z for pt in path])


Out[306]:
[<matplotlib.lines.Line2D at 0x10f1a1390>]

In [306]: