Concepts

Dedicated Syntax

Why Laylight is a language of its own, and the constructs it is built from.

Most layout tools are libraries for a general-purpose language: a device is a class or a decorated Python function, geometry is created by calling methods in the right order, lengths are bare floats, and positions are worked out by hand. The layout concepts live in conventions on top of the language.

Laylight turns those concepts into the grammar. Cells, layers, shapes, anchors, ports and units are part of the language, so the compiler can check them, the formatter can lay them out and the editor can complete them. A cell reads like a description of the device, not like a program that draws it.

layer si {
    shape rect with 30um, 500nm as bus.center at (0, 0)
    shape ring with 10um, 500nm as resonator.center at bus.center offset (0, 10.7um)
}

layer slab { shape (bus + resonator) + 2um as rib }
Compiling…

The snippet above is a complete all-pass ring resonator with a rib slab, on two process layers. The preview next to it is compiled in your browser by the same compiler that the command line uses.

A small syntax

A file holds a layer table, constants and cells. A cell has parameters, derived attributes, markers and one block per process layer:

import "tech.lyl"

cell RingResonator(radius = 10um, width = 500nm, gap = 200nm) {
    attribute { diameter 2 * radius }

    layer si {
        shape rect with 2 * radius + 10um, width as bus.center at self.origin
        shape ring with radius, width as resonator
        place resonator.center at bus.center offset (0, width + gap + radius)
    }
}

Parameters follow the cell's name, and their types come from their defaults: 10um makes radius a size, 90deg would make it an angle, 3 an integer. A parameter without a default, or one whose default does not tell its type (0, []), states it: count integer, offset size = 0. A param { } block with one parameter per line says the same and suits long lists with a comment per parameter.

There are nine hard keywords (cell, define, for, in, if, else, self, true, false); words such as layer, shape or place are keywords only where they start a statement, and primitive names such as rect are ordinary names. There are no classes, decorators, return values or cell caches to manage: a cell with the same parameters is generated once, and a cell with other parameters becomes its own cell automatically.

Import cells from GDS

An existing GDS library joins source cells through import. A local alias lets the DSL refer to the cell's original GDS name, while its geometry and child hierarchy remain in the layout:

import "vendor/components.gds" cell "MMI$1" as ImportedMMI
cell Chip { place ImportedMMI.origin at (100um, 50um) rotate 90deg }

The compiler converts GDS coordinates to the design's DBU and restores Laylight marker metadata when present. See Importing GDS Cells for the import forms, placement interfaces, supported elements and browser integration.

Units are part of the type system

Lengths and angles carry units (500nm, 0.5um, 90deg) and every expression is checked for its dimension. size * size is an area and cannot be used as a coordinate; a bare number where a length is expected is an error, caught before anything is drawn:

error: expected size, found integer; did you mean `10um`?
 --> bad.lyl:7:25
  |
7 |         shape rect with 10, 500nm as core
  |                         ^^

Shapes and placement are separate

shape says what a piece of geometry is, in its own local coordinates; place says where it goes, by putting one of its anchors on a point or on another shape's anchor. Every shape has bounding-box anchors (center, left_center, top_right, …), and waveguide primitives also have start and end:

shape rect with 10um, 500nm as input
shape rect with 30um, 6um as body
clone input as upper
clone input as lower

place {
    input.left_center at (0, 0)
    body.left_center  at input.right_center
    upper.left_center at body.right_center offset (0, 1.5um)
    lower.left_center at body.right_center offset (0, -1.5um)
}
Compiling…

A shape that is placed once can be placed in the statement that defines it: shape ... as input.left_center at (0, 0) is the same as the shape followed by place input.left_center at (0, 0). Here an MMI's input taper, body and output taper are placed this way, the output taper turned around by rotate 180deg so that its wide end meets the body:

shape taper with 10um, 500nm, 2um as input.start at (0, 0)
shape rect with 20um, 6um as body.left_center at input.end
clone input as output.end at body.right_center rotate 180deg
Compiling…

