Step-by-step tutorial · imaging

Step-by-step: five kinds of picture from one GDS

This walks through example_template/imaging/, a runnable starter shipped with the wheel since klink 0.3.3. The imaging domain is klink's dedicated picture-making group: a process cross-section, a per-step process film, a 3D model with a self-contained web viewer, an SEM-style top view, and a paper-grade Blender render -- all driven by three files you own. klink's mechanism layer carries zero process data.

Breaking in 0.4.0: all four imaging tools now require a style. klink is the mechanism layer, and it used to hold numbers that are somebody's taste tuned against somebody's device — sun energy, camera lens, beam blur, page colour, even the colour of every atom in a lattice figure. All of it now lives with you; klink ships none of it and refuses to guess. Upgrading is three steps: klink update for the four style files, python sem_style.py to write its JSON, then pass style=. Full notes in the v0.4.0 release. (A section GDS has no look, so imaging.xsection_run without render still needs nothing.)

What this is

One GDS, plus three files you own, gets you five kinds of picture. Only these three EXAMPLE-owned files drive them -- klink supplies mechanism only (binding the xsection engine, generating GLB meshes, SEM raster rendering, building Blender scenes), zero process constants:

Your fileWhat it is
demo_layout.pyThe example layout: a row of four CMOS transistors (two NMOS in the p-substrate, two PMOS in an n-well), poly gates, a contact on every source/drain, and metal-1 straps plus power rails. Also exports the section cut line, CUT_UM. Editing this file is how you switch to your own device.
demo.pyxsThe process recipe -- trusted Python, executed in-process; # klink-step: <name> comments mark each process step. Editing this file is how you switch to your own flow.
demo_stack.pyA klink_visual_stack_v1 declaration: per-layer z0_um/z1_um, color, SEM greyscale level (sem_grey), material class (solid / lattice / dielectric), and the matching recipe symbol (recipe_symbol). Editing this file is how you switch to your own process.
section_style.py
sem_style.py
viewer_style.py
blender_style.py
Four appearance declarations, one per exit: page colours, rulers, beam parameters, lighting, camera, material recipes. klink ships no default for any of them — a render with no style is refused rather than drawn with somebody else's taste. Every number in these files says what it controls and what happens if you change it. Editing these is how you change the look.

This tutorial walks the shipped starter: example_template/imaging/demo_layout.py, demo.pyxs, demo_stack.py, and the four demo scripts (xsection_demo.py / render3d_demo.py / sem_demo.py / blender_demo.py). Everything runs offline; outputs land in _generated/.

Prerequisites

  • Install klink: pip install klayout-klink.
  • Each of the four exits has its own optional dependency set -- install on demand; whatever you're missing, klink's error names the exact pip command:
pip install klayout klayout-pyxs==0.1.13            # cross-section engine
pip install trimesh shapely mapbox-earcut           # 3D
pip install numpy scipy pillow                      # SEM / film rendering
pip install bpy                                     # Blender (~300MB, optional)
None of this needs KLayout running -- all four demo scripts are plain Python, reading/writing GDS/GLB/PNG/HTML files offline. When connected to KLayout/klink-mcp, the same functionality is four MCP tools: imaging.xsection_run / imaging.render3d / imaging.sem_top / imaging.blender, with parameters matching these scripts one-for-one; klink.find_tools domain=imaging shows the full usage.

Get the starters

klink init myproj
cd myproj/example_template/imaging
python xsection_demo.py

After upgrading klink, run klink update to refresh the starters (new/fixed files get restored; old files you never touched get cleaned up).

Important: the whole example_template/ directory is package-owned -- klink update refreshes it back to the shipped state. So copy demo_layout.py / demo.pyxs / demo_stack.py out to your project root or your own directory before editing (same treatment as pdk.py); don't edit the files inside example_template/ directly. klink update never touches a demo's _generated/ output directory.

1Cross-section + per-step process film

xsection_demo.py first draws a row of four CMOS transistors with demo_layout.py: two NMOS in the p-substrate on the left, two PMOS in an n-well on the right, poly gates crossing the active strip, a contact on every source/drain, and metal-1 straps plus power rails top and bottom. Then it sections the device along an explicit cut line, CUT_UM = [[-0.8, 1.9], [11.2, 1.9]] (straight through the middle of the active row, crossing all four gates, the spacers, and the well edge): once as a single-frame cross-section, once as a per-step process film with steps=True.

