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.

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.

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 two GLB models plus two self-contained web viewers:

python render3d_demo.py
fast_glb = OUT / "render3d_fast.glb"
build_glb_fast(str(GDS), STACK, str(fast_glb))                    # extrudes the STACK declaration
build_viewer_html(str(fast_glb), str(OUT / "render3d_fast.html"))

proc_glb = OUT / "render3d_process.glb"
build_glb_process(str(GDS), STACK, str(RECIPE), str(proc_glb),
                  slices=24, fraction=0.6)                        # sweeps the real recipe; fraction<1 = cutaway
build_viewer_html(str(proc_glb), str(OUT / "render3d_process.html"))

Fast mode directly extrudes the z0_um/z1_um from demo_stack.py -- quick, but the shapes are idealized layer blocks; process mode sweeps the real .pyxs recipe slice by slice, so real topology like LOCOS, conformal layers, or CMP curvature shows up -- this run also passes fraction=0.6, producing a cutaway where the exposed face is a true cross-section. The generated .html is fully self-contained and offline (the viewer JS and the model data are both encoded into the same file): double-click to open in a browser, drag to rotate, and a right-hand panel lets you tune each layer's color/metallic/roughness and export a PNG.

This is the file render3d_demo.py writes, embedded as-is: drag to rotate, scroll to zoom, and use the right-hand panel for background, exposure, tone mapping, shadows, per-material color/metallic/roughness, 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 process-cutaway GLB: an elongated rectangular die block with a shallow channel running along its top, four small dark-red poly-gate blocks evenly spaced along it, and dark-grey vertical contact/metal columns between the gates, on a transparent film with soft shadowing
This is not a screenshot of the self-contained web viewer (that's an offline HTML page you double-click open yourself) -- it's the same render3d_process.glb model, restaged by blender_demo.py's mode="die" onto a transparent film with a shadow catcher: you can see the shallow channel along the top, the four dark-red poly-gate blocks, and the dark-grey contact/metal columns between them, included here to show what the model looks like.

MCP tool: imaging.render3d -- mode="fast" extrudes a klink_visual_stack_v1 declaration; mode="process" sweeps the xsection engine, mapping recipe materials (pbulk, nwell, tungsten, ...) to colors through recipe_symbol; anything unmapped goes grey and is reported as unstyled.

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_process.glb (the section 2 output) on transparent film with a shadow catcher -- this is the same cutaway chip image 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 process-mode 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 and the process-mode 3D catch this, because they actually run the recipe in order instead of stacking idealized layer blocks.

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 process mode (imaging.render3d mode="process") or the cross-section (imaging.xsection_run) -- a material disappearing means the recipe is actually driving geometry, not decoration. All four exits share one VisualStack declaration, so changing a layer color once updates the cross-section, 3D, SEM, and Blender pictures together.