What's My Path?

(see this code in situ in the path_demo.py file in the points demo directory )
An animation made by adding
9 straight paths of polar points with
1 spiral path of polar points and
the Cartesian canvas center point
tl;dr: In this post, I extend my vector based point classes to include ordered point containers called Path. I implement my Path class with my Vector base class. This extends vector arithmetic to include Path containing Polar or Cartesian points. This is also continues my exploration of the use of Structural Pattern Matching within Python programming. Expect to see more match statements than are strictly necessary.

In my artwork, my focus is paths, corridors and their branches. This is the second post on a series about the Python code that contributes to my artwork. While my mazes are hand drawn, I use my own software written in Python for analysis, animation, and print image processing.

I always prefer to work with iterators but sometimes materialization of sequences into memory is useful. I define an ordered sequence of n-dimensional point objects to be a Path through n-dimensional space. I want to operate on instances of Path as units with simple arithmetic statements. My goal is writing expressions that mix paths of CartesianPoints, paths of PolarPoints, individual Cartesian and Polar points, as well as scalars.

square = Path(
    CartesianPoint(0, 0),
    CartesianPoint(0, 10),
    CartesianPoint(10, 10),
    CartesianPoint(10, 0),

# translate square by 50 in both x and y directions
translated_square_50 = square + 50

# translate square by 1 in the x direction and 10 in y direction
translated_square_1_10 = square + CartesianPoint(1, 10)

# scale the square by 2 and 3 in the x and y directions respectively
scaled_square_2_3 = square * CartesianPoint(2, 3)

# scale each point of the square by each point of a translated square
scaled_square = square * translated_square_50

The implementation of the Path class is built directly on the Vector class. I need only redefine two private methods: _judge_candidate_value and _operation. The first restricts members of a Path to be exclusively Vector instances. The second defines how arithmetic works with Path. In this latter case, the match statement differentiates the behavior of Path instances with scalars, other Path instances, Points, and other iterables like ndarrays from numpy.

class Path(Vector):
    def _judge_candidate_value(cls, a_potential_point):
        # accept only instances of Vector as values for a Path
        if isinstance(a_potential_point, Vector):
            return a_potential_point
        raise TypeError(
            f'{cls} members must be a Vector instance, "{a_potential_point}" is not'

    def _operation(self, the_other, a_dyadic_fn):
        # return a new instance with members that result from a_dyadic_fn applied to
        # this instance zipped with the_other
        match the_other:
            case Number() as a_number:
                # match scalars
                return self.__class__(
                        a_dyadic_fn, zip_longest(self, (a_number,), fillvalue=a_number)

            case self.__class__() as a_path_object:
                # match an instance of another Path
                return self.__class__(*starmap(a_dyadic_fn, zip(self, a_path_object)))

            case CartesianPoint() | PolarPoint():
                # match any instance of the two point families
                return self.__class__(
                        zip_longest(self, (the_other,), fillvalue=the_other),

            case Iterable() as an_iterable:
                # match any other type of iterable
                return self.__class__(*starmap(a_dyadic_fn, zip(self, an_iterable)))

            case _:
                # no idea how to apply this value
                raise TypeError(f"{the_other} disallowed")
(see this code in situ in the path.py file in the points main directory )

The method _operation, simulates single-dispatch using the match statement for only one of the method's arguments. I'm not sure if the standard decorator implementations of single dispatch are able to do this. There five different ways the function works based on the type of the method's first argument. The fourth case is important because it handles Iterables of unknown provenance. It assumes that its content represent point-like objects, but not necessarily the point classes in this package, for example, ndarrays. If they are not CartesianPoint or PolarPoint, it assumes the unknown points represent points in Cartesian space. See the test test_multiplication_with_an_iterator_of_tuples where a Path instance is multiplied by an iterator of bare tuples.

The path_demo.py script that draws the animation above, relies on arithmetic with path objects composed of PolarPoint instances. The first Path object that it creates is a spiral of five loops about the origin (0,0) in polar space. Then the outer for loop creates nine straight rays emanating from the origin, also in polar space. It creates them one at a time in the direction specified by theta (θ). Each spiraled ray is created by adding the straight ray path to the spiral path and then the cartesian image center point. The result is nine Path instances in polar space growing from the center of canvas.

def draw_path_demo(a_canvas):
    """Draw 9 looping rays from the center of the canvas"""

    canvas_middle_point = CartesianPoint(a_canvas.the_image.size) / 2
    # materialize a spiral path of PolarPoints about the origin
    spiral_path = Path(
        iter_natural_steps_between(PolarPoint(0, 0), PolarPoint(50, 10 * π), 100)

    collection_of_ray_paths = []

    for θ in iter_linear_steps_between(0, 2 * π, 9):

        # materialize a path of PolarPoints making a straight ray from the origin
        ray_path = Path(
            iter_linear_steps_between(PolarPoint(0, θ), PolarPoint(300, θ), 100)

        # combine the straight rays with the spiral path and translate them
        # from the canvas origin to the canvas middle - save the result as
        # a path of PolarPoints
        collection_of_ray_paths.append(ray_path + spiral_path + canvas_middle_point)

    # set up windowing iterators for all the spiraling ray paths
    windowed_iter_for_every_ray_path = (
        windowed(a_ray_path, 2) for a_ray_path in collection_of_ray_paths

    # step through the all the spiral ray paths in parallel, round robin style
    for a_segment_for_every_ray in zip(*windowed_iter_for_every_ray_path):
        for start_point, end_point in a_segment_for_every_ray:
            a_canvas.draw_line_segment(start_point, end_point)
(see this code in situ in the path_demo.py file in the points demo directory )

Instead of drawing each ray one at a time, I want all the spiral rays to grow simultaneously. I create nine iterators of the nine rays Path instances using the windowed iterator from the moreitertools module. With a size specified as 2, this pairs items from an iterator in a moving window: (0th, 1st), (1st, 2nd), (2nd, 3rd), ... This is perfect for drawing succeeding line segments. Drawing the nine ray iterators simultaneously means zipping the iterators together, iterating over the result, drawing one segment from each ray in turn, round-robin style.

When creating my ray and spiral paths, I'm using two different iterators that seem to do very similar things: iter_linear_steps_between and iter_natural_steps_between. I learned some very interesting things about PolarPoint by writing these iterators.

The first iterator draws from a generalization of how this would be solved in the one dimensional world of scalars: create a delta value by subtracting the start point from the end point and then dividing by the number of increments desired. Then add that increment repeatedly to the start point for the number of intermediate points desired. This is how iter_linear_steps_between works. In fact, it was written originally as a version of the Python range iterator for floating point values. It gets used that way for the θ value in the loop for the nine rays. I think it is a triumph of Object Oriented Programming that a method written for floating point values can be used without modification for my CartesianPoint and PolarPoint classes.

def iter_linear_steps_between(
    start, stop, number_of_iterations, target_type=lambda n: n
    # in cartesian space, make a sequence of points representing
    # a straight line between the start and stop points
    increment = (stop - start) / number_of_iterations
    for i in range(number_of_iterations):
        yield target_type(start + (increment * i))
(see this code in situ in the iterators.py file in the points main directory )

This iterator works for any object that implements scalar-style arithmetic. Of course, that ability was a design the goal of my point classes in the first place. This algorithm always results in iterating over the intermediate points in a straight line, even if the input start and end points are PolarPoints.

An alternative algorithm, not relying on the arithmetic interface magic, leads to some very useful effects. It treats the points as just vectors of values. It applies the delta equation to paired values from each dimension of the vector independently rather than using the semantics of the point. This is how iter_natural_steps_between is implemented. For CartesianPoints, this produces exactly the same result as the previous implementation: a straight line. However, for PolarPoints, it behaves very differently. Instead of a straight line, it produces a smooth curve between the start and end points.

def iter_natural_steps_between(
    start, stop, number_of_iterations, target_type=lambda n: n
    # In cartesian space, Vectors and CartesianPoints trace out straight
    # lines between the start and end points. PolarPoints trace curves
    # based on the full magnitude of the angular components (number of time
    # around the circle). I use this iterator for making spiral paths.
    base_point_type = start.__class__
    for p in zip(
            iter_linear_steps_between(first, second, number_of_iterations)
            for first, second in zip(start, stop)
        yield target_type(base_point_type(*p))
(see this code in situ in the iterators.py file in the points main directory )

Why? Examining the values of a series of intermediate PolarPoints representing a straight line in Cartesian space reveals that the Δρ and Δθ are not constant over the range. The first iterator automatically and transparently adjusts based on the definition of arithmetic for my Cartesian and Polar points. The second iterator treats all points as just vectors of values, ignoring the differences between Cartesian and Polar points. The second iterator forces the use of constant Δρ and Δθ for PolarPoint, so a curved line is the result.

Playing round with the differences between these two iterators took me down a rabbit hole. It seems that there should be several more iterators that perhaps cause both CartisianPoints and PolarPoints to curve, or perhaps makes CartesianPoints curve, but make PolarPoints straight? Hey, I ought to invent a celebratory TransPoint class (Cartesian interface, Polar implementation) in honor of some very fine people I know. I'll eventually write about where my explorations took me.

Instead, the next post will be about further specialization of Path to create transformable viewports for the Python PIL package using a modernized version the ancient Cohen-Southerland clipping algorithm.