python xsection_demo.py
Eleven stacked cross-section frames, from bare substrate through n-well implant, LOCOS field oxide, poly gate, LDD, spacer, source/drain implants, ILD, and tungsten-plug contacts, building up to finished metal-1
The per-step process film (film_film.png): one cross-section frame per klink-step marker in demo.pyxs, from bare substrate through finished metal-1 interconnect -- 11 markers, 11 frames.

demo.pyxs marks process steps with # klink-step: <name> comments -- each marker names the step that follows it, and the film renders one frame per marker. This recipe has 11 markers: p-substrate, n-well implant, LOCOS field oxide, gate oxide, poly gate + silicide, LDD implants, spacer, source/drain implants, ILD deposition, contact etch + W plug + CMP, metal-1 damascene. Here's an excerpt (see demo.pyxs for the full 11 steps):

# klink-step: p-substrate
pbulk = bulk()

# klink-step: n-well implant
nwell = mask(lnwell).grow(0.55, -0.05, mode='round', into=pbulk)

# klink-step: LOCOS field oxide
mfield = mask(lfield)
_fox_up = mfield.grow(0.22, 0.22, bias=0.12, mode='round')
_fox_dn = mfield.grow(0.22, 0.22, bias=0.12, mode='round',
                     into=[pbulk, nwell])
fox = _fox_up.or_(_fox_dn)

# ... gate oxide / poly gate + silicide / LDD implants / spacer /
# source-drain implants / ILD deposition go here (full code in demo.pyxs) ...

# klink-step: contact etch + W plug + CMP
mask(lcontact).etch(0.85, into=ild, taper=5)
tungsten = deposit(0.18, 0.18)
planarize(into=[tungsten, ild], less=0.5)

# klink-step: metal-1 damascene
imd = deposit(0.22)
mask(lmetal1).etch(0.32, into=imd, taper=5)
metal1 = deposit(0.22, 0.22)
planarize(into=[metal1], less=0.22)

A material assigned to a variable is auto-output under that name, no output() call needed -- except when the variable name starts with _: _fox_up / _fox_dn above are the intermediate results of growing the two LOCOS field-oxide halves separately; the leading underscore marks them as intermediates, used only to build the final fox via .or_(), and they never appear as materials of their own in the output.

The five most instructive frames (see the full 11-frame film above):

Step 1 cross-section: a single uniform tan p-substrate block below the cut line, empty air above it, no structure yet
Step 1/11 · p-substrate
Step 3 cross-section: a rounded blue-grey field-oxide bump appears at each end of the cut, with a lighter n-well region visible right of center
Step 3/11 · LOCOS field oxide
Step 5 cross-section: four small dark-red poly-gate blocks are now evenly spaced across the silicon surface, with the field-oxide bumps still visible at both ends
Step 5/11 · poly gate + silicide
Step 10 cross-section: a light blue-grey ILD film covers the whole surface, with several dark-grey tapered tungsten plugs punching straight down through it to the green and purple source/drain regions below
Step 10/11 · contact etch + W plug + CMP
Step 11 (final) cross-section: a blue-grey metal layer now sits on top of the tungsten plugs, connecting down to the source/drain regions below
Step 11/11 · metal-1 damascene

