Fundamentals: patterns and curves#

A Pattern in dphox is analogous to shapely’s MultiPolygon, and contains a set of polygons represented by a list of \(2 \times N\) numpy arrays.

A Pattern can be treated pretty much like a shapely geometry in many respects. While we wrap boolean operations in Pattern using shapely’s API, we do not use shapely’s transform operations. This is because those are not vectorized efficiently over all of the geometries as we do in Pattern.

A Curve in dphox is analogous to shapely’s MultiLineString, and consists of a list of \(2 \times N\) numpy arrays like Pattern, except we do not assume the first and last points are connected.

A path is a Pattern, which is a Curve with thickness or width, which may vary along a curve.

dphox supports any curve or path that can be represented by piecewise parametric function(s): straight lines, circular and elliptical turns, Euler and Archimedian spiral turns, Manhattan routes, and much more. These are very useful for photonic and metal routing.

In dphox (and similar libraries as gdspy), a parametric function is generally defined in terms of a variable \(t \in [0, 1]\). This can be used to define both the curve and the varying widths along the curve. The resolution or number of evaluations of the path, is generally defined for any curve that isn’t “straight” and we typically use 100 as the default here, though that can vary.

Imports#

[1]:
import dphox as dp
import numpy as np
import holoviews as hv
hv.extension('bokeh')

Patterns#

A very important philosophy in dphox is to only implement things not already implemented in shapely unless there is a much more efficient batch implementation (e.g. vectorized transforms using numpy arrays). To this end, we will present many functionalities below that are very simple extensions of shapely transformations, owing to the seamless translation between Pattern and shapely geometries.

Text rendering#

Using the dp.text function it is possible to render any text using LaTeX assuming you’ve installed the fonts in your computer or using default fonts (as is the case in a default Colab kernel). Behind the scenes, we leverage matplotlib’s LaTeX path patches. Below, we will generate the symbol \(\pi\) and manipulate it in the further examples.

We also add a port \(p\) of width \(1\) at some location \((3, 1)\) with angle \(0\), and you should note how it transforms along with the overall geometry.

[2]:
pi = dp.text(r"$\pi$")
pi.port['p'] = dp.Port(3, 1)
pi.hvplot().opts(title='pi')
Matplotlib is building the font cache; this may take a moment.
[2]:

translate#

First let’s experiment with translating the pattern. Note that I need to make a copy of the original pattern each time (this is a deepcopy) because I do not want to apply the transformations sequentially. The transformations also return object itself.

[3]:
pi1 = pi.copy.translate()  # no translation
pi2 = pi.copy.translate(10)  # translation by 10
pi3 = pi.copy.translate(10, 10)  # translation by (10, 10)

b = dp.Pattern(pi1, pi2, pi3).bounds


(pi1.hvplot() * pi2.hvplot('blue') * pi3.hvplot('red')).opts(xlim=(b[0], b[2]), ylim=(b[1], b[3]), title='translation')
[3]:

rotate#

Now, let’s rotate and see what happens, again using the copy trick.

[4]:
pi1 = pi.copy.rotate(45)  # rotate by 45 degrees about the origin
pi2 = pi.copy.rotate(90)  # rotate by 90 degrees about the center of the pattern


b = dp.Pattern(pi, pi1, pi2).bounds


(pi.hvplot() * pi1.hvplot('blue') * pi2.hvplot('red')).opts(xlim=(b[0], b[2]), ylim=(b[1], b[3]), title='rotation')
[4]:

We can choose any point of rotation so let’s also do this about the center.

[5]:
pi1 = pi.copy.rotate(45, pi.center)  # rotate by 45 degrees about the origin
pi2 = pi.copy.rotate(90, pi.center)  # rotate by 90 degrees about the center of the pattern


b = dp.Pattern(pi, pi1, pi2).bounds


(pi.hvplot() * pi1.hvplot('blue') * pi2.hvplot('red')).opts(xlim=(b[0], b[2]), ylim=(b[1], b[3]), title='rotation')
[5]:

scale#

We can rescale our \(\pi\) geometry in the x and/or y dimensions as follows.

[6]:
pi1 = pi.copy.scale(4, origin=pi.center)  # rotate by 45 degrees about the origin
pi2 = pi.copy.scale(2, 2, pi.center)  # rotate by 90 degrees about the center of the pattern


b = dp.Pattern(pi, pi1, pi2).bounds


(pi.hvplot() * pi1.hvplot('blue') * pi2.hvplot('red')).opts(xlim=(b[0], b[2]), ylim=(b[1], b[3]), title='scale')
[6]:

skew#

We can skew our \(\pi\) geometry in the x and/or y dimensions as follows.

[7]:
pi1 = pi.copy.skew(0.5, origin=pi.center)  # rotate by 45 degrees about the origin
pi2 = pi.copy.skew(0, -0.5, pi.center)  # rotate by 90 degrees about the center of the pattern


b = dp.Pattern(pi, pi1, pi2).bounds


(pi.hvplot().opts(title='no skew') + pi1.hvplot('blue').opts(title='xskew') + pi2.hvplot('red').opts(title='yskew'))
[7]:

align#

Sometimes, it might be easier to just align and/or stack designs next to each other, especially in cases where no port reference points / orientations are defined. In such a case, we may use the align, halign, valign functions. Inspiration for this feature comes from phidl.

[8]:
circle = dp.Circle(5)


circle.align(pi)

b = dp.Pattern(circle, pi).bounds

(pi.hvplot() * circle.hvplot('green')).opts(xlim=(b[0], b[2]), ylim=(b[1], b[3]), title='scale')
[8]:

halign#

Here we now align another smaller box to the edge of the circle using halign and valign.

[9]:
box = dp.Box((3, 3))  # centered at (0, 0) by default.

aligned_boxes = {
    'default': box.copy.halign(circle),
    'opposite=True': box.copy.halign(circle, opposite=True),
    'left=False': box.copy.halign(circle, left=False),
    'left=False,opposite=True': box.copy.halign(circle, left=False, opposite=True),
}

plots = []

for name, bx in aligned_boxes.items():
    b = dp.Pattern(circle, bx, pi).bounds
    plots.append(
        (pi.hvplot() * circle.hvplot('green') * bx.hvplot('blue', plot_ports=False)).opts(
            xlim=(b[0], b[2]), ylim=(b[1], b[3]), title=name
        )
    )

hv.Layout(plots).cols(2).opts(shared_axes=False)
[9]:

valign#

[10]:
box.halign(circle, opposite=True)  # to create a wider plot
aligned_boxes = {
    'default': box.copy.valign(circle),
    'opposite=True': box.copy.valign(circle, opposite=True),
    'bottom=False': box.copy.valign(circle, bottom=False),
    'bottom=False,opposite=True': box.copy.valign(circle, bottom=False, opposite=True),
}

plots = []

for name, bx in aligned_boxes.items():
    b = dp.Pattern(circle, bx, pi).bounds
    plots.append(
        (pi.hvplot() * circle.hvplot('green') * bx.hvplot('blue', plot_ports=False)).opts(
            xlim=(b[0], b[2]), ylim=(b[1], b[3]), title=name
        )
    )

hv.Layout(plots).cols(2).opts(shared_axes=False)
[10]:

to#

The to command allows ports in different devices to be aligned to each other. If a from_port is not specified, assume the port is at the origin (0, 0) with an angle of \(180^\circ\) in the reference plane of the pattern.

[11]:
box = dp.Box((3, 3))

box.port = {'n': dp.Port(a=45)} # 45 degree reference port.

aligned_boxes = {
    'to n from origin': pi.copy.to(box.port['n']),
    'to n from p': pi.copy.to(box.port['n'], from_port='p')
}

plots = []

for name, bx in aligned_boxes.items():
    b = dp.Pattern(bx, box).bounds
    plots.append(
        (box.hvplot() * bx.hvplot('blue')).opts(
            xlim=(b[0], b[2]), ylim=(b[1], b[3]), title=name
        )
    )

hv.Layout(plots).cols(2).opts(shared_axes=False)
[11]:
[12]:
aligned_boxes = {
    'to p from origin': box.copy.to(pi.port['p']),
    'to p from n': box.copy.to(pi.port['p'], from_port='n')
}

plots = []

for name, bx in aligned_boxes.items():
    b = dp.Pattern(bx, pi).bounds
    plots.append(
        (bx.hvplot() * pi.hvplot('blue')).opts(
            xlim=(b[0], b[2]), ylim=(b[1], b[3]), title=name
        )
    )
hv.Layout(plots).cols(2).opts(shared_axes=False)
[12]:

Curves and paths#

Before we discuss the link, offset, symmetrize, loopify, and turn_connect operations, we will discuss the various fundamental building blocks or elements for curves and paths.

straight#

A straight path or waveguide can be defined based on a width \(w\) and length \(\ell\) and simply consists of two points.

