Cross-Sections
A cross-section bundles everything that describes a waveguide (or wire) profile into a single reusable object:
| Field | Meaning |
|---|---|
width |
Core width (must be even in DBU so geometry stays symmetric) |
layer |
Primary / core layer |
sections |
Extra layers drawn around the core (cladding, slab, keep-out, …) |
radius |
Hint for the router: preferred bend radius (not enforced) |
radius_min |
Hint for the router: minimum allowed bend radius (DRC) |
bbox_layers / bbox_offsets |
Bounding-box expansion layers (floorplan, die outline) |
Cross-sections are stored in the KCLayout registry so any part of a design
can look one up by name. Routers and schematic-driven design both use cross-section
names to parameterize routing.
Relation to LayerEnclosure
Under the hood a cross-section wraps a LayerEnclosure. The enclosure defines all
the sections; the cross-section adds the core width and routing hints.
See Layer Enclosures for the enclosure details.
Three cross-section classes
| Class | Units | Typical use |
|---|---|---|
SymmetricalCrossSection |
DBU | Immutable data model — the canonical form stored in KCLayout |
CrossSection |
DBU | View of a SymmetricalCrossSection bound to a KCLayout |
DCrossSection |
µm | Human-friendly µm variant — converts to DBU internally |
Setup
import kfactory as kf
from kfactory.cross_section import SymmetricalCrossSection
class LAYER(kf.LayerInfos):
WG: kf.kdb.LayerInfo = kf.kdb.LayerInfo(1, 0)
WGCLAD: kf.kdb.LayerInfo = kf.kdb.LayerInfo(2, 0)
SLAB: kf.kdb.LayerInfo = kf.kdb.LayerInfo(3, 0)
FLOORPLAN: kf.kdb.LayerInfo = kf.kdb.LayerInfo(10, 0)
L = LAYER()
kf.kcl.infos = L
1 · Building a cross-section with DCrossSection (µm)
DCrossSection is the easiest starting point — all dimensions in µm.
◄──── 3 µm ────► WGCLAD (cladding)
◄── 0.5 µm ──► WG (core)
┌───────────────┐
│ WGCLAD │
│ ┌─────────┐ │
│ │ WG │ │
│ └─────────┘ │
│ │
└───────────────┘
The sections list entries have the form (layer, d_max) or
(layer, d_min, d_max) — the distance(s) from the edge of the core:
# DCrossSection — all dimensions in µm
xs_wg = kf.DCrossSection(
kcl=kf.kcl,
width=0.5, # core width µm
layer=L.WG,
sections=[
(L.WGCLAD, 2.0), # cladding: 0 → 2 µm from core edge (symmetric)
],
radius=10.0, # preferred bend radius hint
radius_min=5.0, # minimum bend radius hint (DRC)
name="WG_500",
)
print(f"name: {xs_wg.name}")
print(f"width (µm): {xs_wg.width}")
print(f"layer: {xs_wg.layer}")
print(f"radius (µm): {xs_wg.radius}")
print(f"sections: {xs_wg.sections}")
name: WG_500
width (µm): 0.5
layer: WG (1/0)
radius (µm): 10.0
sections: {WGCLAD (2/0): [(None, 2.0)]}
2 · Storing in KCLayout and looking up by name
Call kf.kcl.get_icross_section() (DBU view) or kf.kcl.get_dcross_section() (µm view)
to register a cross-section. On first call with a new spec it is stored; subsequent
calls with the same name return the cached version.
# Register — this returns a CrossSection (DBU view) bound to kf.kcl
xs_dbu: kf.CrossSection = kf.kcl.get_icross_section(xs_wg)
# Retrieve by name later — handy in factory functions that only know the name
xs_retrieved: kf.CrossSection = kf.kcl.get_icross_section("WG_500")
print(f"same object? {xs_dbu.base == xs_retrieved.base}")
print(f"width (DBU): {xs_dbu.width}") # 500 DBU at 1 nm/DBU
same object? True
width (DBU): 500
3 · DBU variant with CrossSection
Use CrossSection when you want full control at the database-unit level.
All dimensions are integers (database units).
# CrossSection — dimensions in DBU (1 DBU = 1 nm by default)
xs_dbu_direct = kf.CrossSection(
kcl=kf.kcl,
width=500, # 500 DBU = 0.5 µm
layer=L.WG,
sections=[
(L.WGCLAD, 0, 2_000), # (layer, d_min, d_max) in DBU
],
radius=10_000, # 10 µm in DBU
name="WG_500_dbu",
)
print(f"width (DBU): {xs_dbu_direct.width}")
print(f"width (µm): {kf.kcl.to_um(xs_dbu_direct.width)}")
width (DBU): 500
width (µm): 0.5
4 · SymmetricalCrossSection — the canonical data model
Both CrossSection and DCrossSection wrap a SymmetricalCrossSection, which is
the immutable Pydantic model that gets stored in KCLayout.cross_sections.
You can build one directly using an existing LayerEnclosure:
enc = kf.kcl.get_enclosure(
kf.LayerEnclosure(
name="WG_RIB",
main_layer=L.WG,
sections=[
(L.WGCLAD, 0, 2_000),
(L.SLAB, 0, 5_000),
],
)
)
xs_base = SymmetricalCrossSection(
width=700, # 700 DBU = 0.7 µm
enclosure=enc,
name="WG_700_RIB",
radius=15_000,
radius_min=8_000,
)
xs_rib: kf.CrossSection = kf.kcl.get_icross_section(xs_base)
print(f"layers: {list(xs_rib.sections.keys())}")
print(f"radius: {kf.kcl.to_um(xs_rib.radius)} µm")
layers: [SLAB (3/0), WGCLAD (2/0)]
radius: 15.0 µm
5 · Multi-layer and annular sections
Three-element section tuples (layer, d_min, d_max) produce annular (ring-shaped)
regions — useful for doping implants or etch-stop layers that must not touch the core
edge.
xs_implant = kf.DCrossSection(
kcl=kf.kcl,
width=0.5,
layer=L.WG,
sections=[
(L.WGCLAD, 2.0), # cladding: extends 0–2 µm from core
(L.SLAB, 0.5, 3.0), # slab ring: starts 0.5 µm, ends 3 µm from core
],
name="WG_IMPLANT",
)
for layer, segs in xs_implant.sections.items():
for d_min, d_max in segs:
print(f" {layer} {d_min} → {d_max} µm")
SLAB (3/0) 0.5 → 3.0 µm
WGCLAD (2/0) None → 2.0 µm
6 · Bounding-box expansion layers
bbox_layers / bbox_offsets add layers that expand the component bounding box by a
fixed amount — commonly used for die outline, exclusion zones, or floorplan tiles.
These do NOT use Minkowski operations; they simply offset the bounding box polygon.
xs_with_fp = kf.DCrossSection(
kcl=kf.kcl,
width=0.5,
layer=L.WG,
sections=[
(L.WGCLAD, 2.0),
],
bbox_layers=[L.FLOORPLAN],
bbox_offsets=[5.0], # floorplan 5 µm outside bounding box
name="WG_FP",
)
xs_fp_dbu = kf.kcl.get_icross_section(xs_with_fp)
print(f"bbox_sections: {xs_fp_dbu.bbox_sections}")
bbox_sections: {FLOORPLAN (10/0): 5000}
7 · Creating ports with a cross-section
A Port stores its geometry via its cross_section; you can pass a
SymmetricalCrossSection directly to add_port.
Because @kf.cell caches by parameters, factory functions should accept
the cross-section name (a string) and look it up inside:
@kf.cell
def mzi_arm(cross_section: str, length_um: float = 20.0) -> kf.KCell:
"""A simple straight acting as an MZI arm.
Args:
cross_section: Name of a registered cross-section.
length_um: Arm length in µm.
"""
c = kf.KCell()
xs = kf.kcl.get_icross_section(cross_section)
length = kf.kcl.to_dbu(length_um)
w = xs.width
c.shapes(kf.kcl.find_layer(xs.layer)).insert(
kf.kdb.Box(-length // 2, -w // 2, length // 2, w // 2)
)
c.add_port(
port=kf.Port(
name="o1",
trans=kf.kdb.Trans(2, False, -length // 2, 0), # West-facing
cross_section=xs.base,
kcl=kf.kcl,
)
)
c.add_port(
port=kf.Port(
name="o2",
trans=kf.kdb.Trans(0, False, length // 2, 0), # East-facing
cross_section=xs.base,
kcl=kf.kcl,
)
)
return c
arm = mzi_arm(cross_section="WG_500")
print(f"port o1 width: {arm['o1'].width} DBU = {kf.kcl.to_um(arm['o1'].width)} µm")
print(f"port o1 layer: {arm['o1'].layer}")
arm.plot()
port o1 width: 500 DBU = 0.5 µm
port o1 layer: WG
8 · Cross-sections in routing
Optical and electrical routers accept a cross_section argument on start/end ports —
the router reads xs.radius and xs.radius_min to choose bend radii automatically.
The typical workflow:
- Define one
DCrossSectionper waveguide type at PDK setup time. - Register it on
kf.kclwith a human-readable name. - Pass the name (or the
CrossSectionobject) wherever routers need a waveguide spec.
# PDK setup (once)
xs_wg = kf.DCrossSection(kcl=kf.kcl, width=0.5, layer=L.WG,
sections=[(L.WGCLAD, 2.0)], radius=10.0, name="WG_500")
kf.kcl.get_icross_section(xs_wg) # register
# In a factory function — look up by name
def my_bend(cross_section: str = "WG_500") -> kf.KCell:
xs = kf.kcl.get_icross_section(cross_section)
...
See Routing Overview for full routing examples.
9 · Asymmetric cross sections
SymmetricalCrossSection rejects odd widths (width % 2 == 0) and centers the
profile on the port axis. For non-symmetric profiles — angled ribs, slot
waveguides, strip-loaded waveguides, or just any odd-width strip —
AsymmetricalCrossSection is the way.
It describes each layer strip as a signed [section_min, section_max] interval
in dbu relative to the port centerline. Both bounds are integers, so edges are
always on the dbu grid regardless of width parity. The strip's width is the
derived property section_max - section_min.
Asymmetric cross sections live in a separate registry on KCLayout
(asymmetrical_cross_sections) and have their own getters.
from kfactory.exceptions import (
AsymmetricMirrorRequiredError,
CrossSectionSymmetryMismatchError,
)
# A 301-dbu-wide strip shifted toward +y of the port centerline.
# Width is ODD (would be rejected by SymmetricalCrossSection) and the strip is
# off-center — [-100, 201] rather than [-150, 151].
acs = kf.kcl.get_asymmetrical_cross_section(
kf.AsymmetricalCrossSection(
layer=L.WG,
section_min=-100,
section_max=201,
name="ASYM_301",
)
)
print(f"width: {acs.width} DBU (odd!)")
print(f"main strip: [{acs.section_min}, {acs.section_max}]")
print(f"xmin/xmax: ({acs.get_xmin()}, {acs.get_xmax()})")
print(f"is_symmetric: {acs.is_symmetric()}")
width: 301 DBU (odd!)
main strip: [-100, 201]
xmin/xmax: (-100, 201)
is_symmetric: False
Cell convention for asymmetric profiles
For an asymmetric strip to chain correctly between cells, the cell's two ports must have matching profile orientation in cell-local coordinates. With a straight-like cell, the standard convention is:
o1 = R180at the left edge (faces -x, no mirror).o2 = M0at the right edge (faces +x withmirror=True).
The mirror flag on o2 flips the port-local frame so that "right side of
profile" maps to the same world-y direction at both ports.
@kf.cell
def asym_straight(cross_section: str = "ASYM_301") -> kf.KCell:
"""Asymmetric-cross-section straight.
Port convention:
o1 → R180 at left, faces -x.
o2 → M0 at right, faces +x with mirror (asymmetric requirement).
"""
c = kf.KCell()
xs = kf.kcl.get_asymmetrical_cross_section(cross_section)
length = 5_000 # 5 µm
c.shapes(kf.kcl.find_layer(xs.layer)).insert(
kf.kdb.Box(0, xs.section_min, length, xs.section_max)
)
c.create_port(name="o1", trans=kf.kdb.Trans(2, False, 0, 0), cross_section=xs)
c.create_port(name="o2", trans=kf.kdb.Trans(0, True, length, 0), cross_section=xs)
return c
straight = asym_straight()
print(
f"o1 trans: {straight['o1'].base.trans} mirror={straight['o1'].base.trans.mirror}"
)
print(
f"o2 trans: {straight['o2'].base.trans} mirror={straight['o2'].base.trans.mirror}"
)
straight.plot()
o1 trans: r180 0,0 mirror=False
o2 trans: m0 5000,0 mirror=True
Chaining: o2 → o1 works by default
Connecting inst_b.o1 to inst_a.o2 is the natural chain. With the port
convention above, o2 already carries the mirror flag, so the join between
the two ports is an M90 (mirror) transform — exactly what an asymmetric
profile needs to keep its left/right halves aligned. connect does not
copy the other port's mirror flag onto the instance; it simply honors the
per-port frames, so the default connect succeeds and neither instance ends
up with a mirror flag.
kfactory validates this by requiring the two connected asymmetric ports to
have opposite mirror flags in world space. If they'd line up with the same
orientation (an R180 join, which flips the profile), it raises
AsymmetricMirrorRequiredError. This applies to all asymmetric ports of the
same width and layer, see
Cross section checks in connect.
parent_chain = kf.KCell(name="asym_chain_ok")
ia = parent_chain << straight
ib = parent_chain << straight
# Default connect → succeeds, neither instance ends up with a mirror flag
ib.connect("o1", ia, "o2")
print("Default connect:")
print(f" ia.trans: {ia.trans} (mirror={ia.trans.mirror})")
print(f" ib.trans: {ib.trans} (mirror={ib.trans.mirror})")
parent_chain.plot()
Default connect:
ia.trans: r0 0,0 (mirror=False)
ib.trans: r0 5000,0 (mirror=False)
o1 ↔ o1: still possible, but one instance ends up mirrored
Connecting two of the same kind of end (both R180) needs mirror=True
too, but the result has one of the two instances flipped.
parent_o1o1 = kf.KCell(name="asym_chain_o1o1")
ia2 = parent_o1o1 << straight
ib2 = parent_o1o1 << straight
try:
ib2.connect("o1", ia2, "o1")
except AsymmetricMirrorRequiredError as e:
print("WITHOUT mirror=True, o1↔o1 raises:")
print(f" AsymmetricMirrorRequiredError: {e}\n")
ib2.connect("o1", ia2, "o1", mirror=True)
print("WITH mirror=True (one instance mirrored):")
print(f" ia2.trans: {ia2.trans} (mirror={ia2.trans.mirror})")
print(f" ib2.trans: {ib2.trans} (mirror={ib2.trans.mirror})")
parent_o1o1.plot()
WITHOUT mirror=True, o1↔o1 raises:
AsymmetricMirrorRequiredError: Cannot connect ports 'o1' and 'o1' carrying asymmetric cross sections without `mirror=True`. Asymmetric profiles require an M90 transformation (mirror) — R180 would flip the left/right halves. Pass `mirror=True` to `connect`.
WITH mirror=True (one instance mirrored):
ia2.trans: r0 0,0 (mirror=False)
ib2.trans: m90 0,0 (mirror=True)
Connecting symmetric ↔ asymmetric: structural mismatch
With strict cross section checks, a port carrying a SymmetricalCrossSection
cannot connect to one carrying an AsymmetricalCrossSection of the same width
and layer — the two are structurally different objects. This raises
CrossSectionSymmetryMismatchError. By default, they connect.
@kf.cell
def sym_straight(cross_section: str = "WG_500") -> kf.KCell:
"""Plain symmetric straight for the mismatch demo."""
c = kf.KCell()
xs = kf.kcl.get_icross_section(cross_section)
length = 5_000
c.shapes(kf.kcl.find_layer(xs.layer)).insert(
kf.kdb.Box(0, -xs.width // 2, length, xs.width // 2)
)
c.create_port(name="o1", trans=kf.kdb.Trans(2, False, 0, 0), cross_section=xs)
c.create_port(name="o2", trans=kf.kdb.Trans(0, False, length, 0), cross_section=xs)
return c
# A 500-dbu asymmetric strip, so that it matches WG_500 in width and layer.
kf.kcl.get_asymmetrical_cross_section(
kf.AsymmetricalCrossSection(
layer=L.WG, section_min=-200, section_max=300, name="ASYM_500"
)
)
parent_mix = kf.KCell(name="asym_sym_mismatch")
sym_inst = parent_mix << sym_straight("WG_500")
asym_inst = parent_mix << asym_straight("ASYM_500")
try:
asym_inst.connect("o1", sym_inst, "o2", allow_cross_section_mismatch=False)
except CrossSectionSymmetryMismatchError as e:
print("Strict symmetric ↔ asymmetric connect raises:")
print(f" CrossSectionSymmetryMismatchError: {e}")
asym_inst.connect("o1", sym_inst, "o2")
print("Default connect succeeds.")
Strict symmetric ↔ asymmetric connect raises:
CrossSectionSymmetryMismatchError: Cross section symmetry mismatch between ports 'o1' (asymmetric) and 'o2' (symmetric). Symmetric and asymmetric cross sections cannot be connected.
Default connect succeeds.
Summary of asymmetric port behavior
| Scenario | connect() |
connect(mirror=True) |
|---|---|---|
| sym ↔ sym | works | works |
| asym o2 → asym o1 (chain) | works, no instance mirrored | AsymmetricMirrorRequiredError |
| asym o1 ↔ asym o1 | AsymmetricMirrorRequiredError |
works, one instance mirrored |
| sym ↔ asym (same width and layer) | works; strict: CrossSectionSymmetryMismatchError |
same |
The check is on the mirror flags: kfactory requires the two connected
asymmetric ports to end up with opposite mirror orientations (an M90 join). A
natural o2 → o1 chain already satisfies this via the port frames, so it
works without mirror=True, and adding mirror=True would over-mirror it and
raise.
Cross section checks in connect
Besides width, layer and port type, connect checks the cross sections of
the two ports. These checks only apply to ports with the same width and main
layer: a cross section determines both, so an allowed width or layer mismatch
already implies a cross section mismatch, and the checks are skipped.
Two asymmetric profiles must never be flipped against each other: their
ports need opposite mirror flags after the connection, otherwise
AsymmetricMirrorRequiredError is raised (see above). Only then do both
profiles share a frame, and the allow_cross_section_mismatch flag selects
how strictly they are compared:
| Ports (same width and layer) | True (default) |
False (strict) |
|---|---|---|
| symmetric ↔ asymmetric | connects | CrossSectionSymmetryMismatchError |
| asymmetric, identical | connects | connects |
| asymmetric, different | core offsets (section_min/section_max) must match, otherwise AsymmetricCoreMismatchError |
CrossSectionMismatchError |
| symmetric, different enclosure | connects | connects |
The default is True for now. It is planned to change to False in
kfactory 4.0.
For a single connection, pass the flag to connect. VInstance.connect
and InstanceGroup.connect take it as well:
inst.connect("o1", other, "o2", allow_cross_section_mismatch=False)
Globally, set it in the config. A value passed to connect still takes
precedence:
kf.config.allow_cross_section_mismatch = False
The config can also be set from the environment or a .env file, see
Config:
export KFACTORY_ALLOW_CROSS_SECTION_MISMATCH=false
Impact. The default doesn't catch symmetric ↔ asymmetric connections or asymmetric cross sections that only differ outside their core. Strict checks do, but:
- Every connection between asymmetric ports of the same width and layer
needs identical cross sections. Different extra sections or offset cores
raise
CrossSectionMismatchError. - Connecting a symmetric to an asymmetric port of the same width and layer
raises
CrossSectionSymmetryMismatchError. - Connections between symmetric ports are unaffected:
connectnever compares enclosures. Usekf.checks.port_mismatch_check(cell, check_cross_section=True)to report connected ports whose cross sections differ. - Routing functions don't take the flag and use the config value, so setting it globally applies to routes as well.
The demo below connects two asymmetric cross sections that share a core but differ in their extra sections.
from kfactory.exceptions import CrossSectionMismatchError
kf.kcl.get_asymmetrical_cross_section(
kf.AsymmetricalCrossSection(
layer=L.WG,
section_min=-200,
section_max=300,
sections=(
kf.CrossSectionLayer(layer=L.WGCLAD, section_min=-200, section_max=2_300),
),
name="ASYM_500_CLAD",
)
)
parent_xs = kf.KCell(name="asym_xs_mismatch")
plain = parent_xs << asym_straight("ASYM_500")
clad = parent_xs << asym_straight("ASYM_500_CLAD")
try:
clad.connect("o1", plain, "o2", allow_cross_section_mismatch=False)
except CrossSectionMismatchError as e:
print("Strict connect raises:")
print(f" CrossSectionMismatchError: {e}\n")
clad.connect("o1", plain, "o2")
print("Default connect succeeds, the cores match.")
parent_xs.plot()
Strict connect raises:
CrossSectionMismatchError: Cross section mismatch between the ports 'o1' ('ASYM_500_CLAD') and 'o2' ('ASYM_500').
Default connect succeeds, the cores match.
Bounding-box layers
Both symmetric and asymmetric cross-section wrappers provide add_bbox.
It draws bbox_sections, not the ordinary cladding sections, into a KCell,
DKCell, or VKCell. Choose the reference bounds using one of these alternatives:
xs.add_bbox(cell) # all geometry, including instances
xs.add_bbox(cell, ref=xs.layer) # bounds on one layer
xs.add_bbox(cell, ref=instance) # transformed instance bounds
xs.add_bbox(cell, ref=kf.kdb.DBox(0, -1, 10, 1)) # explicit micrometer box
xs.add_bbox(cell, ref=xs.layer, top=0) # no padding on the top edge
ref accepts a LayerInfo, a layer index in the target layout, an integer Box
in the target layout's dbu, or a DBox in micrometers.
Instance, DInstance, and VInstance references are also supported, using their
transformed bounds in their parent's coordinates. Those coordinates must
match the target cell's coordinate system. Cells are not accepted as ref;
use cell.dbbox() explicitly instead.
The cross section, target, and any instance reference must share the exact same KCLayout object. Equal dbu values are insufficient: layer indices belong to their layout. A mismatch raises ValueError before resolving layers or inserting geometry.
Padding and the optional top, bottom, left, and right overrides use the wrapper's
units: micrometers for DCrossSection/DAsymmetricCrossSection and dbu for
CrossSection/AsymmetricCrossSection. None uses the layer's stored padding;
zero suppresses padding on that edge. All bbox layers use the same original
reference bounds, so padding does not accumulate between layers. Empty
references draw nothing, and locked cells are rejected. Cross-section
metadata and supplied boxes are unchanged.
Real cells use integer DBU boxes and padding, converted before drawing.
Virtual cells use a separate micrometer implementation without grid snapping.
Summary
| Need | Use |
|---|---|
| Human-friendly µm API | kf.DCrossSection(kcl, width, layer, sections, ...) |
| DBU integer precision | kf.CrossSection(kcl, width, layer, sections, ...) |
| Reuse an existing enclosure | SymmetricalCrossSection(width, enclosure) |
| Register / look up by name | kf.kcl.get_icross_section(name_or_spec) |
| µm view of a registered xs | kf.kcl.get_dcross_section(name_or_spec) |
See Also
| Topic | Where |
|---|---|
| Layer enclosures (auto-cladding) | Enclosures: Layer Enclosure |
| Cell-level enclosures (tiling) | Enclosures: KCell Enclosure |
| Straight waveguide (uses xs) | Components: Straight |
| Width tapers (uses xs) | Components: Tapers |
| Routing with cross-sections | Routing: Overview |