Placements are constraints, not steps: they may refer to shapes defined further down, and they are resolved when an anchor is first read. A placement cycle is reported with the chain of shapes that forms it.

Anchors are frames, a position with a direction. Placing a port on a port turns the new instance so that the two line up, which is how waveguide routes are written:

cell Route {
    place {
        place Waveguide with length = 20um at self.origin as a
        place Bend at a.marker.output[0] as b          # now heading +y
        place Waveguide at b.marker.output[0] as c
        place Bend with sweep = -90deg at c.marker.output[0] as d
    }
}

Waveguides between devices that are already placed are computed from their ports instead; Auto Routing shows how.

Several layers in one cell

A device spans several process layers: the waveguide core, a partially etched slab, a cladding opening, metal. In Laylight each of them is a layer block of the same cell, so the layers of a device are defined together and cannot drift apart. Shapes on one layer can be derived from shapes on another: in the first example, the slab is the core grown by 2 µm.

Shape and morphological operations are built in

Boolean and morphological operations are expressions: a + b or a | b (union), a & b (intersection), a - b (difference), a ^ b (xor), and a + 2um / a - 500nm to grow or shrink a shape, with mitered, round or beveled corners.

layer si {
    shape rect with 20um, 8um as plate
    shape circle with 2um as hole
    place plate at (0, 0)
    place hole at plate.center
    shape plate - hole as holed
}

layer slab {
    shape holed + 1.5um, "round" as halo
}
Compiling…

The operations run on the integer database grid inside the compiler (see Efficient Geometry). Operands on the result's layer are replaced by it; operands on other layers stay. Helper shapes that should only shape a result are placed with place* and never written. Boolean and Morphological Operations lists every operator and its rules.

Simple control flow

The cell body is declarative, but layouts also have regular structures. for loops over integer ranges and lists, if / else chooses, and define introduces local variables that can be updated and appended to:

for i in 0..12 {
    define w = 300nm + i * 40nm
    shape rect with w, 12um as tooth
    place tooth at (i * 1.1um, 0)
}
Compiling…

A regular grid of unnamed instances from nested loops is written to GDS as an array reference (AREF).

Geometry and simulation stay apart

What a device is made of and how it is simulated are different concerns, and Laylight keeps them apart. Fabricated geometry lives in layer blocks. Everything else, the ports a device is connected through and the annotations a simulation needs, lives in marker blocks: named, located, carrying attributes, and never fabricated.

cell RingResonator {
    # ... param and layer blocks as above ...

    marker {
        input  { at bus.left_center with type = "waveguide", direction = "input", width = width }
        output { at bus.right_center with type = "waveguide", direction = "output", width = width }
        source {
            at bus.left_center + (1um, 0)
            with kind = "light_source"
        }
    }
}

Markers travel with the cell through compilation: they are part of the Laylight IR and are written to GDS as properties on a marker layer, so a simulation flow finds its sources and ports in the same file as the mask. Adding a monitor or moving a source never touches the geometry, and the geometry stays the single source for both the mask and the simulation setup.

Ports also make connectivity checkable. Placing a port on a port records a connection; laylight check reports dangling ports and connected ports whose type or width disagree, and laylight lvs compares the result with a reference netlist.

How much code a device takes

To measure what the dedicated syntax saves, the silicon photonics examples were each written four times: in Laylight, and in gdsfactory, gdstk and KLayout's Python module. They are devices and circuits from published papers, from waveguide bends and splitters to an arrayed waveguide grating, a lithium niobate modulator and a routed optical phased array, and every implementation builds the same layout.

26%fewer tokens

21% vs gdsfactory · 24% vs gdstk · 34% vs KLayout

11%fewer lines of code

13% vs gdsfactory · 6% vs gdstk · 15% vs KLayout

Laylight against gdsfactory, gdstk and KLayout on 17 silicon photonics devices from published papers, each implementation building the same layout; on average over the three tools.

On average over the three tools, Laylight needs 26% fewer tokens and 11% fewer lines of code to describe the same devices: 21% fewer tokens than gdsfactory, 24% fewer than gdstk and 34% fewer than KLayout, and 13%, 6% and 15% fewer lines.