[13]:
straight_curve = dp.straight(3) # A turn of radius 5.

straight_path = dp.straight(3).path(1) # A turn of radius 5 and width 1

straight_curve.hvplot().opts(title='straight curve', ylim=(-2, 2)) + straight_path.hvplot().opts(title='straight path', ylim=(-2, 2))
[13]:
[14]:
hv.DynamicMap(lambda width, length: dp.straight(length).path(width).hvplot().opts(
    xlim=(0, 5), ylim=(-2, 2)),
    kdims=['width', 'length']).redim.range(
    width=(0.1, 0.5), length=(1, 5)).opts(framewise=True)
[14]:

turn#

A smooth turn can be defined based on a width \(w\) or taper function \(w(t)\), radius \(r\), Euler fraction \(e\) (linearly ramps the curvature to reduce photonic bend loss). Note that the Euler parameter increases the length of the bend but takes up the roughly same bounding box area.

[15]:
turn_curve = dp.turn(5, 90) # A turn of radius 5.

turn_path = dp.turn(5, 90).path(1) # A turn of radius 5 and width 1

turn_curve.hvplot().opts(title='turn curve') + turn_path.hvplot().opts(title='turn path')
[15]:
[16]:
dmap = hv.DynamicMap(lambda width, radius, angle, euler: dp.turn(radius, angle, euler).path(width).hvplot().opts(
    xlim=(-10, 10), ylim=(-10, 10)),
    kdims=['width', 'radius', 'angle', 'euler'])
dmap.redim.range(width=(0.3, 0.7), radius=(3., 5.), angle=(-180, 180), euler=(0, 0.5)).redim.step(radius=0.1, euler=0.05).redim.default(angle=90, width=0.5, radius=5)
[16]:

taper#

A taper follows the polynomial width function \(w(t)\).

We typically define this based on a polynomial function \(w(t) = w_0 + w_1 t + w_2 t^2 + w_3 t^3 \cdots\) , so we define these in dphox explicitly. The nice thing about this form \(w(t)\) is that the sum of the coefficients gives the overall width at the end (\(t = 1\)) and \(w_0\) gives the initial width.

Why use a nonlinear taper? - A quadratic taper is the minimal function that allows for \(C_2\) smooth tapering transition at either one end. - A cubic taper is the minimal function that allows for \(C_2\) smooth tapering transitions on both ends.

[17]:
cubic = dp.taper(5).path(dp.cubic_taper_fn(1, 0.5))
quad = dp.taper(5).path(dp.quad_taper_fn(1, 0.5))
linear = dp.taper(5).path(dp.linear_taper_fn(1, 0.5))

linear_plot = linear.hvplot().opts(title='linear taper (1 to 0.5)', ylim=(-2, 2))
quad_plot = quad.hvplot().opts(title='quadratic taper (1 to 0.5)', ylim=(-2, 2))
cubic_plot = cubic.hvplot().opts(title='cubic taper (1 to 0.5)', ylim=(-2, 2))
linear_plot + quad_plot + cubic_plot
[17]:
[18]:
def taper_plot(length, init_w, final_w):
    cubic = dp.taper(length).path(dp.cubic_taper_fn(init_w, final_w))
    quad = dp.taper(length).path(dp.quad_taper_fn(init_w, final_w))
    linear = dp.taper(length).path(dp.linear_taper_fn(init_w, final_w))
    linear_plot = linear.hvplot().opts(title=f'linear taper ({init_w} to {final_w})', xlim=(0, 10), ylim=(-5, 5))
    quad_plot = quad.hvplot().opts(title=f'quadratic taper ({init_w} to {final_w})', xlim=(0, 10), ylim=(-5, 5))
    cubic_plot = cubic.hvplot().opts(title=f'cubic taper ({init_w} to {final_w})', xlim=(0, 10), ylim=(-5, 5))
    return linear_plot + quad_plot + cubic_plot

dmap = hv.DynamicMap(lambda length, init_w, final_w: taper_plot(length, init_w, final_w), kdims=['length', 'init_w', 'final_w'])
dmap.redim.range(length=(5., 10.), init_w=(3., 5.), final_w=(2., 6.)).redim.default(length=10)
[18]:

arc#

An arc of specified angle \(\alpha\), radiu \(r\), similar to a circular bend except now the center is at the origin.

[19]:
curve = dp.arc(120, 5)
path = curve.path(1)
path_taper = curve.path(dp.cubic_taper_fn(0.5, 2))