MCP tool: imaging.xsection_run -- cut_um must be given explicitly (µm), steps=true plus the recipe's # klink-step: markers produce the film. z_window_um fixes one z window for every frame (here (-1.1, 1.5) -- the engine's substrate runs several microns deep, so an unframed section is mostly bulk), and axis=true draws a labeled z / µm ruler down the left side of every frame plus a horizontal scale bar in the corner (that's the tick marks on the left and the "1 µm" segment in each image above). Outputs are deterministic (no GDS timestamps), overwrite=false by default, and every run writes a klink_imaging_result_v1 machine-readable sidecar.

23D + self-contained web viewer

render3d_demo.py turns the same four-transistor CMOS row into three things: a viewer for the whole model, a cutaway viewer for the same model sliced open, and a light-page viewer for the same GLB with a different palette. The model itself is an honest drawing of the masks: every stack layer's polygons are extruded between its own z0_um/z1_um -- a circle stays a smooth prism, nothing is invented. What an extrusion can never contain is process curvature -- LOCOS bird's beaks, tapered contact holes, CMP dishing -- none of that is in the GDS, so none of it belongs in this model; that lives in the 2D sections of imaging.xsection_run, which are engine-exact. 0.5.8 removed the old "process sweep" 3D mode (it stacked a stack of 1D-engine sections into a body, which staircased everything it claimed to show), and replaced it with two declared, never-guessed capabilities:

  • sidewall_deg (a per-layer stack field, default 0 = vertical) lofts a layer's walls to one smooth tilt -- the top face is pulled inward by thickness*tan(angle). This demo declares 5 degrees on contact and metal-1, the same angle demo.pyxs etches with. A polygon too narrow to survive the tilt is drawn vertical and reported in the result's warnings.
  • cutaway_um=[x0,y0,x1,y1] boolean-cuts the finished model down to this region (µm): the model is built whole first, then cut, so the section shows only on the cut faces (needs pip install manifold3d).
python render3d_demo.py
# ---- 1. the model: a 3D drawing of the masks ---------------------
glb = OUT / "render3d_fast.glb"
r = build_glb_fast(str(GDS), STACK, str(glb), STYLE)
build_viewer_html(str(glb), str(OUT / "render3d_fast.html"),
                  STYLE, title="demo fast", overwrite=True)

# ---- 2. the same model, cut open ---------------------------------
cut_glb = OUT / "render3d_cutaway.glb"
rc = build_glb_fast(str(GDS), STACK, str(cut_glb), STYLE,
                    cutaway_um=[-0.8, 1.9, 11.2, 4.7])
build_viewer_html(str(cut_glb), str(OUT / "render3d_cutaway.html"),
                  STYLE, title="demo cutaway", overwrite=True)

All three generated .html files are fully self-contained and offline (viewer JS and model data are both encoded into the same file): double-click to open in a browser, drag to rotate, and the right-hand panel lets you tune each layer's color/metallic/roughness/alpha and export a PNG.

0.5.2 shipped two fixes from real user feedback: the GLB export is now spec-correct glTF +Y-up, and the viewer frees model-viewer's default 22.5-157.5° polar clamp to the full 0-180° -- rotation no longer walls off, and every face of the model is reachable. The per-material panel also gained an alpha slider (A, next to metallic M and roughness R): fade a layer down to highlight or de-emphasize it -- below 1 the material switches to BLEND automatically, back at 1 its own mode returns, and Reset restores the declared look. The viewer embedded below is the cutaway one -- drag it all the way around and try the A slider.

This is the cutaway viewer render3d_demo.py writes, embedded as-is: the model is built whole first, then boolean-cut, keeping the side y >= 1.9 µm so the cut face runs through the four gates; drag to rotate, scroll to zoom, and use the right-hand panel for background, exposure, tone mapping, shadows, per-material color/metallic/roughness/alpha, and PNG export. It is a single offline file — viewer JS and model data are both encoded into that one HTML — so opening it directly or mailing it to a colleague works the same, with no network and nothing to install.
A rendered view of the render3d_fast GLB: an elongated rectangular die with four dark-red poly-gate blocks evenly spaced across a pale active strip, contact plugs between them, and a metal-1 ladder floating at its declared height above, all sitting on an n-well/substrate slab, on a transparent film with soft shadowing
Not a screenshot of the self-contained viewer -- this is the same render3d_fast.glb restaged by blender_demo.py's mode="die" on a transparent film with a shadow catcher: the metal-1 ladder floating at its declared z over the red poly gates crossing the pale active strip, contact plugs between them, all on the n-well/substrate slab; contact and metal-1 carry the stack's declared 5-degree sidewall tilt.

MCP tool: imaging.render3d -- extrudes each layer of a klink_visual_stack_v1 declaration into a mask solid; the old process-sweep parameters (mode, recipe, exclude, and the rest) are gone in 0.5.8, replaced by the stack's per-layer sidewall_deg (smooth tilt) and the call-time cutaway_um (build whole, then boolean-cut).

As of 0.5.8, every 3D layer read follows the same slit contract: a zero-width GDS keyhole cut-line (the storage artifact a drawn hole becomes) dissolves silently; a slit with real width (>= 1 dbu) may be drawn intent (a nanogap, say), so it is kept and reported in the result's warnings; weld_slits_dbu (default 0) is the explicit weld override for confirmed artifacts. The same contract governs every 3D layer read by imaging.xsection_run and imaging.blender (figure mode). The new starter example_template/imaging/keyhole_3d_demo.py walks through all three rules.

3SEM-style top view

sem_demo.py renders the layout the way a secondary-electron micrograph would roughly show it: per-layer sem_grey/edge_glow from the VisualStack drive grey emission levels and bright topography rims, plus litho corner rounding, beam blur, seeded film grain, scanlines, and vignette -- the same seed always gives the identical image.

python sem_demo.py
Greyscale SEM-style top view: a thick bright power rail top and bottom, nine bright vertical bars of alternating height in between crossing a slightly brighter horizontal active band, background darker on the left half and a lighter dark grey on the right half, with the split running down the middle
Greyscale variant (sem_top.png): the two power rails top and bottom; the nine vertical bars in between alternate between the four poly gates and five metal-1 contact columns, crossing the active area. The left half of the background (p-substrate) is darker than the right half (n-well) -- per-layer sem_grey emission levels drive that contrast.
False-color SEM-style top view: red vertical bars (poly gates) alternate with pale blue-grey vertical bars (metal-1) crossing a green active band, with a pale blue-grey power rail top and bottom, background dark navy on the left and olive-brown on the right
False-color variant (sem_top_color.png): the same underlying data, a different read -- red is poly gate, pale blue-grey is metal-1, the green band is the active area, and the background colors separate p-substrate (dark navy) from n-well (olive-brown).

The script also renders a cumulative "masks printed so far" sequence, following demo_stack.py's layer order -- paired with the recipe's # klink-step: order, that's the top-view equivalent of the per-step process film:

Top view after only the n-well mask has printed: the frame is almost entirely blank, showing only a single vertical boundary line separating a slightly darker region from a slightly lighter one, with no other visible structure
Top view after only mask 1/0 (n-well) has printed (sem_after_1_0.png) -- n-well is an implant with no topography of its own, so at this step the frame is just a flat grey field split into two shades by the well boundary; the active/poly/contact/metal masks that print later aren't visible at all yet. The layers= parameter restricts rendering to the masks printed so far.

MCP tool: imaging.sem_top -- greyscale and false-color PNGs come out of one call, layers=[...] restricts to the mask subset printed at a given step, rendering is deterministic (same seed, same result).

4Blender paper-grade renders

blender_demo.py uses headless bpy to produce two figures from the same declarations:

python blender_demo.py
  • mode="figure": a 2D-material device at 1:1 layout coordinates -- layer 10/0 is a MoS2 flake; declaring kind="lattice", motif="mos2" grows a real atomic lattice (motif="graphene" is also supported); layer 20/0 is Au electrodes, drawn as exact layout prisms; plus explicit Si/SiO2 substrate slabs.
  • mode="die": restages render3d_fast.glb (the section 2 output) on transparent film with a shadow catcher -- this is the same die render already shown above.
A paper-grade render of a MoS2 field-effect device: an irregular hexagonal atomic-lattice flake in the middle, a gold rectangular electrode on each end, and a grey-blue SiO2/Si substrate block below
blender_figure.png: the MoS2 flake grows a real atomic lattice (kind="lattice", motif="mos2"), the Au electrodes are exact layout prisms, and the substrate is an explicit SiO2/Si slab.

Both modes also write a .blend file (blender_figure.blend / blender_die.blend) -- open it in desktop Blender to adjust camera/lights/materials by hand and re-render; it's not a one-shot black-box output.

MCP tool: imaging.blender -- runs headless bpy in an isolated subprocess; mode="die" needs a GLB already produced by imaging.render3d (or render3d_demo.py); mode="figure" takes gds + stack directly and needs bpy (plus trimesh/klayout-pyxs==0.1.13 if you also want the die figure).

Does it get the physics right?

This isn't marketing language -- it's a checkable result: take the same layout, write the same handful of contact-step lines in the .pyxs recipe three different ways, and whether the tungsten plug actually shows up in the generated 3D model comes out completely different each time -- causality lives in the recipe order, not in the layout coordinates.

VariantRecipe changeW plug in the model
A -- the shipped flow ild = deposit(...) → mask(lcontact).etch(0.85, into=ild, taper=5) → tungsten = deposit(...) → planarize(...) z = 0.00 - 0.49 µm: the plug runs from the silicon surface up to the CMP plane.
B -- contact etch deleted the mask(lcontact).etch(...) line removed The W plug material is absent from the model entirely -- with no hole to fill, the CMP takes all the tungsten back off.
C -- contact etch moved before the ILD the same etch line, run before ild = deposit(...) Absent as well -- it opened holes in a film that hadn't been deposited yet, the equivalent of etching thin air.

The layout is byte-identical in all three variants -- only the recipe order differs. B and C are both open circuits: every transistor is broken at the contact level, and the device never conducts. And nothing in a layout view would show it -- an ordinary layout checker only looks at the polygons in the GDS, and the GDS is the same file in all three variants. Only the cross-section (imaging.xsection_run) catches this, because it actually runs the recipe in order; render3d's 3D now only extrudes the GDS mask polygons and has no idea about recipe order, so all three variants extrude to the identical model -- exactly the effect of 0.5.8 splitting the 3D and 2D responsibilities cleanly.

Worth noting for honesty: metal-1 sits at z = 0.45 - 0.93 µm in all three cases -- the upper levels look perfectly fine, because the metal-1 damascene step doesn't care whether the tungsten plug below it exists. The failure is only visible at the contact level; you'd never catch it from the layout or from a metal-only view.

Authoring gotcha: deposit is not patterned

Verb semantics (the easiest trip-up): patterned deposition is mask(l).grow(...) -- there is no mask(l).deposit(); deposit(...) is always a blanket (whole-surface) operation. etch(...) is blanket too; patterned etch is mask(l).etch(...). demo.pyxs is a live example: nwell uses mask(lnwell).grow(...) (patterned growth), gox/ild/tungsten/imd/metal1 use blanket deposit(...), and the contact hole and metal-1 trench use mask(lcontact).etch(...) and mask(lmetal1).etch(...) respectively (patterned etch).

Determinism · output contract

  • Never silently overwrites: if the target file already exists, the call is rejected by default -- you must pass overwrite=True explicitly to replace it.
  • Every run writes a machine-readable sidecar: *.klink_imaging.json, format klink_imaging_result_v1 -- it carries the input cut_um/parameters, a sha256 for every output file, and sha256es for the GDS/recipe inputs, so a script or CI can read this JSON directly instead of parsing human-readable logs.
  • GDS output is byte-reproducible: no timestamps are written, so the same inputs produce byte-identical GDS across runs.

Engine & viewer credits

Cross-sections are driven by the klayout-pyxs engine -- klink imports it as a dependency, pinned to ==0.1.13, and never vendors it into the repo. The 3D viewer embeds Google's <model-viewer> (a bundled copy is vendored so the exported .html can open fully offline). What klink itself writes is the glue: GLB mesh generation, SEM raster rendering, and Blender scene construction -- not the cross-section engine or the viewer itself.

Next steps

Copy demo_layout.py, demo.pyxs, and demo_stack.py into your own project, swap in your real layout, layer numbers/z-ranges/colors, and your real process steps (deposition thickness, etch depth, mask layers), and rerun the four scripts unchanged. To check the physics really holds for your process, do what the "Does it get the physics right?" section above did: move one line in your recipe to where it actually belongs (say, an etch to before the deposition it's supposed to follow) and compare whether a material vanishes entirely from the cross-section (imaging.xsection_run) -- a material disappearing means the recipe is actually driving geometry, not decoration; the 3D drawing (imaging.render3d) only extrudes GDS mask polygons and can't show a recipe-order difference. All four exits share one VisualStack declaration, so changing a layer color once updates the cross-section, 3D, SEM, and Blender pictures together.