Tokens

Tokens are what has to be written and read: names, numbers with their unit, strings and punctuation. They do not depend on how the code is broken into lines.

Tokens per device, 17 silicon photonics examples

Lower is better · names, numbers with their unit, strings and punctuation, without comments and docstrings

All examples

  • Laylight5,673
  • gdsfactory7,141 · 1.26×
  • gdstk7,419 · 1.31×
  • KLayout8,538 · 1.51×
DeviceLaylightgdsfactorygdstkKLayout
Waveguides and bends283362 · 1.28×698 · 2.47×809 · 2.86×
1×2 MMI splitter136184 · 1.35×201 · 1.48×225 · 1.65×
Mach-Zehnder interferometers370489 · 1.32×480 · 1.30×498 · 1.35×
Grating couplers604783 · 1.30×745 · 1.23×815 · 1.35×
Broadband directional coupler258294 · 1.14×259 · 1.00×340 · 1.32×
Waveguide crossing158167 · 1.06×158 · 1.00×185 · 1.17×
Arrayed waveguide grating640823 · 1.29×695 · 1.09×859 · 1.34×
Microring and Euler racetrack resonators316344 · 1.09×468 · 1.48×596 · 1.89×
Thermo-optic phase shifters326436 · 1.34×448 · 1.37×497 · 1.52×
Spiral delay line424351 · 0.83×314 · 0.74×462 · 1.09×
p-i-n variable optical attenuator149230 · 1.54×252 · 1.69×273 · 1.83×
Carrier-injection microring modulator282383 · 1.36×444 · 1.57×545 · 1.93×
Thin-film lithium niobate modulator401582 · 1.45×566 · 1.41×666 · 1.66×
Germanium electro-absorption modulator353441 · 1.25×444 · 1.26×409 · 1.16×
III-V-on-silicon DFB laser329501 · 1.52×441 · 1.34×409 · 1.24×
Fiber-array test structure232259 · 1.12×253 · 1.09×282 · 1.22×
Optical phased array412512 · 1.24×553 · 1.34×668 · 1.62×

Over the seventeen examples Laylight takes 5,673 tokens; gdsfactory needs 1.26 times as many, gdstk 1.31 times and KLayout 1.51 times. Laylight needs the fewest tokens for 16 of the 17 examples. It saves them where the layout concepts are part of the language: positions follow from anchors and ports instead of coordinate arithmetic, lengths carry their units, shapes go on a layer by being written in its block instead of through API calls, and bends, tapers, booleans and offsets are single expressions. The KLayout versions, which build every point and every region through the API, are the longest; gdsfactory, which has waveguide components, cross-sections and ports, comes closest.

Lines of code

Lines of code per device, 17 silicon photonics examples

Lower is better · without comments, docstrings, blank lines and lines that only close brackets

All examples

  • Laylight604
  • gdsfactory692 · 1.15×
  • gdstk640 · 1.06×
  • KLayout708 · 1.17×
DeviceLaylightgdsfactorygdstkKLayout
Waveguides and bends2837 · 1.32×46 · 1.64×51 · 1.82×
1×2 MMI splitter1822 · 1.22×20 · 1.11×21 · 1.17×
Mach-Zehnder interferometers3949 · 1.26×42 · 1.08×45 · 1.15×
Grating couplers7178 · 1.10×71 · 1.00×76 · 1.07×
Broadband directional coupler2828 · 1.00×31 · 1.11×29 · 1.04×
Waveguide crossing1214 · 1.17×12 · 1.00×14 · 1.17×
Arrayed waveguide grating7680 · 1.05×64 · 0.84×76 · 1.00×
Microring and Euler racetrack resonators3436 · 1.06×39 · 1.15×48 · 1.41×
Thermo-optic phase shifters2943 · 1.48×40 · 1.38×41 · 1.41×
Spiral delay line4032 · 0.80×27 · 0.68×36 · 0.90×
p-i-n variable optical attenuator1516 · 1.07×15 · 1.00×15 · 1.00×
Carrier-injection microring modulator2324 · 1.04×26 · 1.13×35 · 1.52×
Thin-film lithium niobate modulator4861 · 1.27×60 · 1.25×61 · 1.27×
Germanium electro-absorption modulator4141 · 1.00×38 · 0.93×37 · 0.90×
III-V-on-silicon DFB laser4048 · 1.20×37 · 0.93×41 · 1.02×
Fiber-array test structure1923 · 1.21×21 · 1.11×24 · 1.26×
Optical phased array4360 · 1.40×51 · 1.19×58 · 1.35×

Laylight takes 604 lines; gdstk takes 1.06 times as many, gdsfactory 1.15 times and KLayout 1.17 times, and Laylight has the fewest lines for 13 of the 17 examples. The structure stays short: parameters follow the cell's name and take their types from their defaults, a shape is placed in the statement that defines it, and a block with one statement takes one line.

The Python versions take fewer lines for four devices, and each shows something Laylight lacks. gdstk draws a waveguide whose width changes along it as one path, which gives it the shortest delay line (a bent taper) and laser (a mesa with four tapers), where Laylight places a taper per section. Its AWG star coupler returns the positions of its 40 array ports as a list, which a Laylight cell cannot yet export as ports. And in the germanium modulator, the Python versions loop over the p and n sides and the layers of each, which a Laylight layer block, written with a fixed layer, cannot.

How it is measured

The scripts and sources are in bench/syntax and examples/photonics in the repository.

  • Same layout. A script builds every implementation, flattens the result with KLayout and compares it with the Laylight output on every layer: each must lie within 5 nm of the other. Curves are discretized independently by each tool with a chord error of about 1 nm, so they are never identical to the nanometer; most devices agree to within 3 nm, and straight-edged ones exactly. The deviation of every implementation is listed on its example page.
  • Same parameters. Each device is a parametric cell with the same parameters and defaults in all four tools, and examples build on each other in the same way (the Mach-Zehnder interferometer imports the MMI, for instance).
  • Each tool's own primitives. gdsfactory may use its waveguide elements (straights, circular and Euler bends, S-bends, tapers), cross-sections, ports and routing, but not its ready-made device cells, since the point is to write a device, and the published devices differ from them anyway. gdstk and KLayout use their own APIs. The Python versions may use numpy.
  • Idiomatic, formatted code. Every version is written as a regular user of that tool would write it, without golfing. Laylight is formatted with laylight fmt, one statement per line; Python with ruff format in its default settings (88 columns, which Laylight lines also keep to). A Laylight cell's parameters are wrapped the way ruff wraps a signature: on the cell's line if they fit, else on one line of their own, else one per line. A shape is placed in its own statement when that fits on the line.
  • What is counted. Lines that contain code, without blank lines, comments and docstrings, without lines that only close brackets (Laylight's closing braces, and the closing parentheses and brackets that ruff puts on lines of their own when it wraps a call or a signature; Python marks its blocks by indentation instead), and without the shared layer table (tech.lyl and its Python equivalents, which also activate gdsfactory's generic PDK and create the KLayout layout that all cells go into) and the few lines of Python that write the GDS file (Laylight's command line does that). Lines depend on formatting, so the chart also counts tokens: names, numbers (with their unit), strings and punctuation.

Versions: gdsfactory 9.51.0, gdstk 1.0.1, KLayout 0.30.12, Python 3.12, ruff 0.16.10.

Tools that understand the language

Because the language is small and fully specified, the tools around it are exact:

  • laylight fmt formats every file the same way, keeping comments and the author's line breaks, and writes a block that holds a single statement on one line when it fits in 88 columns.
  • laylight lsp, the language server, serves any editor with LSP support (Editor support) and drives the VS Code extension: diagnostics while typing, completion of parameters, anchors and ports, hover with evaluated values (such as diameter = 20um), rename, and a live layout preview.
  • The compiler and the language server also run in the browser, in the DSL playground and the Tour, which complete code as you type.

The Language Reference and its pages on shape primitives and operations cover the language in detail; the Tour teaches it step by step.

On this page