arc_curve_plot = curve.hvplot().opts(xlim=(0, 6), ylim=(-5, 5), title='arc curve')
arc_path_plot = path.hvplot().opts(xlim=(0, 6), ylim=(-5, 5), title='arc path')
arc_path_taper_plot = path_taper.hvplot().opts(xlim=(0, 6), ylim=(-5, 5), title='arc path, cubic taper')

arc_curve_plot + arc_path_plot + arc_path_taper_plot
[19]:

bezier_sbend#

An sbend following a classic cubic, 4-pole bezier structure defined based on a width \(w\) or taper function \(w(t)\), bend width displacement \(\delta x\), bend height displacement \(\delta y\). The poles are placed at \((0, 0), (\delta x / 2, 0), (\delta x / 2, \delta y), (\delta x, \delta y)\).

[20]:
curve = dp.bezier_sbend(bend_x=15, bend_y=10)
path = dp.bezier_sbend(15, 10).path(1)
path_taper = dp.bezier_sbend(15, 10).path(dp.cubic_taper_fn(0.5, 2))

curve.hvplot().opts(title='bezier curve') + path.hvplot().opts(title='bezier path') + path_taper.hvplot().opts(title='bezier path, cubic taper')
[20]:

turn_sbend#

An sbend based on circular/Euler turns rather than bezier curves, and involve bending up and down by the same angle (assumed to be less than 90 degrees). The input parameters are an effective radius \(r\) and a bend height \(\delta y\) for the sbend. If the radius is smaller than twice the bend height, we use 90 degree turns and allow the straight segment to cover the full bend height.

[21]:
curve = dp.turn_sbend(height=5, radius=5)
path = dp.turn_sbend(5, 5).path(1)
path_taper = dp.turn_sbend(5, 5).interpolated.path(dp.cubic_taper_fn(0.5, 2))
curve.hvplot().opts(title='turn_sbend curve') + path.hvplot().opts(title='turn_sbend path') + path_taper.hvplot().opts(title='turn_sbend path, cubic taper')
[21]:

Operations#

segments#

We can also visualize the individual elements of link by plotting all of the geometries in teh racetrack curve, which we refer here as segments.

[23]:
racetrack_segments = racetrack_curve.segments
xmin, ymin, xmax, ymax = racetrack_curve.bounds

hv.Overlay([segment.hvplot() for segment in racetrack_segments]).opts(xlim=(xmin - 2, xmax + 2), ylim=(ymin - 2, ymax + 2))
[23]:

reverse#

The reverse() operation simply reverses the curve to move in the opposite direction and flips the ports.

[24]:
taper = dp.taper(5).path(dp.cubic_taper_fn(1, 0.5))
reverse_taper = dp.taper(5).reverse().path(dp.cubic_taper_fn(1, 0.5))

(taper.hvplot().opts(title='forward') + reverse_taper.hvplot().opts(title='backward')).opts(shared_axes=False).cols(1)
[24]:

interpolated#

Interpolation of a curve is important in cases where there are multiple segments to a curve with varying resolution. Interpolation allows for tapering of geometries with equal segment lengths along the curve, and can be called using .interpolated. Below is an example for when the radius of a turn_sbend is smaller than twice the bend height; as you can see the taper is more evenly distributed in the interpolated case.

[25]:
path_taper = dp.turn_sbend(20, 5).path(dp.cubic_taper_fn(0.5, 2))
path_taper_interp = dp.turn_sbend(20, 5).interpolated.path(dp.cubic_taper_fn(0.5, 2))
path_taper.hvplot().opts(title='noninterpolated', fontsize=10) + path_taper_interp.hvplot().opts(title='interpolated', fontsize=10)
[25]:

symmetrized#

The symmetrization of a curve or path mirrors any curve or path at its endpoint.

[26]:
trombone_taper = path_taper_interp.symmetrized()

trombone_taper.hvplot(alpha=0.5) * trombone_taper.curve.hvplot(alternate_color='red', line_width=6)
[26]:

We can apply the symmetrization many times to build funky ring structures.

[27]:
path1 = dp.link(dp.turn(5, -45).path(0.5), trombone_taper, dp.turn(5, -45).path(0.5)).symmetrized().symmetrized()

path2 = dp.link(dp.turn(5, -45).path(0.5), trombone_taper.symmetrized(), dp.turn(5, -45).path(0.5)).symmetrized().symmetrized()


(path1.hvplot() * path1.curve.hvplot(alternate_color='red') + path2.hvplot() * path2.curve.hvplot(alternate_color='red')).opts(shared_axes=False)
[27]: