No description
  • Rust 92.6%
  • Nix 4.3%
  • Python 2.9%
Find a file
andi ee4c20d6fa D255, D256 and D257
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Wsct4bxTmseK4cuK6nFgRD
2026-09-14 14:45:56 +00:00
blocks/ldo_3v3 design blocks: the group link, and laying one out by hand 2026-09-03 20:19:29 +00:00
crates A sheet's embedded symbol matches the library, and ERC is measured as a person sees it 2026-09-14 14:43:49 +00:00
docs D255, D256 and D257 2026-09-14 14:45:56 +00:00
examples A sheet's embedded symbol matches the library, and ERC is measured as a person sees it 2026-09-14 14:43:49 +00:00
nix The root arranges itself by what its sheets are joined to 2026-09-12 20:58:33 +00:00
python 06 and 07 buy a regulator that can shed 330 mW 2026-09-10 02:46:25 +00:00
spikes Every table in the tree, laid out 2026-09-03 15:45:25 +00:00
templates/block-library Hold a lock from the read to the write, and keep both runs' rows 2026-09-11 19:58:54 +00:00
.envrc Nix flake defines the environment; no more store paths in source 2026-09-01 22:19:22 +00:00
.gitignore Hold a lock from the read to the write, and keep both runs' rows 2026-09-11 19:58:54 +00:00
Cargo.lock The page check reads all four sides, and one SVG parser reads the export 2026-09-12 19:54:22 +00:00
Cargo.nix The page check reads all four sides, and one SVG parser reads the export 2026-09-12 19:54:22 +00:00
Cargo.toml Hold a lock from the read to the write, and keep both runs' rows 2026-09-11 19:58:54 +00:00
flake.lock A Markdown table this project writes can be read without a renderer 2026-09-03 15:45:18 +00:00
flake.nix checks.emacs: the shell's Emacs opens a Rust buffer, and it goes red without one 2026-09-07 13:34:34 +00:00
README.md A sheet's embedded symbol matches the library, and ERC is measured as a person sees it 2026-09-14 14:43:49 +00:00

kicad-automation

Describe a circuit in Rust; get a KiCad schematic. Not a picture of one — a real project you open in KiCad and go on to lay out: .kicad_sch files with symbols placed, wires routed, references annotated, LCSC part numbers filled in, and a .kicad_pro carrying whatever net classes the design declared. Nothing in a design names a coordinate, and in the demonstrator nothing names a part number either.

The reason to want that is not that drawing a schematic is hard. It is that drawing the same 3.3 V rail for the fifth time is a fresh chance to put the feedback divider on the wrong side of the capacitor, and that a schematic cannot be asked why — why 15 pF, why 5.1 kΩ, why that footprint. A design that is a program can be asked, and can be checked.

The idea, in one sheet

This is the charger sheet of examples/src/devboard.rs, with its longer comments cut. Nothing else about it is abridged.

use ka::prelude::*;
use ka_examples::{sym, JST_PH2, SOT23_5};

fn charger() -> Result<Design, BuildError> {
    let s = Sheet::new("charger", "Single-cell lithium charging from USB.");

    // Named by hand, because nothing could choose them for you.
    let u = s.next_part::<sym::Mcp73812>("MCP73812", SOT23_5);
    let j = s.next_part::<sym::Conn1x02>("CELL", JST_PH2);

    // 0.2 C on a 1000 mAh cell. The MCP73812 makes I_reg = 1000 V / R_prog, so
    // asking for a charge rate gives you the resistor -- rounded *up* onto E24,
    // because a larger programming resistor is a smaller charge current.
    let r_prog = Series::E24.ceil_q(volts(1000.0) / milliamps(200.0));

    // Described, not part-numbered. The catalogue picks one and writes the
    // LCSC number into parts.lock.json.
    let rprog = s.next_res(r_prog, P0402);
    let cin = s.next_cap(microfarads(4.7), P0603.x5r_x7r().rated(volts(16.0)));
    let cbat = s.next_cap(microfarads(4.7), P0603.x5r_x7r().rated(volts(10.0)));

    s.net("+5V").tie((u.v_dd, u.ce, cin.p1));
    s.net("+BATT").tie((u.v_bat, cbat.p1, j.pin_1));
    s.net("PROG").tie((u.prog, rprog.p1));
    s.net("GND").tie((u.v_ss, rprog.p2, cin.p2, cbat.p2, j.pin_2));
    s.export(&["+5V", "+BATT", "GND"]);
    s.finish()
}

That is the whole sheet. This is what it draws:

the devboard's charger sheet

and this is part of what the run prints while drawing it, re-wrapped to fit:

· charger    5 parts,   13 wires,   2 labels,  3 picked
  · R201  196 mA charge  MCP73812, DS22036B §4.4: I_reg = 1000 V / R_prog,
                         rounded up onto E24. The formula belongs to this
                         controller; another charger IC has a different one,
                         so it is written beside the part rather than assumed
  · U201  0.39 W         charging a flat cell: 5 V to 3 V at 196 mA burns 393
                         mW -- 60% efficient, which for a linear regulator is
                         just the voltage ratio. 393 mW through 220 °C/W
                         raises the junction 86 °C above a 40 °C ambient, to
                         126 °C
  ! thermally tight
    only 4% of the temperature rise budget is left on the pessimistic
    estimate, and θJA is a property of your board as much as of the package.
    fix: more copper on the thermal pad, a lower ambient, or less current.

· sheet    ref    value   footprint            LCSC     tier   MPN
  charger  R201   5.1k    R_0402_1005Metric    C25905   basic  0402WGF5101TCE
  charger  C201   4.7uF   C_0603_1608Metric    C19666   basic  CL10A475KO8NNNC
  charger  C202   4.7uF   C_0603_1608Metric    C19666   basic  CL10A475KO8NNNC

! 41 infos, 15 warnings, no errors

The marker in the left column is the whole point of the shape. · is information, ! is legal-but-it-will-bite, ✗ is something that failed; the count at the end says whether there was anything, so "do I have to read this?" is one glance. On a terminal each marker is coloured; piped to a file, captured by a test or collected into a Nix builder's log there are no escape codes at all, and NO_COLOR and TERM=dumb are honoured. See docs/decisions.md D31.

Five things in that output are worth naming, because none of them is in the source.

R201 is not. No designator is typed in that function. Each sheet owns a hundred reference numbers from its position in the project — the charger is page 2, so its parts are the two-hundreds — and next_res takes the next free one. That is KiCad's own per-sheet convention, so C204 reads as "the fourth capacitor of sheet 2" to anybody who has used it. The one thing to know is what happens when the project changes: adding or renaming a sheet moves nothing, but inserting or reordering one renumbers every sheet after it, which matters once a designator is silkscreened on a board you have had made. Sheet::numbering(200) pins a sheet's hundred so it stops moving; see the ka::numbering module for the whole rule.

5.1k is not. You asked for 200 mA of charge current; 1000 V / 200 mA is 5 kΩ, which is not a value anybody stocks, and Series::E24.ceil_q moved it to the next value up that is. The direction is written down because it decides the answer: 5.1 kΩ charges at 196 mA and the E24 value below it, 4.7 kΩ, charges at 213 mA, and on a lithium cell being wrong low is the safe way to be wrong.

C19666 is not. No part number appears anywhere in that function. The capacitors were described — 4.7 µF, an 0603 land pattern, an X5R or X7R dielectric, rated for 16 V — and the catalogue found a part that satisfies all four, wrote its LCSC number and manufacturer part number into parts.lock.json, and will pick that same part next time. The lockfile is committed, so the build is offline and reproducible.

Where the tolerance is the specification, say so. P0603.tolerance(0.001) asks for a 0.1% part and refuses anything looser, including anything that publishes no tolerance at all. It is a different question from P0603.within(0.001), which narrows which marked values count as 10 kΩ — a 10.0 kΩ ±1% part is marked exactly 10.0 kΩ, so it satisfies a ±0.1% window and is anywhere from 9.5 to 10.5 kΩ. On a difference amplifier that is the difference between 34 dB of common-mode rejection and 54. Expect a stated grade to cost stock: precision parts are stocked in hundreds where their loose siblings are stocked in millions, and a refusal saying so is the answer you want before the board is made.

And you can see what it did not pick. The same build writes out/devboard/parts/charger.md — one Markdown file per sheet — listing every candidate the catalogue offered for each described part, with its tier, its rating, its stock and its price, marking the one that won and naming the term of the ranking that separated it from the runner-up. The ranking is assembly tier, then how near the value asked for, then stock to the order of magnitude, then unit price; docs/decisions.md D32 explains why in that order, and the defect that shape was chosen to fix.

And what the board comes to. out/<board>/parts/cost.md adds those unit prices up — one line per order code, quantity, extended price — and then does the part that makes the number worth having: it says what it could not price. Every part named in the design rather than described to the catalogue is a symbol and a land pattern with no order code, so the total leaves it out, the total says out loud that it is partial, and each missing part is listed by designator. A part with no price is never counted as costing nothing. The figure is at quantity one, because one price per part is all the catalogue publishes.

The page does that twice, because it carries a second check with the same failure mode. Its fits column compares the package the supplier published against the land pattern the design drew, and it exists only where somebody confirmed an order code — so it is silent about most of a board, and a silence reads exactly like a pass. cost.md therefore states how many of the named parts it compared and how many it was never asked about, and it names once the land patterns that carry no package family at all: a pin header, a terminal block, a module. Those stay a ? however the lookup goes, and knowing it before running one is the point.

And what p2 actually is. out/<board>/pads.md names, for every pin of every part, the pad it becomes: the field you write, the pad number, where that pad sits on the land pattern and how big it is. r.p2 is a struct field and the compiler checks that it exists — it has no idea what the copper under it does, and on a four-terminal Kelvin shunt pads 1 and 2 are the two ends of one end cap rather than the two ends of the resistor. A board that wired the pack return through one of those put 100 A into a 1.03 mm² sense pad with every other check green.

So the build also refuses that: tell a net class what its nets carry, with Class::carries or by deriving the whole class from Carries, and a declared current landing on a pad at most half the median pad of its own part stops the build. It is a comparison of a part against itself, because there is no published way to turn a pad's area into amps and none is invented here; docs/decisions.md has the measurement behind the ratio and the list of what it cannot catch.

And how many pads are on each net. out/<board>/nets.md is one row per net, fewest pads first, each with the parts on it and whether anything on it can source it. It is a list to read rather than a check that fired, and the reason is that the defect it is for cannot be refused: a node that should have three connections and has two. Independent expert reviews of generated boards ended on that sentence, and on one board it was both of the serious defects — a pair of bootstrap capacitors with no diodes to charge them, so the converter never switched at any input voltage, and a jumper whose selectable resistors never reached ground, so every setting gave the same output voltage. Both boards passed every check there is, because a two-pad net is also what a series pair, a decoupling capacitor and a pull-up look like. The counts are KiCad's, out of the netlist it exported from the finished drawing, so a pad on no net at all is a row of one at the very top of the file.

One shape out of that file is refused rather than listed: a net your design named that has one pad on it. A name is a claim that some pins are one node, so a single pin under it is a name that was meant to match another name, or a second thing wiring a pin something had already wired — and neither needs a datasheet to settle. A dogfooding round shipped both on one board and every check passed. A pin nobody is using looks different, because KiCad puts it on no net and names the row itself, and Sheet::unused is how you say you meant that.

And you can close that gap, one part at a time. Say what to buy a part as, and the build knows everything the supplier does about it:

let u = s.next_ordered::<sym::Stm32g431kb>("STM32G431KB", LQFP32, "C529357");

The symbol still names it, the same way next_part does, and u.reference is the designator it chose. s.ordered::<sym::Stm32g431kb>("U301", ..) is the same call with the designator typed, for the times you want to choose it.

C529357 is confirmed once, by a person, with ka parts lookup esc.lock.json STM32G431KB — which prints every part the supplier returns, with its package, its stock and its price, and then asks. It never picks: that name matches four parts across two packages, and only one of them fits this land pattern. With the answer in the lockfile every build afterwards resolves it offline, prices it into the estimate, prints the quantity breaks the supplier published, and compares the package the supplier ships against the footprint you drew — an SOT-23-5 part on an SOT-23-6 land pattern is a caution, not a discovery made when the boards come back. The stock figure carries the day it was captured, and is never shown as current. With no terminal — CI — the command prints what it found and stops rather than waiting for an answer nobody is there to give.

Easiest of all: let the build ask you. Run it at a terminal, with a network, with fetch on your ka dependency:

env -u NIX_BUILD_TOP cargo run --features fetch -- servo_driver

and before it draws anything it searches for every part nobody has chosen an order code for, lists what came back, and writes down what you pick:

7 parts on this board have no order code yet.

[2/7] F101   Device:Polyfuse marked "MF-MSMF160/16X" on Fuse_1812_4532Metric
      nothing came back for "MF-MSMF160/16X". A board's own marking is often
      not a string the supplier indexes -- try the manufacturer's family.
      Search for [a term, or nothing to leave it]: MSMF160
       1. C89649   MF-MSMF160-2   1812   extended   16 in stock  $0.0297 each  fits
      Which one? [1-1, another search term, or nothing to leave it]: 1
      recorded C89649 (MF-MSMF160-2).

The candidates that fit the land pattern you drew come first, and the one you pick is priced into that same build. An empty answer leaves the part for later. Nothing is asked about a capacitor or a resistor: those the catalogue chooses on its own.

None of this is ever on a hermetic build's path. NIX_BUILD_TOP is set inside every Nix builder and inside nix develop — which is why the line above unsets it — so a sandboxed build resolves from the committed lockfile, opens no socket, and never stops to ask. Nor does one with no terminal, which is CI, and KA_NO_PROMPT=1 turns it off for anybody who wants a build that just finishes.

Or record it from outside the design. A fuse is drawn as a symbol, a marking and a land pattern — s.part::<sym::Polyfuse>("F1", "500mA", FUSE1206) — and there is nowhere natural in that to put an order code. So tell the lookup which part in the design the answer is for. This is also how you change an answer already recorded: it replaces rather than adding a second.

env -u NIX_BUILD_TOP ka parts lookup servo.lock.json JK-nSMD050 \
    --symbol Device:Polyfuse --value "500mA" --footprint Fuse:Fuse_1206_3216Metric

It searches, shows what came back — six polyfuses there, all 1206, all 500 mA hold and 1 A trip, differing only in the voltage they interrupt at — asks, and records the confirmed code against the way the design draws that part. Every build afterwards puts it on the fuse offline, prices it, gives it a line in the bill of materials and checks the supplier's package against your land pattern. Every placement drawn the same way is answered at once, so a board with six of them needed this once; a 2 A fuse, or the same fuse in 0805, is a different part and a different question.

A build that cannot resolve one says so and prints that command in full, once per part rather than once per placement, naming every designator it covers. It is a warning by default, because a design is written before its parts are chosen and refusing to draw a schematic until every connector is decided would refuse the drawing you need in order to decide them. Project::require_order_codes() makes it an error, for a board that has finished choosing and must not go back.

An order code written in the design wins. Sheet::ordered for a part you place yourself; .ordered("C123456") on the Controller you hand a blueprint for one it places, which is where a polyfuse's goes because Fuse is what places it:

Fuse::new(amps(1.0)).fuse(
    Controller::new(sym::Polyfuse::LIB_ID)
        .value("MF-MSMF160/16X")
        .footprint(FUSE1812)
        .ordered("C89649"),
)

Either says this placement, so it beats a recorded choice, which is a default for a part the design left open. Where the two name different codes the build prints both and says which will be bought.

The term is a search, not the marking. The servo driver's polyfuse is drawn as MF-MSMF160/16X; that string returns nothing and MSMF160 returns the part, which is why it is an argument of its own. And a fuse really is only searched by text here — a PPTC's hold current, trip current, voltage and time to trip arrive unlabelled inside one description string, and nothing pretends to read them. What the search does is narrow it to the handful a person picks between; docs/decisions.md has what the catalogue publishes parametrically and what using it would take.

A search that cannot find your part says so. The supplier matches loosely and hands back at most a hundred rows sorted by stock, so a term it only half recognises fills a page with parts that share one word — which used to be printed in the same table as a real hit, with nothing marking the difference. Every row is now checked against the term: a part number is matched as a run of characters, so STM32G431KB finds STM32G431KBT6, and a description is matched as whole words. Rows that answer are the list you pick from, and the rest are counted rather than shown.

Where no row answers, nothing is offered. JST PH 2.0mm 3 pin connector returns three JST housings and not one of them is that part — one is a 2-pin at 2 mm and two are 3-pin at 2.5 mm — so instead of a plausible row at the wrong pitch you get "No part answered", the words that were compared, and each close row with the words it does not say:

  # │ order   │ manufacturer part   │ package    │ ... │ not said
  1 │ C265438 │ B2B-PH-K-K(LF)(SN)  │ 插件,P=2mm   │ ... │ jst, 3
  2 │ C161637 │ B03B-XANK-1(LF)(SN) │ 插件,P=2.5mm │ ... │ jst, ph, 2mm

The command fails, so a script notices as well as a person. Drop the words nothing said, search the manufacturer's part number instead, or — where you can see a row is right and the words could not — name it: --choose C265438 reaches any row the supplier returned, and says which words that row does not carry before it records it.

And you stop hunting for datasheets. The lookup records where the supplier says the part's document is, so the cost report links it; then

env -u NIX_BUILD_TOP ka datasheets vendor esc.lock.json

fetches every one of them into datasheets/ and writes the URL, the byte count and a sha256 into the lockfile. After that ka datasheets show esc.lock.json C529357 answers without a network — and it says which copy answered: the project's own, this machine's shared ~/src/datasheets/, or neither. It never keeps a file it did not prove is a PDF, because a URL ending in .pdf that answers 200 is often a viewer page, and a part whose document could not be fetched is named rather than quietly skipped. One document often covers a whole resistor series, so it is stored once and every part that shares it points at the same file. ka datasheets check re-hashes the lot offline and fails when one has moved under you — two revisions of a datasheet disagree, and a number taken from one is not a number in the other.

That says the files did not change and nothing about whether they were ever the right files, so ka datasheets verify esc.lock.json reads the words off the pages and looks for each part number among them. It refuses one thing: a document that spells one part's number out in full and does not carry the beginning of another part filed under the same file, which is what a supplier serving one document at two URLs produces. It does not refuse a datasheet that covers a family, and it names every document it could not read — a scan has no text in it at all, and an encrypted one has text this cannot reach. "Clean" about a document nobody could read is the answer that started this.

u.v_dd is a struct field, generated from KiCad's own symbol library, not the string "VDD". Misspell it and the crate does not compile. Tie it to two nets and the crate does not compile either, because tie consumes the pin. Forget to tie it at all and s.finish() fails, naming the pad, the pin's name in the datasheet, and the field you have to go and type.

Nothing says where anything goes. The positions, the rotations, the ground symbols, the junction dots and the two labels are the layout engine's, and the sheet was then handed back to KiCad, which exported its netlist so that the nets KiCad sees could be compared with the nets that were declared.

What is checked, and by what

mistake caught by
one pin tied to two nets the compiler — a Pin is not Copy, so tie moves it (E0382)
a pin name that does not exist on that part the compiler — pins are struct fields (E0609)
a resistance where a capacitance goes the compiler — uom quantities (E0277)
a pin nobody connected the run — Sheet::finish fails with the whole list, one line each
a footprint that is not in the libraries the run — and it suggests the near match, because C_0402_1005metric is a typo, not a missing part
a footprint that exists and is the wrong variant the run — the symbol's pins are compared with the land pattern's pads, so a five-pin part on a three-pad footprint is refused and the variants that do fit are named. The other direction — pads the symbol does not name, a thermal pad or a shield tab — is a note and not a failure; docs/decisions.md D30 has the measurement behind that
a net you drew that KiCad does not agree exists KiCad — every sheet's netlist is exported with kicad-cli and compared against what was declared
ink drawn on top of ink KiCad — every sheet is rendered to SVG, in which every glyph is a stroked path, and the marks are measured
a drawing that runs off the page the run — every sheet is measured against the page it declares, and one that does not fit is an error, because what falls past the paper's edge is missing from every SVG and PDF KiCad exports and from nothing else. See the page a design is drawn on
two regulator outputs wired together, a floating enable, a supply pad nobody drives KiCad — its own rules check, run once over the assembled design. None of these is visible on any one sheet: the pads only meet after the hierarchy is put together
a net name that was meant to match another one and does not the run — a net your design named carrying one pad is refused, out of the netlist of the assembled design, so a net joined on another sheet is not one. Two things write it: a misspelled name, and a Strap on a pin a blueprint had already tied. A pad meant to stay open is a different shape and is said with Sheet::unused

Every one of those failures exits non-zero, and you do not have to remember to ask. Project::build prints its report and then refuses:

fn main() -> Result<(), BuildError> {
    ka::block_on(Project::new("my_board", "out").build(&[power]))?;
    Ok(())
}

The ? is the whole safety net. Reaching the Ok(()) under it means KiCad has seen the board and had nothing to say about it; anything else leaves by the error path with every finding on it. This has not always been true — the findings used to arrive in a value a program had to go and read, and a board whose whole-design ERC came back red exited 0 for anyone following this file. examples/tests/erc_exit_code.rs is two regulators wired to one rail, built by a main written the old way, asserting the process exits 1.

If you want the findings as data — to write them somewhere, or to build several boards and report on all of them rather than stopping at the first — match the error and ask it:

match ka::block_on(project.build(SHEETS)) {
    Ok(outcome) => println!("{} parts", outcome.bom.len()),
    Err(e) => match e.outcome() {
        // The board was written and checked, and it came back red.
        Some(outcome) => for group in outcome.error_groups() { print!("{group}") },
        // The build never got as far as a board.
        None => eprintln!("{e}"),
    },
}

None of that is a claim. examples/tests/ui/ holds programs that must not compile, each beside the exact message it must produce; examples/tests/errors.rs holds the run-time half, including the mistakes that are deliberately not compile errors and why. Across the example boards the audit reports no overlapping ink on any sheet, and every net verified against KiCad.

The sheet and crossing figures are not written here. EXPECTED in examples/tests/generated_sheets.rs carries a row per sheet and the audit runs against it, so a board added or a wire that starts crossing another one fails a test rather than leaving a number in this file that nothing checks. Run it yourself, once the boards are built, with

cargo run -p ka-verify --example audit -- out/*/gen/*.kicad_sch

And the first command you will run yourself does not come back red.

kicad-cli sch erc -o erc.rpt out/rp2350/rp2350.kicad_sch

Errors 0, on every board, with nothing filtered — no --severity-error, so what comes back holds KiCad's warnings as well as its errors, and examples/tests/generated_sheets.rs::every_board_is_clean_under_kicads_own_erc asserts that error count off KiCad's own summary line.

The warnings are asserted too, and they are not all zero. A board reports nothing at all unless it has a rail carrying a supply pad and a pin KiCad's default map grades against Power output. Each of those is one [pin_to_pin] between the pin and a PWR_FLAG this build placed, and the paragraph below says why it cannot be taken away. a_persons_own_erc_run_reports_only_the_flag_pairings holds every other category at zero and freezes the pairing count per board, so a warning of any other kind fails a test rather than arriving in your report.

That test runs KiCad the way your KiCad is set up, and the difference matters. KiCad compares every symbol a sheet carries against the library it came from, and it can only do that when the library is in its configuration. The build's own ERC pass runs with an empty $HOME so that what it reports is a property of the file — which means it has no library table, and KiCad skips that comparison. Your run has one. So the test gives a copy of each board the two library tables the flake pins and reads the whole report.

That is not the same statement as the table above. The build's own ERC pass drops findings by name and prints every line it drops: a pin_not_connected on a pad the design declared open with unused(), a power_pin_not_driven on a rail with no supply pin on it, and a pin_to_pin between a PWR_FLAG this build placed and a pin KiCad's pin map grades against Power output (D128). Anything else KiCad reports fails the build whatever its severity, warnings included. The first two do not exist when you run KiCad's ERC, which is the first thing anybody does after opening a new schematic. So those decisions are written into the drawing rather than only into the build log: a no_connect marker on every pad the design declared open, and a PWR_FLAG on every rail the design said is fed by something a schematic has no source pin for — placed on the root sheet, one per rail, and taken away again if the design later grows a regulator that drives it. A pad that KiCad has quietly wired by stacking it on another pad of the same name gets no marker, because an X there would say the net stops at a pin that is connected.

The flag rests on that sentence and not on the absence of a source, because the two are indistinguishable: a rail arriving from a connector and a rail nobody wired both have supply pads on them and nothing driving them, and a flag placed on the second silences power_pin_not_driven, which is the only check that catches it. So a rail whose source is off the drawing says so, on the sheet holding the source, and a rail nobody said it about stops the build with the pads that would never power up.

The third does survive your own run, as a warning, and that is KiCad rather than a slip. A PWR_FLAG's pin is Power output, which KiCad's default pin map grades against a Bidirectional one — so a sensor whose address-select pin is strapped to GND to pick its I²C address reports [pin_to_pin] between that pin and the flag. A rail carrying a supply pad and one of those pin types cannot be made silent under the default settings: without a flag it is power_pin_not_driven, with one it is pin_to_pin. What is left in your report is therefore a statement about a symbol this build invented rather than about the circuit, and it is left there to read rather than filtered out of the command this page hands you.

s.net("VM").driven_by("J101 pin 1, the XT60 pack's positive terminal");
s.net("+12V").driven_by("the LMR36510, through its output inductor");

Ground is not asked — it is the reference the other rails are measured against — and neither is a rail with no supply pad on it, which is a rail this design only supplies: nothing on it is waiting to be powered.

Those same rails are the ones nothing on the board can work out the voltage of, so that is said in the same place:

s.net("VIN")
    .driven_by("J101 pin 1, the XT60 pack's positive terminal")
    .rail_voltage(rail_range(volts(19.8), volts(27.7)));

Two numbers, because the ends answer opposite questions: a capacitor's rating and a pin's absolute maximum are checked against the top, while dropout, gate drive and a converter's duty cycle are decided at the bottom. A pack is 9.0–12.6 V and neither end of that is 12 V. rail_at(volts(3.3)) is the same thing said about a regulated output, whose ends are equal.

A regulator declares its own output as it builds, so this is for the rails that enter the board. What reads it: every capacitor's voltage rating, every pin compared against its absolute maximum, and the clearance — say which column of IPC-2221A Table 6-1 your gaps belong in, with Project::coating(..), and a net class whose clearance is under what the table asks at that voltage stops the build. A rail nobody declares is compared against nothing, and every build prints a clearance line naming which those were.

Overlaps fail the audit. Crossings are a quality number, not a failure, because a wire crossing another wire is legal and sometimes unavoidable: wireless/sensors crosses where the halves of an I²C bus have to swap over, one part having SCK above SDI and the other SDA above SCL, and a person drawing it would cross them too. Overlaps inside the Sensor_Motion:MPU-6000 symbol are reported separately and not counted at all — it draws pin names longer than its own body rectangle, and no placement can fix that.

The page a design is drawn on

A project is drawn on A4 landscape unless it says otherwise:

// 420 × 297, for a board with more on a sheet than A4 holds.
Project::new("my_board", "out").paper(Paper::A3)

// 297 × 420, the same sheet stood on end, for a drawing that is tall
// rather than wide.
Project::new("my_board", "out").paper(Paper::A3.portrait())

Every page KiCad offers is there — A5 through A0, ANSI A through E, US Letter, Legal and Ledger — and the choice decides two things: the frame the layout wraps inside, and the largest page any sheet of this board comes out on.

Each sheet then takes the smallest page that holds it. A board declares one page because one sheet needs it, and most of its sheets draw far less than that. Eight resistors on A2 are a stamp in the corner of whatever a reviewer opens the sheet in — they cover 1.6% of that page, against 13.2% of the A5 the same drawing fits. So once a sheet is drawn it is written on the smallest page of the same series that holds every millimetre of it, and KiCad plots each sheet of a hierarchy at its own page size. Over the twelve boards here, 48 of the 64 sheets come off the page their board declares.

Nothing on a sheet moves because of this: the drawing is already placed when its page is chosen, so the netlist, the wires and the labels are what they would have been. The page is only ever made smaller, never bigger and never stood on end, so a sheet that does not fit the page the design declared is still refused — with the message below.

That last one is why this is not a preference. A schematic drawn past the edge of its page is legal, opens in KiCad, passes ERC and exports a complete netlist — and kicad-cli sch export svg writes a document exactly one page across and clips the rest without a word. The PDF and the printer do the same. The parts are in the file and on no page of the drawing, so a reviewer reading that PDF sees a buck converter with no feedback divider and no reason to suspect one is missing.

So a sheet that does not fit stops the build, and the message says which sheet, which way, by how much, and which page would hold it:

  ✗ sheet `mcu` draws to 274.3 mm down the page, and A4 has 200.0 mm of
    drawable frame (210.0 mm of paper). The last 64.3 mm is in the
    `.kicad_sch` and in nothing KiCad exports from it: the SVG and the PDF are
    exactly one page and clip the rest without a word, so whoever reviews the
    export is reviewing an incomplete circuit. Give the design a bigger page --
    `Project::paper(Paper::A3)` holds the whole drawing -- or split the sheet.

What is measured is the drawing and not the placement points: a part's pins, its reference, its value, the rail symbols and labels on it, the dashed frame round the circuit and the sheet's name above it. docs/decisions.md has the measurements from this repository's own boards, three of which were overflowing A4 before the check existed.

Net classes are declared, not assumed

A net class is where the track width, the clearance and the via sizes live, and KiCad keeps them in the .kicad_pro rather than the board — which is what lets a generator write them before a board exists. They are opt-in. A design that does not ask for any gets a .kicad_pro with no net_settings in it, and KiCad then puts every net on Default: 0.25 mm of track and 0.2 mm of clearance, whatever the net carries.

That is a reasonable answer for a board you are going to route by hand anyway and a bad surprise on a 2 A rail, so the build says which one happened, every time, in the same place it says everything else it decided. A project that declares none is told so:

  · net classes  none declared, so myboard.kicad_pro carries
                 KiCad's own defaults: 0.25 mm of track and 0.2 mm of
                 clearance on every net, whatever it carries.
                 `Project::classes(vec![..])` is where they go. …

and one that declares some is told which:

  · net classes  2 declared: Battery (VBAT, GND), Output (+5V)

To declare one, say what the net does and who is making the board. Carries::class derives the width, the gap and the via together:

use ka::netclass::{Carries, Fab};

// One shop's published process, copied off their capability page with the
// date you read it. There is no default: a process is not physics.
let fab = Fab {
    min_track: millimetres(0.15),
    min_clearance: millimetres(0.15),
    min_drill: millimetres(0.3),
    annular_ring: millimetres(0.13),
    plating: PlatingClass::Two,
    thickness: millimetres(1.6),
};

// 1.5 A, 10 °C of rise, 1 oz copper, an outer layer, 5.5 V across the gap.
let power = Carries {
    current: amps(1.5),
    v_peak: volts(5.5),
    rise: celsius_delta(10.0),
    copper_oz: 1.0,
    layer: Layer::Outer,
    coating: Coating::BareExternal,
}
.class("Power", &["+3V3", "+5V"], fab)?;

Project::new("sensor_node", "board")
    .fab(fab)
    .classes(power)

Each number is the larger of the physics and the factory, and the build says which one decided. examples/src/devboard.rs works this way, and the reasoning travels with the numbers: Bound::Temperature means the circuit decided and the knob is electrical, Bound::Process means the shop decided and the electrical requirement had room to spare. A class whose pattern matches no net in the design fails the build, because KiCad's own answer to one is to leave the net on Default and say nothing.

The physics alone has no floor under it

ka::calc::board::trace_width answers one question — how much copper does this current need at the rise you accept — and stops there. That is the right place for it to stop. A fabricator's process is not physics, and ka-calc has no source to cite for one.

So the answer at a small current is correct and unmakeable. 250 mA at a 10 K rise on 1 oz outer copper is 0.044 mm of copper, which is a third of the 0.15 mm a common process holds. Fed straight into Class::track that number reaches the .kicad_pro, KiCad routes it, and the DRC passes.

Carries::class closes that: it takes the physics width, raises it to Fab::min_track, and records which of the two decided. And Project::fab(..) holds every number a class states — width, gap, via drill and a differential pair — against the same shop, so a width somebody typed is checked too. A board that never said who is making it is not checked, and the build's process line names the classes it therefore passed over:

  · process            not checked
        This board has not said who is making it, so the width on Power was not
        held against a process. ka_calc::board::trace_width answers how much
        copper a current needs and has no floor under it: 250 mA at a 10 K rise
        comes out at 0.044 mm. That answer is right, and nobody etches it.
        Project::fab(..) is where the shop's capability page goes.

Call trace_width directly for the physics question: how much copper does this current need, what does a fault rise buy, how do two rises compare. examples/src/servo_driver.rs uses it that way.

A width nobody could work out is written as no width

IPC-2221's chart stops at 35 A and at 400 mil, and trace_width refuses past either rather than extrapolating. That refusal must not turn into a number anyway. A 50 A pack rail is a pour, a plane or a busbar, and there is no honest track width to write — but leaving track_width out is not how you say so either, because KiCad then falls the net back to Default and 0.2 mm of copper routes and passes DRC exactly like a width somebody meant.

So a class can say it outright, and Carries::class says it for you when the chart runs out:

class("Pack", &["VBAT"])
    .not_a_track("50 A is past IPC-2221A Figure 6-4, which stops at 35 A")
    .clearance(millimetres(0.5))
    .build()

The .kicad_pro then carries no width for that class, and <board>.kicad_dru carries (constraint disallow track) on it with the reason written above the rule — so a layout engineer who routes the net gets Items not allowed (rule 'Pack is not a track') from KiCad's own DRC instead of a board that passes and burns. Zones and vias stay legal; the pour and its stitching are the point. Declaring both a width and not_a_track stops the build, because the file can only say one of them and the width is the half a router follows.

A pour is not the same statement as "not a track"

not_a_track refuses every track on the net. That is right for a pack rail that runs connector to converter and nowhere else, and wrong for a return plane: GND is the pack's current and the microcontroller's VSS at milliamps, and the stubs to the signal grounds are ordinary tracks. An ESC had to leave GND out of its class list entirely rather than take forty refusals for copper that was right — which put the return carrying 19.6 A on KiCad's Default, the silence net classes exist to break.

So say the other thing:

class("Ground", &["GND"])
    .carries(amps(19.6))
    // The stubs' width -- a gate driver's supply return at 1 A -- and why.
    .pour(stubs, "the return is poured copper on both layers, stitched with \
           vias. The stubs off it are ordinary tracks: every decoupling \
           capacitor's other end, every gate driver's reference.")

The width is the stubs' — the busbar is a zone and has no width, and the tracks on the net are the stubs — and no rule is written. KiCad cannot be told which tracks on a net are stubs — a rule condition sees a track's net, netclass, layer, type and width, and no length — so an unscoped disallow track is the only rule available and on this net it is false.

Nothing enforced is given up. A net class's track width was never a floor: KiCad writes it as the router's preferred width and takes the DRC minimum from board setup, so a stub necked down below its class was always DRC-clean. The reason is required and the build prints every pour on every run, with the current declared and the pads it lands on.

Declaring a class is a decision per board, not a property of the tool. Nothing reaches the .kicad_pro unless a board asks for it, and a net no class matches stays on KiCad's Default — which is the silence, and saying nothing is a choice a board is allowed to make.

A class can be declared on the net instead

A class in Project::classes is a pattern against net names, in one block at the top of the board. That is right for a rail every sheet uses and wrong for a class used once: the rule ends up a long way from the circuit it is about, and the pattern has to predict what KiCad will call the net — /analog/+3V3 for a rail kept to a sheet, /AIN_P for one leaving through a port. Boards here carry paragraphs of comments about exactly that, written after a rename moved a net out from under a pattern.

So say it where the connection is made:

s.net("SENSE_MID")
    .in_class(declared_here("Kelvin").clearance(millimetres(0.3)))
    .tie((top.p2, bottom.p1, filter.p1));

declared_here takes no patterns, and that is the point. The build asks the circuit what KiCad will call this net and writes those names into the class, so renaming it, making it local() or exporting it as a port cannot leave the class pointing at a name that is gone. Everything a project-level class can say it can say — a derived track width, via geometry, a differential pair, carries, not_a_track, pour.

The same name on two sheets is one class covering both nets, which is what a sheet drawn in several places needs, as long as the two declarations agree on every number; a disagreement is refused and names both. A net a project-level pattern already reaches is refused too: KiCad puts a net in exactly one class, so the two are contradictory rather than layered, and neither is quietly taken as overriding the other.

A clearance the package cannot give

A class is a rule about copper nobody has drawn yet; a land pattern is copper that already exists, and they can contradict each other. An LQFP-48_7x7mm_P0.5mm puts 0.3 mm pads on a 0.5 mm pitch, so 0.200 mm of bare board separates each pad from the next. Put GND in a class at 0.25 mm, land it on that package, and the pads are already closer together than the rule — with the schematic clean, ERC passing, every pad present and the .kicad_pro saying 0.25 mm.

The pin this bites is not the one in the class. Clearance applies between items of different nets, and where those two nets are in different classes KiCad resolves the larger of the two, so a signal pin in no class at all is held to GND's the moment it sits beside a VSS pad.

  ! a net class asks for more clearance than the land pattern leaves between
    the pads its nets arrive on:

      U601 on Package_QFP:LQFP-48_7x7mm_P0.5mm: pad 46 ('PWR_GOOD') and pad 47
      ('GND') are 0.200 mm apart, and class 'Rails' asks for 0.25 mm around
      'GND'. Short of it on pads 45, 46, 47, 48.

It is a caution, not a refusal: every file the build wrote is correct, and which half to change is a decision only you can make. Narrow the class — below about 30 V a clearance is the fabricator's number rather than the voltage's — or keep it and scope a smaller one to that part with a KiCad custom rule. Where the package simply cannot give it — a USB-C receptacle's contacts are where the standard puts them, and a 0.4 mm-pitch QFN is a 0.4 mm-pitch QFN — say so and say why:

.escapes_at(&[(
    "U301",
    millimetres(0.2),
    "a 0.4 mm-pitch QFN-60 leaves 0.200 mm between neighbouring pads, and the \
     rails land on it because that is where the part's supply pins are",
)])

The land pattern is then measured against that number rather than the class's, so a gap the package cannot give is still reported — and the build prints every exception with its reason and the gap it measured, because an exception nobody can read is a waiver. An exception makes the build say something different, never less. 04_flight_controller has two.

And whether a trace can get off the pad

Two pads far enough apart is not the same question as a trace getting out from between them. Clearance comes out of the lane twice — once to the pad on each side — before any copper is drawn, and what is left is the widest track that can reach the pin.

A QFN-60-1EP_7x7mm_P0.4mm puts 0.2 mm pads on a 0.4 mm pitch, so a pin's two neighbours leave a 0.600 mm lane. At 0.2 mm of clearance a side that lane holds 0.200 mm of track — and an unclassed net is routed at KiCad's Default 0.25 mm, which every project file this build writes carries. The pad-to-pad gap is 0.200 mm against 0.2 mm demanded and passes; the trace does not fit.

  ! U302.vreg_pgnd on Package_DFN_QFN:QFN-60-1EP_7x7mm_P0.4mm_EP3.4x3.4mm:
    pad 47 sits between pad 48 ('VREG_LX') and pad 46 ('+3V3'), which leave
    0.600 mm of lane between them. Clearance takes 0.200 mm off one side of it
    and 0.200 mm off the other, so the widest track that reaches this pin is
    0.200 mm -- and 'GND' is routed at 0.25 mm.

That is a caution: a thinner track does fit, and which of your two numbers gives — the width or the clearance — is yours to choose. When the clearance owed on the two sides is the lane or more, no width reaches the pin at all, and that is a build failure, because there is no choice left to offer you.

Only a pad with a pad either side of it is measured. A two-pad passive's copper has open board beside it and a wide track simply spreads past the pad, which is ordinary and is not reported.

It is not a claim that the board routes. That needs a layer count, a via strategy, a placement and a router. What is claimed is one subtraction over the two pad outlines KiCad's own land pattern drew — outlines rather than bounding boxes, because a circle's box reaches past its copper and two round pads measured box to box read as shorted when they are not.

Two rails with one name

A net whose name looks like a supply — +3V3, +5V, VBUS, GND — is drawn as a power symbol: a small tag on each pin that wants it, and no wire between them. That is how schematics are drawn, and it is also why every +3V3 in a design is one net. Nothing is drawn between two sheets and nothing has to be; matching names is the whole mechanism.

Right for GND. Wrong the moment you want a quiet analogue 3.3 V beside the digital one, because both want to be called +3V3 — and if both are, the two regulators are wired together in parallel, which is not how a load is shared. Say which one is yours:

let rail = s.net("+3V3");
rail.local();                       // this 3V3 belongs to this sheet
rail.tie((u.vout, c_out.p1));

Two sheets that each say that get two rails, and KiCad's netlist calls them /analog/+3V3 and /digital/+3V3. Two things follow.

A local rail does not leave its sheet at all. It is made here and used here, which is the ordinary case for a locally regulated supply. A rail that should reach some other sheets and not others needs hierarchical sheet pins, which are not built — so saying both local() and export() is refused rather than half-honoured.

Say it on every sheet that uses the name. A rail that is local on one sheet and left alone on the next is two nets wearing one name, in two files KiCad is perfectly happy with, and the build refuses it by name:

'+3V3' is kept to itself on analog but is global on digital.

A net class still reaches a local rail, but the pattern has to allow for the sheet in front of the name: *+3V3, not +3V3. A pattern that matches nothing fails the build, and the message lists the names that do exist.

Where the screws go

A board is bolted to something, and that belongs on the schematic like any other part — so the fabricator drills it, the assembler knows not to try to place it, and the enclosure and the board agree about how many holes there are. KiCad's own way of saying it is a mounting-hole symbol; this is that, with the one decision it involves made out loud:

s.mounting_hole("H801", Screw::M3, Bond::To("GND"));   // the chassis reference
s.next_mounting_hole(Screw::M3, Bond::Isolated);       // H802, and so on

Bond has no default. A screw through a plated, grounded hole into a metal chassis makes the chassis part of your ground: sometimes exactly right — a shield to bond, a discharge that needs somewhere to go — and sometimes four parallel return paths through metal you do not control. Both wire up cleanly, both pass ERC, and the difference shows up as noise on somebody else's bench. So it is an argument rather than a setting, and it picks both halves: the symbol with a pin on the plated footprint, or the pinless one on the unplated hole. Pairing those the wrong way round is the mistake this removes — a plated pad under a symbol with no pin is a pad on no net, and KiCad reports it only as a warning while the board is being updated.

Screw is M2 to M5 and the drill is KiCad's clearance for the thread — 3.2 mm for an M3, not 3.0 mm, which the screw does not go through.

Where the holes are is not said here, because nothing here writes a .kicad_pcb: you place them in the PCB editor with everything else. See docs/decisions.md D54 for that boundary and what moving it would cost.

What this does not do

It does not lay out boards. What you get is the .kicad_sch files and a .kicad_pro — carrying net classes if the design declared any, and track widths derived from the current and IPC-2221 rather than typed into a dialog if it derived them; see net classes, which are opt-in and which the build says out loud either way. Then you open the PCB editor and place things yourself. There is no autorouter here and there is not going to be one, because the entire argument for design blocks is that your layout is better than what a machine would produce.

What it does instead is stop you laying the same thing out twice. A design block is a piece of a board you have already solved, kept as KiCad's own .kicad_block with a key that says which circuits its copper is still good for. See below.

It will not choose your regulator. A blueprint names its controller and sizes everything around it. Which switching regulator to use is a judgement about topology, package, availability and what you already have in a drawer, and a tool that guesses at it is a tool you cannot trust. Once you have named the part, the inductor, the capacitors, the feedback divider and the compensation follow from its datasheet, and that is arithmetic nobody should be doing by hand. The same line runs through the parts catalogue: resistors and capacitors resolve automatically because their parameters are published honestly; inductors and ICs do not.

It does not invent numbers. Where there is no validated formula — an LDO's stability capacitor, a regulator's bootstrap — a blueprint asks you for the datasheet's value and records that the number came from you. A value that looks derived and is not is worse than no value at all.

It does not simulate anything, and it is not a component database. It knows about resistors and capacitors, and about the symbols and footprints KiCad ships. For a part KiCad has never heard of, ka import drafts one from the supplier — see below — and the draft is for a person to check.

Getting going

Everything the generator needs — the exact KiCad it writes files for, that KiCad's symbol and footprint libraries, kicad-cli, rsvg-convert, the Rust toolchain and the LSPs — comes from flake.nix. Nothing is found by searching /nix/store and there are no fallbacks, so being outside the shell fails immediately with a message telling you to enter it, rather than resolving to a path that happens to exist on one machine.

direnv allow            # or, by hand: nix develop

.envrc is a one-line use flake, so direnv allow and nix develop do the same thing. The shell prints the KiCad and rustc versions it pinned.

Entering it also installs a pre-commit hook, and it is worth knowing what it does before it does it. Cargo.nix is generated from the manifests and Cargo.lock, and it is committed, because the Nix build compiles one crate per derivation out of it (D25). The hook regenerates it on any commit that touches a Cargo.toml or the lockfile, and refuses the commit if the result is not what was staged — telling you to git add Cargo.nix and commit again. It also formats what you staged, which is the paragraph below. On every other commit it does nothing and costs nothing, and it leaves a pre-commit hook you already had exactly where it is. nix flake check runs the same two comparisons in a sandbox, so nothing is lost by deleting it.

nix fmt                 # or, on one file: treefmt path/to/file

Three formatters over the whole tree: nixfmt for the .nix, rustfmt for the .rs, and ka-mdfmt for Markdown tables — in .md files and in /// doc comments alike. The last one is this workspace's own, crates/ka-md, and what it does is pad the cells so the pipes line up down the column:

| what | value |             | what   | value |
|---|---|              ->    |--------|-------|
| ripple | 15 µH |           | ripple | 15 µH |
| rail | 3V3 |               | rail   | 3V3   |

A renderer does not care. A person reading the file, a terminal and a git diff do, and that is where most of this is read — including out/<board>/parts/<sheet>.md, the report that says which part the catalogue picked and what the runner-up would have cost, which the build writes laid out. A column of prose is left ragged rather than padded out to several hundred spaces; the rule and its reasons are the crates/ka-md module documentation, and D43. checks.formatting is the same three formatters in check mode.

cargo run -p ka-examples -- all

Builds every example board into out/ — resolving every part from the committed lockfiles, laying out and writing each board's sheets, and handing every one of them to kicad-cli for an ERC run and a netlist export that is compared with what the Rust declared. Takes about ten seconds once compiled. The first run downloads the crates in Cargo.lock, so it needs the network once; after that it does not.

Then open one:

kicad out/devboard/devboard.kicad_pro

The root sheet is devboard.kicad_sch and the sheets it links to are under gen/; the .kicad_pro beside them is where this board's net classes went, and the build says which it declared.

out/ is not committed. It is generated, and a stale drawing left lying around is worse than no drawing.

cargo test --workspace

Two to three minutes on a warm build. It includes examples/tests/generated_sheets.rs, which builds every board itself and audits every sheet KiCad renders — so it cannot pass by finding nothing to check.

To work on one board rather than all of them:

cargo run -p ka-examples -- 07_devboard

The names are 01_ldo, 02_boost, 03_rp2350, 04_flight_controller, 05_esc, 06_wireless, 07_devboard, 08_servo_driver, 09_sensor_node, 10_crossover, 11_power_or and 12_preamp. checks.readme-boards holds this file to that list: the board names it writes have to be exactly the ones ka-examples builds, so a board added and not written up here fails a check rather than leaving the README describing a corpus that has moved on.

Before committing a change here, the other three that have to be clean:

cargo fmt --all
cargo clippy --workspace --all-targets -- -D warnings
RUSTDOCFLAGS="-D warnings" cargo doc --workspace --no-deps

When KiCad does not have the part

env -u NIX_BUILD_TOP cargo run -p ka-cli -- import C5446 --into lib/

ka import takes an LCSC part number, fetches the supplier's CAD data, and writes a .kicad_sym and a .kicad_mod into lib/. What it writes is a draft, and the useful half of what it prints is the list of things you have to check with the datasheet open: pad numbers against the pinout table, which corner pin 1 is on, and the courtyard it deliberately did not draw. Supplier data is wrong in ways that pass every rules check there is and only show up on an assembled board.

The env -u NIX_BUILD_TOP is not decoration. NIX_BUILD_TOP is set inside every Nix builder and inside nix develop, and this project treats it as "you may not touch the network" — that is what makes a nix build hermetic and what makes a missing part a build failure rather than a slow build. Unsetting it for one command is how you say you meant it. KA_OFFLINE=1 is the same switch from the other direction, for anywhere that is not a Nix shell. Run the import yourself, read the report, commit both files: a build reads them from the repository and never fetches.

When the lockfile does not have the value

A part the catalogue cannot resolve is a different failure: not a symbol KiCad has never heard of, but a value your parts.lock.json does not cover. You get it by doing something ordinary — changing a capacitor from 0603 to 0402 — because the lockfile holds the parts for the values the design asked for last time, and 0402 is a different query.

env -u NIX_BUILD_TOP cargo run -p ka-cli -- parts add ldo.lock.json capacitor 0402 1e-6

The build prints that line for you, filled in, so it is a copy rather than something to work out. It fetches the candidates for that one value, keeps the two dozen a selector would plausibly reach for, records the query so a later backfill re-runs it, and writes the file. Commit it: every build after that one resolves the value from the file and opens no socket, which is what makes your build and everybody else's produce the same board.

1e-6, 1u and 1uF all mean the same capacitor. 4k7 does not work on purpose — write 4.7k.

The other way round, while you are iterating. If you are adding values faster than you want to add them one at a time, put features = ["fetch"] on the design's ka dependency and it resolves new values by itself as it builds:

ka = { path = "...", features = ["fetch"] }

That compiles an HTTP client into the design, which is exactly what the default avoids, so it is a switch you flip while working and flip back — or leave to one developer, since the lockfile it produces is what everybody else builds from either way. ka-examples has the same switch (--features fetch) for the same reason, and its default build still links no client: crates/ka/tests/no_http_in_a_design.rs is what says so.

Starting a board of your own

In a repository of your own, which is what you want if the board is going to be a real one:

nix run .#ka -- block init myboard --ka "git+file://$PWD"
cd myboard && nix develop
cargo run -- regenerate     # draw the copper for the example blocks
git add blocks flake.lock Cargo.lock && exit   # a flake reads the git-tracked
nix develop                                   #   tree, so this is what makes
                                              #   the new blocks visible to Nix
cargo run -- board          # the KiCad project those blocks go on
kicad board/myboard.kicad_pro

ka block init initialises the repository and stages what it wrote, so there is no git init && git add -A on the second line any more. That step existed for Nix's benefit rather than yours — a flake's source is the git-tracked tree, so nix develop in a freshly scaffolded directory used to fail on a flake.nix sitting right in front of you — and it is not a thing anybody should have to know. It stages and does not commit: the first commit is yours to write. Inside an existing repository it declines, because a nested repository is a decision rather than a default, and then it prints the two commands.

ka block init is named after design blocks and writes rather more than a block library: a flake pinning this KiCad, a build.rs that turns symbols into types and every land pattern KiCad ships into constants, blocks with their copper, and a KiCad project that uses them, with its parts lockfile and its design-block-lib-table already beside the .kicad_pro. src/board.rs is the file to edit, and adding a sheet to it is one function. A block library of your own has the rest.

regenerate before board, and the exit/nix develop between them, are not a formality. blocks/ is empty in a fresh project — the copper is the artefact and a person draws it, so there is nothing there to ship — and flake.nix turns blocks/ into the library the devshell exports, from the git-tracked tree. So the shell you entered on line two was built from an empty directory, and until regenerate has written the copper, git add has made it visible to Nix and the shell has been re-entered, cargo run -- board correctly reports that the block library does not resolve. It says which of the two states you are in and which command closes it.

cargo run -- board and not KiCad's File ▸ New Project. That dialog would put the .kicad_pro in a subdirectory of its own, one level below the library table that has to sit beside it. Run the build; open what it wrote.

nix run .#init -- myboard --ka "git+file://$PWD" is the same thing under a shorter name: the flake app forwards to ka block init and adds nothing to it. --ka is the only argument you have to think about — it is the flake reference your project will depend on, and ka block init --help says how to find yours.

Updating that project later takes two commands, not one. A scaffolded project pins ka twice and the two are resolved by different tools:

nix flake update ka                 # the ka on your PATH, and the shell's KiCad
cargo update -p ka -p ka-codegen    # the ka your program links, which writes the sheets
exit && nix develop                 # the shell was built from the old flake.lock

Running only the first gets you a new binary and an old library — a usability trial did that and nearly confirmed a finding about sheets the old one had written. cargo run -- check compares KA_REV against the project's Cargo.lock and says so when they disagree, naming both revisions and the command that closes the gap.

Then, before you draw anything: ka blueprints.

ka blueprints            # all eighteen
ka blueprints usb        # or search -- pullup, charger, ldo, crystal, reset

A blueprint is a subcircuit you parametrise rather than draw, and this is the list of them. It is worth thirty seconds now because the alternative is finding out afterwards. Somebody built their first board with this tool and hand-wired a USB-C receptacle — connector, two 5.1 kΩ pull-downs, five nets, and a good paragraph explaining why the pull-downs matter. UsbCSink builds that circuit, opens with the same paragraph, warns about the two things that bite, and was already imported into their file by the use ka::prelude::* at the top of it. Nothing was broken; there was just no way to find out.

ka symbol <Library:Symbol> now says it too, at the last moment it can: look up a part a blueprint covers and it names the blueprint before it tells you how to wire that part by hand. So does ka find, on every row of a search — that receptacle is one ka find usb c receptacle away, and every one of the five answers is marked blueprint: UsbCSink.

Inside this repository, which is the fastest way to try something without starting a project: copy examples/src/ldo.rs and examples/src/lib.rs. The first is a whole board — one blueprint, no coordinates — and the second is the plumbing every board needs: the generated symbol module, the land patterns, and where the output and the lockfile go.

Using this from a repository that is not this one

Nothing here is published to crates.io, and it will not be until the API stops moving. A project outside this tree reaches it over git, and that works today — there is nothing to add and nothing to wait for:

# Cargo.toml
[dependencies]
ka = { git = "ssh://forgejo@forgejo.rammhold.de/slop/ka.git" }
# flake.nix
inputs.ka.url = "git+ssh://forgejo@forgejo.rammhold.de/slop/ka.git";

git remote get-url origin inside a checkout prints the URL to use; for a local clone, file:///absolute/path/to/kicad-automation on the Cargo side and git+file:///absolute/path/to/kicad-automation on the Nix side both work offline. Cargo records the exact commit in your Cargo.lock and Nix in your flake.lock, and both are committed, so everybody builds the same tree.

Not path: on the Nix side. It copies the directory verbatim rather than asking git what is in it, which means a multi-gigabyte Cargo target/ goes into the store every time you evaluate. git+file:// copies what git tracks.

A branch is spelled twice. Nix puts it in the URL — …/kicad-automation?ref=my-branch — and Cargo puts it in a key beside git: branch = "my-branch". Neither tool reads the other's spelling, and on a file:// URL Cargo's git library looks for a directory whose name ends in ?ref=my-branch and says no such path exists. ka block init --ka "git+file://$PWD?ref=my-branch" writes both forms for you, so this only matters if you edit the manifest by hand.

Two things the flake gives a consumer, and they are the reason this is worth doing rather than pinning KiCad yourself:

ka.lib.<system>.env the whole pinned toolchain as one attribute — KICAD_SYMBOLS, KICAD_FOOTPRINTS, KICAD_CLI, RSVG_CONVERT, KICAD_DEMOS. env = ka.lib.${system}.env // { … } in your devshell and you have the exact KiCad this was tested against
ka.lib.<system>.mergeBlocks your design blocks laid over the ones you get from here, per block rather than per library. Its system argument is the library underneath and has nothing to do with the platform

What not to copy out of this tree. examples/src/lib.rs computes the repository root as CARGO_MANIFEST_DIR/.. and its lockfile helper reaches into python/examples/; neither survives the move. Write those two functions yourself — they are about ten lines — or start from a scaffold that has no such assumptions.

Sheets that are written by code, and never by hand

The sheets under gen/ are output. The design describes them, the blueprints size the parts, and the next build rewrites the file — it does not merge anything you typed into it. Every one of them says so in three places: a line in its title block, which KiCad prints on the drawing and on the PDF; ka.source and ka.digest on the parent's sheet symbol, where the tooling reads them; and the gen/ directory itself.

ka render is that build, from outside the editor:

cargo run -p ka-cli -- render --project <the project directory>

It runs the project's own design program, which it finds in a four-line ka.project.json written once beside the .kicad_pro. (The example boards do not have one: they live under out/, which is regenerated, and a real project's copy is committed.)

{ "version": 1,
  "name": "ldo",
  "render": ["cargo", "run", "-q", "-p", "ka-examples", "--", "01_ldo"],
  "generated": "gen" }

Before it runs anything, it asks whether anybody has edited a generated sheet by hand — and refuses if they have. That is the whole reason the command exists rather than just running the design directly:

gen/u_5v.kicad_sch has been edited since ka wrote it.

    C102     value      22uF -> 47uF

  A synthetic sheet is regenerated from source; ka will not merge these.

  Keep them:    move the change into examples/src/ldo.rs::u_5v and re-run
  Discard them: ka render --force gen/u_5v.kicad_sch

Nothing was run and nothing was written; the sheet on disk is exactly as it was left. --force names one sheet and there is deliberately no flag for all of them. ka render --check does the checking and stops without writing, which is what belongs in a pre-commit hook.

--check asks two questions, not one. Has anybody edited a generated sheet by hand? — free, answered against the .ka-canon beside each sheet. And is each sheet still what the code says? — which needs the code, so it runs your design program into a scratch directory and compares. A project whose design changed an hour ago passes the first question perfectly and fails the second:

1 generated sheet(s) no longer match the code that writes them. Nothing was
rewritten -- close the project and run: ka render --project /home/you/ldo

gen/power.kicad_sch is not what the code describes any more.

    C101     value      10uF -> 47uF

  Nobody edited the sheet -- it is byte for byte what ka last wrote. The design
  changed underneath it, so what you are looking at in KiCad is the circuit as
  it was before.

The second question is why --check needs a devshell: it builds your design. It still writes nothing into your project — the design is redirected with KA_RENDER_INTO, and anything a design too old to know that variable writes is put back.

The check is not a hash of the file, and that matters: KiCad's writer and this one disagree about a file even when they agree about a circuit — a forced format upgrade takes a 1535-line generated sheet to 1959 lines without moving one component — so a byte hash would call every sheet hand-edited from the first save onwards. What is compared is a canonical form: the parts, the wires, the labels, the rails and the junctions, with everything KiCad is entitled to rewrite left out. crates/ka-render/src/canon.rs has the whole argument and crates/ka-render/tests/canonical.rs is the measurement.

Two things it refuses outright: writing while KiCad has the project open, and a generated directory that points outside the project. KiCad does not reload files that change underneath it, so an editor holding your project saves its stale copy over the re-render at the next Ctrl-S. Two things say it is open — a .lck beside a file ka would touch, or a running eeschema/kicad whose command line names one — and either is enough. The second one is there because the first is not a test for no: KiCad writes the lock file only once the schematic is actually open, so a first-run wizard, a slow start or a read-only directory leaves a live editor with nothing on disk to find. crates/ka-cli/src/render.rs (held) and docs/kicad-source-answers.md §5.2 are the argument and the evidence.

ka render --check answers the hand-edit question on its own and does not mind an open editor, because it writes nothing.

What the design decided, as JSON

ka render --explain prints what every blueprint on every sheet chose and why, grouped by the blueprint that chose it. It runs your design the way --check does — into a scratch directory — so it writes nothing into the project and is safe with KiCad open.

cargo run -p ka-cli -- render --project <the project directory> --explain
{ "kind": "power::Boost",
  "instance": "u_boost/boost",
  "site": { "file": "examples/src/boost.rs", "line": 25, "column": 7 },
  "decisions": [
    { "what": "C102", "chose": "10 µF",
      "because": "not the same sum as a buck's: the inductor is disconnected …",
      "bound": { "constraint": "ripple" } } ] }

bound is the field worth knowing about: null means nobody calculated this — the number came from a datasheet or from you — and a name means a constraint decided it and relaxing that constraint is the knob.

For two blueprints so far, each one also carries its parameters: what it was asked, in what unit, and the file, line and column where each value is written in your Rust. Those spans quote the source text they point at, so anything generating an edit can check the line still says what it was told before touching it. ka::explain documents what in the document is stable and what is deliberately not.

The button in KiCad

The same render, asked for from inside the editor — and carried out the moment you close it, because that is the first moment it can be.

nix run .#ka -- plugin install          # from a checkout of this repository
nix run github:…/kicad-automation#ka -- plugin install    # from anywhere else

In your own project the tidier form is to put the command on your PATH once, by adding this flake's ka to your devshell —

inputs.ka.url = "github:…/kicad-automation";
# …
devShells.default = pkgs.mkShell {
  packages = [ inputs.ka.packages.${system}.ka pkgs.kicad ];
};

— after which it is ka plugin install, ka render and ka plugin status, and your flake.lock pins which ka your boards were built with.

That is the whole installation. It writes a small plugin into KiCad's own user plugin path — ~/.local/share/kicad/10.0/plugins/ka on Linux, and it asks the KiCad you are pinned to rather than assuming that path — and points its entrypoint back at the ka that installed it. Then it tells you the one thing it cannot do for you:

installed the KiCad plugin into /home/you/.local/share/kicad/10.0/plugins/ka
    bin/ka -> /nix/store/…/bin/ka

KiCad will not load it yet. Its plugin system ships switched off and
nothing but you can switch it on:

    1. Preferences -> Plugins -> Enable KiCad API
    2. restart KiCad (plugins are scanned once, at startup)
    3. the button is in the *schematic* editor's top toolbar, at the
       right-hand end, tooltipped "Re-render synthetic sheets"

Start KiCad from your project's devshell. This is the fourth step and it is not in that list because ka cannot check it for you. Installing the plugin writes into ~/.local/share/kicad/…, which is global and belongs to no project — so the natural thing, install it and then launch KiCad the way you always launch KiCad, gives you a button that fails. The button runs your design program, your design program is a Rust build, and KiCad started from a desktop menu has no cargo, no kicad-cli and none of the pinned libraries. In a devshell it works; outside one you get eight lines of could not compile and a status bar with room for one of them.

nix develop
kicad board/myboard.kicad_pro

ka plugin status says whether all three of the steps above are true, reading KiCad's own settings back for the first one; ka plugin uninstall removes exactly what was written. The entrypoint is a symlink into the Nix store, so a nix store gc that collects the ka you installed from leaves it dangling — ka plugin status says so in as many words, and re-running install fixes it. Putting ka in your project's devshell as above avoids it entirely, because the devshell holds it. Nothing else in this flake writes to $HOME, and this is not an exception to that rule so much as the reason it is a command rather than something the devshell does behind your back. If you would rather have it declaratively, nix build .#kicadPlugin produces the same directory in the store for home-manager or an overlay to link:

xdg.dataFile."kicad/10.0/plugins/ka".source = "${kicadPlugin}/ka";

Where the answer appears — read this before you click it. Nowhere you are looking. KiCad does not print a plugin's output as text: it puts it in a hidden message store and shows a small warning triangle at the right-hand end of the status bar, with a count on it. The triangle is hidden until there is something to read, so it appearing — and its number going up by one on every click — is how you know the button ran. Click the triangle to open a Messages window with what it said.

This is KiCad's own doing and there is no way round it from a plugin; the chain is common/api/api_plugin_manager.cpp → common/reporter.cpp → common/widgets/kistatusbar.cpp, with line numbers in docs/kicad-source-answers.md §7.1.1. It was found by the first person to press the button, whose report was "the plugin button is there but I don't know how to judge if it is working. Shouldn't there be some text visible when I click it?" ka plugin install and ka plugin status now both print the paragraph above, so nobody meets the button without meeting it.

What the button does. It runs your design program, compares every generated sheet against what that program says, and rebuilds the ones that have drifted — when you close KiCad, which is the only moment rebuilding them means anything.

That last part is not a shortcoming to apologise for, it is the shape of the tool. Pressing the button means eeschema is holding every sheet of your hierarchy in memory. It never re-reads one from disk — there is no call on KiCad 10.0.6's schematic API that makes it, no revert and no save — and SCH_EDIT_FRAME::SaveProject writes all of its copies out at the next Ctrl-S with no per-sheet modified check (eeschema/files-io.cpp:1352-1407). A sheet rewritten while the editor is up is not risky, it is reverted, for any reason, without a word. So the press leaves behind a process that waits for the editor to be gone and then does exactly what ka render does.

So there are three things it can say, one line each:

Plugin action 'Re-render synthetic sheets': ldo: 2 synthetic sheet(s), none
edited by hand and all of them up to date with the code. Nothing to rebuild.
Plugin action 'Re-render synthetic sheets': 1 generated sheet no longer matches
the code. Queued: the rebuild runs as soon as you close KiCad. It cannot run
now -- KiCad has every sheet in memory, never re-reads one from disk, and
writes its own copy back at the next Ctrl-S. Sooner: close KiCad and run
ka render --project /home/you/ldo -- what changed: /tmp/ka-render-ldo.txt

and a refusal, below. The queued rebuild happens with no KiCad running, so there is no window for it to report into; the next press tells you what it did:

Plugin action 'Re-render synthetic sheets': ldo: 2 synthetic sheet(s), none
edited by hand and all of them up to date with the code. Nothing to rebuild.
The rebuild queued last time: ldo: 1 rewritten, 1 unchanged, 0 new.

If you would rather not wait, the command in the second message does it now, and closing KiCad first is the whole of the difference.

Because it runs your design program, the button needs the devshell — which is the fourth step above, and the reason it is there.

docs/decisions.md D36 is the argument and the citations, including why the rebuild waits on the pid of the KiCad that launched it rather than on a lock file, and why the API cannot answer that better. KiCad 11's API is the thing that changes any of it.

What the button will never do is quietly overwrite a sheet you have edited. If it finds one it refuses — and so does the rebuild, which re-runs that check at the moment it writes rather than carrying the press's answer forward, so a sheet you tidy up after pressing the button is still safe. Because a status bar is one line long, the whole refusal — which part, which field, what it was, what it is now — goes to a file whose path is in the line:

Plugin action 'Re-render synthetic sheets': gen/u_5v.kicad_sch has been edited
since ka wrote it. -- the whole of it: /tmp/ka-render-ldo.txt

docs/kicad-plugin.md is the design and docs/kicad-source-answers.md is what KiCad's own source says a plugin can and cannot reach. crates/ka-cli/src/plugin.rs is the code, and its module documentation is the short version of both.

If you do not use Nix

It will work, and you will have to supply five things by hand, because ka-env reads them and has no fallbacks:

variable what it points at
KICAD_SYMBOLS colon-separated directories of .kicad_sym libraries
KICAD_FOOTPRINTS colon-separated directories of .pretty footprint libraries
KICAD_CLI the kicad-cli binary
RSVG_CONVERT rsvg-convert, from librsvg
KICAD_DEMOS KiCad's shipped demo projects — the 134-file corpus the s-expression layer round-trips

plus a Rust toolchain (1.75 or newer; the shell pins 1.97).

Two more are optional, and both are about what starting KiCad costs. A full cargo test --workspace starts kicad-cli a couple of hundred times. KA_KICAD_JOBS says how many may run at once — a quarter of the cores if nobody says, 0 for one per core. KA_KICAD_CACHE names a directory to keep what KiCad wrote, filed under a digest of the command and of every byte that went into it, so a suite re-run over unchanged sheets barely starts it at all; the devshell points it at target/kicad-cache, and no check sets it, because a check has to ask KiCad itself.

The version matters more than it looks. KiCad hard-refuses a file written by a newer build, so the kicad-cli that writes a sheet and the GUI that opens it must be the same one — the flake pins 10.0.6. And the frozen parity fixtures under crates/ka-symbols/tests/parity/ and crates/ka-geometry/tests/parity/ were measured against the exact kicad-symbols revision flake.lock names, so on a different library version those two suites will fail. That is not a bug in your setup and the failure says which it is: a symbol that changed is worth looking at, a symbol that did not exist before is a library bump. Everything else in the workspace is version-independent.

Design blocks: lay it out once

A 3.3 V LDO is four parts, and drawing its schematic takes ten minutes that everything above already gives you back. Laying it out is different work. The bypass capacitor has to sit next to the pin, on the side the rail leaves. The ground return has to come back without threading between the regulator's tab and the output capacitor. The SOT-223 numbers its tab as pad 2, the same number as its output pin, so copper has to actually join them or DRC calls them unconnected. None of that is arithmetic. It is twenty minutes of judgement, it comes out slightly different every time, and the version you drew in March is better than the one you would draw today because you thought about it in March.

So it is worth keeping. A design block is that piece of a board, kept as KiCad's own .kicad_block — which means KiCad's Apply Design Block Layout does the placing, with graph isomorphism and automatic net reassignment, and your blocks work in KiCad with none of this tooling in the room.

Look at the one this repository ships:

cargo run -p ka-cli -- block list
ldo_3v3          5V -> 3.3V LDO, AMS1117-3.3 with input/output/bypass caps
                 keywords: ldo regulator 3v3 power
                 …/ka-design-blocks/ka.kicad_blocks/ldo_3v3.kicad_block

blocks/ldo_3v3/ is its source: three committed files, and the first of them is the point.

file what it is
layout.kicad_pcb the copper. This is the artefact — a person drew it
layout.lock.json the key it was drawn against
block.json what KiCad's Design Blocks panel shows

The key, which is the whole idea

Reusing stored copper is safe if, and only if, on the new circuit that copper still lands on the same pads and still joins the same nets. That is the entire test, and the key is computed from exactly those things: the reference designators, each part's resolved land pattern, the pad names on it, and the net topology as net -> sorted set of ref.pad.

Deliberately not in it: values, order codes, silkscreen, UUIDs, the symbol's lib_id, and the KiCad version. None of them can move a pad.

  • A capacitor going 22 µF to 10 µF in the same 0805 does not move a pad. The layout is reused and the build prints the drift as one line. This is not a technicality; it is the case the whole thing exists for.
  • A capacitor going 0805 to 0603 moves both its pads. The stored tracks now end in mid-air, a millimetre from copper. That is a miss, and it is a refusal naming the parts that moved and the parts that did not — never a warning, and never a quiet reuse, because a stale layout silently reused is a board that does not work and it does not look wrong until it comes back from the fab.

The key is taken over the lookup half of part selection and never the search half, so it needs no catalogue, no lockfile and no network: a 22 µF capacitor with no orderable part in 0603 still has a perfectly well-defined layout key.

A block library of your own

nix run .#ka -- block init myblocks --ka "git+file://$PWD"   # from a checkout
nix run github:…/kicad-automation#ka -- block init myblocks \
    --ka github:…/kicad-automation                           # from anywhere else
cd myblocks                  # init already made the repository and staged it
nix develop
cargo run -- regenerate

That is a working project: a flake consuming this checkout and putting ka on the devshell's PATH, a Cargo.toml depending on ka alone, a build.rs generating a type per symbol and a constant per land pattern, and a src/main.rs defining three blocks — an RC low-pass, a pair of I²C pull-ups and an indicator LED — with the copper for all three. regenerate draws them, DRCs every fragment through kicad-cli, and writes the three files per block into blocks/. Commit those, then go round once more:

git add blocks
exit && nix develop        # rebuilds the library this shell exports
cargo run -- build out     # every stored layout checked against its circuit
cargo run -- board         # writes board/: four sheets out of three blocks
cargo run -- check         # can KiCad see the result, and what to do next

The re-entry is not a formality and it is the one step that surprises people. The flake turns blocks/ into the library the devshell exports, so the shell you entered was built from blocks/ as it was then — empty. Until you git add and re-enter, check correctly reports that it cannot find the blocks you just wrote, and regenerate prints those three lines when it finishes. After that it is an ordinary edit loop: only a new block needs another re-entry.

board comes before you open KiCad, and it is the thing that makes the KiCad project. A library on its own has nowhere to go, and check's last paragraph sends you into KiCad — so the project has to exist by then. src/board.rs is a ka::Project with four sheets, and one of the three blocks is on two of them, which is the argument for design blocks made concrete rather than argued. Its design-block-lib-table is written into board/ — beside the .kicad_pro, which is the only place KiCad looks — and its parts come out of a committed myblocks.lock.json, so the first build is offline and works. Everything else under board/ is output: board/gen/*.kicad_sch is rewritten by every build and ka render --project board refuses rather than merging a hand edit.

Do not make that project from KiCad's File ▸ New Project. Its dialog opens with Create a new folder for the project ticked (kicad/widgets/filedlg_new_project.h:36-37) and the handler then appends a directory named after the project (kicad/tools/kicad_manager_control.cpp:112-115, both 10.0.6), so pointing it at board/ puts the .kicad_pro in board/myblocks/ and leaves the library table one directory above it, where KiCad does not look. The only symptom is an empty Design Blocks panel. cargo run -- check walks the project for that shape and names both paths and the fix; cargo run -- board writes the project, the table and the sheets together and needs no dialog at all.

Then KiCad:

ka plugin install                  # the re-render button, in KiCad
kicad board/myblocks.kicad_pro     # started from inside this devshell

Start KiCad from inside that devshell, as the second line does. The button runs the project's own program, and that program needs the toolchain the shell pins; the section on the button has the whole of it.

And read Seeing them in KiCad before you go looking for the blocks, because four separate things about KiCad's own panel look exactly like a library that was built wrong. The shortest of them: the panel is off by default and does not populate in the session you switch it on; and in the schematic editor a KA block is listed but will not place, answering "Design block has no schematic to place" — which is the correct answer, because a KA block is copper and its schematic is generated per use from your Rust. The board editor is where it places.

The scaffold's Cargo.toml says one dependency and means it, boards included: ka::Project::build is async — every sheet's ERC is its own kicad-cli process and a bounded number of them run at once — and ka::block_on starts the runtime for you, so nothing has to reach for tokio. ka is on the devshell's PATH for the same kind of reason: the ka that installs the button is then, by construction, the ka the blocks were built with.

--ka is the one thing only you know: it is the checkout or remote you depend on. git add before nix develop is not optional — Nix reads a flake's directory through git, and an untracked file is one it cannot see. blocks/ ships with a .gitkeep for the same reason: the flake turns that directory into the library the devshell exports, and git does not track an empty one.

What init writes is templates/block-library/, and that is a compiled member of this workspace: cargo check --workspace, clippy and the test suite all cover the program it hands you, so a scaffold that no longer builds fails a check here rather than wasting an afternoon there.

Writing a block

Three things, and only the third takes judgement. The full worked example is examples/src/blocks.rs — one real block with the reasoning behind every track — and the API is the ka::blocks module documentation (cargo doc --open -p ka).

One: the circuit. An ordinary sheet, with the designators the stored board will address its footprints by.

fn rc_lowpass() -> Result<Design, BuildError> {
    let s = Sheet::new("rc_lowpass", "1 kHz RC low-pass on a signal line.");
    let r = s.next_res(kilohms(1.6), P0402);
    let c = s.next_cap(nanofarads(100.0), P0402);

    s.net("IN").tie((r.p1,));
    s.net("OUT").tie((r.p2, c.p1));
    s.net("GND").tie((c.p2,));
    s.export(&["IN", "OUT", "GND"]);
    s.finish()
}

Two: the copper. Place stock land patterns, then draw between pads. You never compute a pad position: Fragment::pad is the footprint's at plus the pad's local offset rotated by its orientation, and getting the sign of that rotation wrong puts track ends a millimetre from copper on a board that still opens and still parses. crates/ka-blocks/tests/pads.rs is what says the arithmetic is right, by placing a footprint at each right angle and asking kicad-cli whether anything came out unconnected.

f.place(&tools.footprints, R0402, "R1", (100.0, 100.0), 0.0,
        &[("1", "IN"), ("2", "OUT")])?;
f.place(&tools.footprints, C0402, "C1", (103.5, 100.0), 270.0,
        &[("1", "OUT"), ("2", "GND")])?;

// Two pads and a corner, and the corner comes off the pads too. `connect`
// refuses two pads on different nets, because that is a short and it is the
// worst thing you can draw.
let (rx, _) = f.pad("R1", "2")?;
let (_, cy) = f.pad("C1", "1")?;
f.connect(("R1", "2"), &[(rx, cy)], ("C1", "1"), 0.25)?;

Three: three lines adding it to your library(), and cargo run -- regenerate.

Or draw it in KiCad. Rust copper is the way to make a block's first layout without leaving the build; a re-layout is usually better done where the copper is. ka::blocks::Library::layout writes a KiCad project holding just that block — with the design-block group already on its root schematic, so F8 with both boxes ticked seeds the board with the copper you already have — and Library::adopt reads back what you drew, drops the board furniture, hands it to kicad-cli pcb drc, and stores it. After that regenerate refuses to overwrite it, because no drawing function produces it and nothing else in the tree has a copy. examples/src/blocks.rs is a program that does both, and neither is a ka subcommand for the same reason build is not: a block's circuit and its copper are Rust in your project.

And regenerate never deletes. Take a block out of library() and its directory stays in blocks/, which matters because the library KiCad opens is built from that directory and not from your program — the block goes on appearing in the Design Blocks panel, placeable, while every report says it is gone. build and check both refuse on one, name it, and print the rm -r that removes it. Nothing deletes copper on your behalf.

Using a symbol that is not a resistor, a capacitor or an inductor

This is the ten lines that decide what a project can contain, and it is the first thing to read after the section above.

ka::sym holds exactly three types — C, R and L — and that is not a shortlist of the popular ones. It is everything a library can ship: a type per symbol has to exist before ka is compiled, and Sheet::cap and Sheet::res have to name a concrete one. Every other symbol in KiCad's 22,860 is a type generated into your own crate, by a build script:

# Cargo.toml — the one entry a project carries besides `ka`, because a build
# script's crates live in their own table and cannot be reached through the
# facade.
[build-dependencies]
ka-codegen = { git = "ssh://forgejo@forgejo.rammhold.de/slop/ka.git" }
// build.rs
fn main() -> Result<(), ka_codegen::BuildError> {
    // `Options::ka()` writes `::ka::rt` into the generated files, so the
    // generated types reach their runtime through the facade and nothing else
    // appears under `[dependencies]`. There is no default: a build script
    // cannot read its own crate's manifest, and a guess produced a scaffolded
    // project that would not compile.
    ka_codegen::build_rs::emit(
        &[("Led", "Device:LED"), ("Rp2350a", "MCU_RaspberryPi:RP2350A")],
        &ka_codegen::Options::ka(),
    )?;
    Ok(())
}
// src/main.rs, once
mod sym {
    include!(concat!(env!("OUT_DIR"), "/ka_symbols.rs"));
    pub use ka::sym::{C, L, R};   // so one `sym::` names all of them
}

Then s.part::<sym::Led>("D1", "green", "LED_SMD:LED_0603_1608Metric") gives you a struct whose fields are its pins: d.a, d.k, a pin wired twice is a compile error and a pin never wired is a failure when the program runs. The library half of a lib_id is a file name without its extension — Device:LED means Device.kicad_sym — and naming a symbol that is not there stops the build pointing at the lib_id rather than handing you a struct with no pins.

Name the ones you use and no more: generating the whole stock corpus costs about 57 seconds of cold rustc. ka block init writes all of the above, with an LED block using it, so a scaffolded project starts able to hold real parts.

A part drawn as several units gives each of them its own fields. KiCad draws a dual op-amp as three units — amplifier A, amplifier B, and a unit carrying V+ and V− — and so is every logic gate, every analogue switch and a Neutrik combo jack. All of them are placed, each at its own place on the sheet, under one designator that KiCad draws as U1A, U1B, U1C; and where two units would want the same field name, the unit's letter separates them:

let u = s.part::<sym::Opa1662d>("U1", "OPA1662D", SOIC8);
s.net("REF_MID").tie((u.out_a, u.n_a, u.p_b));   // A's output into B's input
s.net("+5V").tie(u.v_p);                          // the supply unit

An op-amp's output has no name in KiCad's libraries, so the field takes its electrical type: out_a, out_b. ka symbol <lib_id> prints the whole table and says how many units the part is drawn as.

Footprints are the other way round: you get all 15,450. A symbol becomes a struct with two fields per pin; a land pattern becomes one constant, so there is no list to keep and nothing to guess at:

// build.rs, beside the `emit` above
ka_codegen::build_rs::emit_footprints(&ka_codegen::Options::ka())?;
// src/main.rs, once
pub mod fp {
    include!(concat!(env!("OUT_DIR"), "/ka_footprints.rs"));
}

Then type fp:: and the editor lists the 155 libraries, fp::package_to_sot_smd:: and it lists what is in that one, each with KiCad's own description, its pad count, whether it is surface mount and the path to its 3D body:

s.part::<sym::Led>("D1", "green", fp::led_smd::LED_0603_1608Metric.lib_id);

The constant is named as KiCad names the file, with the characters Rust will not take in an identifier replaced by _ — SOT-23-5 is SOT_23_5, Crystal_SMD_3225-4Pin_3.2x2.5mm is Crystal_SMD_3225_4Pin_3_2x2_5mm. It costs about 3 seconds of optimised rustc and 233 MB of rust-analyzer for the whole corpus, measured; crates/ka-codegen/src/footprints.rs shows the working and docs/decisions.md D78 is the argument.

A project scaffolded by ka block init has both of those already, symbols and land patterns, and its example blocks are drawn on fp:: constants rather than on strings. There is nothing to switch on: fp:: completes the moment the first build finishes.

To find a part at all, ask ka find. It takes the words you would say out loud rather than a Library:Symbol you do not have yet, because that is where choosing actually starts — and it reads each symbol's description, KiCad's own keywords and the land pattern it names, not only its name. buck appears in no stock symbol name and in 515 of their descriptions; hall in one name and 283 descriptions. Every word has to match, so a second word narrows. Whitespace separates the words wherever it comes from, so ka find "hall sensor" and ka find hall sensor ask the same thing — and so do ka symbol, ka footprint and ka blueprints, which read a query the same way.

$ ka find INA240
8 of 22860 symbols match "INA240" -- every word, in the name, the description,
KiCad's own keywords or the land pattern. They come in 2 shape(s): a pad count,
and the land pattern the symbol's own library names for it.

  Symbols in one shape are wired the same way. Under each shape is the sentence
  its symbols share, with `[...]` where they diverge, and then what each of them
  puts in that hole -- which is the whole of the difference between them.

  8 pads on SOIC-8_3.9x4.9mm_P1.27mm
      High- and Low-Side, Bidirectional, Zero-Drift, Current-Sense Amplifier With Enhanced
      PWM Rejection, [...] SOIC-8
        Amplifier_Current:INA240A1D  20V/V
        Amplifier_Current:INA240A2D  50V/V
        Amplifier_Current:INA240A3D  100V/V
        Amplifier_Current:INA240A4D  200V/V

  8 pads on TSSOP-8_4.4x3mm_P0.65mm
      ...

A shape is a pad count and the land pattern the symbol's own library names, which is the first thing anybody eliminates on — ka find STM32G431 is 35 symbols and 9 shapes. Inside a shape the symbols are wired identically, so what is printed there is only the fragment of the description that differs: the gain above, 32KB against 128KB for an STM32's flash. Add a word to narrow, and a land pattern is a word: ka find hall SOT-23W is 11 of the 283 hall sensors.

Give it a Library:Symbol, or narrow until one symbol is left, and it prints the whole page instead — the pins, the land pattern's own pads and 3D body, and both lines to paste:

      // build.rs
      ka_codegen::build_rs::emit(&[("Ams111733", "Regulator_Linear:AMS1117-3.3")])?;

      // your sheet
      const SOT_223_3_TABPIN2: &str = "Package_TO_SOT_SMD:SOT-223-3_TabPin2";
      s.part::<sym::Ams111733>("U1", "AMS1117-3.3", SOT_223_3_TABPIN2);

A search reads all 223 stock libraries: 1.6 s to 2.5 s, measured on a busy machine. Naming a Library:Symbol reads one library and costs 50 ms.

ka symbol prints the pin half on its own. It is the same binary that installs the KiCad button and adds parts to the lockfile, so it is already on your PATH, and it needs no project and no build:

$ ka symbol Device:LED
Device:LED
    2 pad(s), reference prefix D

  In build.rs, so that `sym::Led` exists:

      ka_codegen::build_rs::emit(&[("Led", "Device:LED")])?;

  field  pad(s)  pin name  type
  k      1       K         passive
  a      2       A         passive

An argument with no colon in it runs ka find's search under this command's name — ka symbol attiny — because the question one step earlier is the same question wherever you type it. ka symbol --rust prints the whole generated module for when you want to grep it.

The same information is in your own project's rustdoc once the part is in build.rs, with the datasheet's pin name on every field: cargo doc --bins --no-deps --document-private-items --open.

--add does the paste. A symbol becomes a type only once its pair is in the list your build.rs hands to ka_codegen::build_rs::emit — that is why fp:: completes in your editor and sym:: completes only what you have already asked for. Adding a diode you will use once meant knowing the symbol exists, opening build.rs, pasting, and rebuilding. Now:

$ ka symbol Device:D --add
...the pin table, as always...

  Added to /home/you/myboard/build.rs:7, so that `sym::D` exists:

      5 |             // Used by `led_indicator` in src/main.rs.
      6 |             ("Led", "Device:LED"),
    + 7 |             ("D", "Device:D"),
      8 |         ],
      9 |         &options,

    The list is grouped by comments, and this went at the end of it, under

        // Used by `led_indicator` in src/main.rs.

    Nothing here knows your grouping: move the line if it belongs somewhere
    else.

    `cargo build` regenerates the module. `sym::D` is what the sheet names.

It edits the build.rs at or above the directory you are standing in, so it finds your project's and nothing else; --build-rs <PATH> names one when the nearest is not the one you meant. The file is read as Rust rather than as text — the line goes in at a byte offset the lexer chose — and it is read back before anything is written, so a refusal leaves your build script exactly as it was. --dry-run shows the same report and writes nothing.

ka symbol Device:D --add calls the type D, because that is the name the printed line has always suggested. --as Diode names it yourself, which is also the answer when two libraries have the same leaf name: the second --add is refused, naming the symbol that already has the name, rather than declaring the type twice or inventing a suffix. Running it again when the part is already there changes nothing and says where it is.

ka find has the same flag, on the command that has already worked out which symbol it is recommending. ka find schottky diode --add never makes you type the name back. A search that has not narrowed to one symbol prints its listing and then refuses, rather than choosing for you.

And ka footprint for the other half. A symbol says what a part is; a footprint says what it physically fits, and s.part will not compile without both. The command has the same two shapes:

$ ka footprint Package_TO_SOT_SMD:SOT-23-5
Package_TO_SOT_SMD:SOT-23-5
    5 pad number(s) on 5 piece(s) of copper, surface mount
    SOT, 5 Pin (JEDEC MO-178 Var AA https://www.jedec.org/document_search?...)
    tags: SOT TO_SOT_SMD

  Where the footprint goes in your design:

      const SOT_23_5: &str = "Package_TO_SOT_SMD:SOT-23-5";
      s.part::<sym::SYMBOL>("REF", "VALUE", SOT_23_5);
  ...
  That string works in any project. Once your build.rs calls
  `ka_codegen::build_rs::emit_footprints()`, the same name is a constant your
  editor completes and a typo stops the build instead of the board:

      const SOT_23_5: &str = fp::package_to_sot_smd::SOT_23_5.lib_id;

An argument with no colon searches — ka footprint "push button" — and the search reads each footprint's description and tags as well as its name, because button is in no stock footprint filename at all and in 313 land patterns once the description and the tags are read. It also prints the 3D body, which comes with the land pattern rather than being a third thing to choose.

A search is words, and every word has to match, exactly as in ka find. The words may sit in different fields and in any order, and quoting makes no difference: ka footprint "Fuse 1812", ka footprint fuse 1812 and ka find fuse 1812 are the same question. Fuse:Fuse_1812_4532Metric answers it, because its name holds both words and its descr line reads "Fuse SMD 1812". A one-letter word narrows almost nothing, so reach for the spelling the library uses: usb-c is 3 land patterns where usb c is 105.

A search that finds nothing says which word to change. It names any word the libraries do not carry at all, and then, for each word, how many rows are left when that word is dropped. Where the list is short enough it prints the names too, and then the refusal is the answer. ka find, ka symbol and ka blueprints refuse in the same shape.

$ ka footprint fuse qfn-60
ka: nothing matches "fuse", "qfn-60" in 15450 footprints across 155 libraries.
    Searched: ...
    Each word, and how many footprints the search finds without it:
        "fuse"      2
            Package_DFN_QFN:QFN-60-1EP_7x7mm_P0.4mm_EP3.4x3.4mm
            Package_DFN_QFN:QFN-60-1EP_7x7mm_P0.4mm_EP3.4x3.4mm_ThermalVias
        "qfn-60"  155
    ...

ka footprint --package "LQFP-32(7x7)" is the search from the supplier's end: give it the package string ka parts lookup reported and it lists the land patterns that fit it.

Completing a symbol the project has not declared yet

fp:: completes on its own, because every land pattern is generated. sym:: cannot: a symbol becomes a Rust type only when its pair is in build.rs, so rust-analyzer completes the parts you have already asked for and nothing else. Somebody who wants a diode they have never used gets an empty list, and there is nothing rust-analyzer could do about it — the item is not there.

ka-lsp is a second language server that reads KiCad's libraries instead of your crate, so it can offer what does not exist yet. It runs beside rust-analyzer rather than instead of it, and it answers only after sym::.

The binary is nix build .#ka-lsp, and it has to be on the PATH the editor was started with. In a project written by ka block init it already is — it is in that project's default devshell, so direnv allow puts it there — and the .dir-locals.el written beside it registers this client for you, which is what makes a long-lived Emacs work across several projects without your configuration knowing any of them. Emacs asks once before running that file, and docs/emacs-integration.md §6.6.5 is why it is a file rather than a line in your init.

Here, or in a project of your own, it is these lines in your own configuration:

;; lsp-mode. `:add-on? t` is what makes it run alongside rust-analyzer
;; rather than compete with it.
(lsp-register-client
 (make-lsp-client
  :new-connection (lsp-stdio-connection "ka-lsp")
  :major-modes '(rust-mode rust-ts-mode)
  :add-on? t
  :priority -1
  :server-id 'ka-lsp))

Type sym::dio and it offers Device:D — matched on the library's own description, because "diode" is in no symbol name. Accept it and two things happen: sym::D goes in the buffer, and ("D", "Device:D") goes in your build.rs, as an edit your editor makes and C-/ undoes.

D            Device:D — 2 pads — not in build.rs yet
DSchottky    Device:D_Schottky — 2 pads — not in build.rs yet

The pair is not written by the server. It runs ka symbol Device:D --add against a copy of your build script and sends the difference back as an edit, so where the pair goes in the list is the same decision the command makes at a terminal, and a refusal — a name already taken, a build script with no list — reaches you in the command's own words.

eglot cannot run this, and its own manual says so in a chapter: it keeps one server per buffer by design. docs/emacs-integration.md has the reading, what the rest of an Emacs integration would be, and the measurements — a 2-second startup walk over 22,860 symbols, and answers in well under a millisecond afterwards, which is what makes a completion list that may be refetched on every keystroke affordable.

When something is refused

blocks/i2c_pullups/layout.kicad_pcb: KiCad will not accept this fragment.
  silk_overlap            Silkscreen clearance
      Segment of R1 on F.Silkscreen (99.85, 100.38)
      Reference field of R2 (100.00, 100.83)
  ('invalid_outline' is expected and ignored: a block has no board outline.)

The two indented lines are KiCad's own, and they are the answer rather than the question: nothing is wrong with the copper. A footprint's Reference field — the R2 KiCad prints on the silkscreen — sits about 1.17 mm above the part and is 1 mm of text, so two stacked 0402s at a 2 mm pitch have their designators sitting on each other. About 2.25 mm is where that stops.

invalid_outline is the one violation every fragment has for ever, because a design block is a piece of a board and the outline belongs to the board it is placed into. It is filtered, and nothing else is: zero unconnected items and nothing but that, with no way to turn either off.

Seeing them in KiCad

First, the project itself. KiCad reads a design-block-lib-table from the directory holding the .kicad_pro and from nowhere else — not the project's parent, not a subdirectory. A project written by cargo run -- board has one there already; a project made from KiCad's File ▸ New Project almost certainly does not, because that dialog opens with Create a new folder for the project ticked and puts the .kicad_pro one directory below where you pointed it (kicad/widgets/filedlg_new_project.h:36-37, kicad/tools/kicad_manager_control.cpp:112-115). The table stays where it was, KiCad reads none of it, and the only symptom is an empty panel.

ka block check walks the directory you run it in for that shape and prints both paths and the fix:

no design-block-lib-table beside this project, so KiCad reads no blocks for it:
    project  /home/you/myblocks/board/mine/mine.kicad_pro
    table    /home/you/myblocks/board/design-block-lib-table

Then two things about KiCad itself, and between them they cost somebody an hour. Neither is ours; both are measured on 10.0.6 and recorded in docs/findings.md.

  1. The Design Blocks panel is off by default. View ▸ Panels ▸ Design Blocks, four menus in, unticked.
  2. KiCad does not populate it in the session you first switch it on. Tick it, quit KiCad, open the project again, and everything is there.

An empty panel looks exactly like a library that was built wrong, and Preferences ▸ Manage Design Block Libraries will show your table as perfectly healthy while it happens. The person who found this rebuilt their library twice and tested against a known-good fixture before working it out.

  1. And then, in the schematic editor, the block's preview pane will be empty, which is also correct. That preview reads a block's .kicad_sch and only that (eeschema/widgets/sch_design_block_preview_widget.cpp:193), and a block here ships its board and not its schematic — deliberately, because the schematic half is generated per instance and one file in a library can only be one of them. KiCad's own design_block_io.cpp says a block needs "a schematic or board file", so board-only is legal, and docs/design-blocks.md §3.5 is the whole argument. It goes blank without a word: the widget clears the canvas, finds no file, and sets no status text.

    The board editor's preview draws the copper. It is a different widget reading the .kicad_pcb (pcbnew/widgets/pcb_design_block_preview_widget.cpp:169) — two previews, one file format each. So an empty preview is expected on one side of KiCad and would be a real fault on the other.

That is three separate ways an empty rectangle in that panel means nothing is wrong. Two of them cost somebody an hour and the third cost somebody a trip into docs/.

Placing one: the board editor, not the schematic editor

This is the fourth way, it is the one that reads most like a bug, and it is worth its own heading because the panel offers you the wrong thing without dimming it.

A KA block is copper. It holds a .kicad_pcb and no .kicad_sch. So:

  • In the schematic editor it cannot be placed, and says so. The entry is listed and Place design block is offered — eeschema's menu condition is only "is this a design block" (eeschema/tools/sch_design_block_control.cpp:65), so nothing is greyed out — and clicking it puts "Design block has no schematic to place." in the info bar (sch_drawing_tools.cpp:784). Place as sheet is worse: a modal box reading File '' does not exist. That is the expected answer, not a broken library.

  • In the board editor it places. pcbnew's own Design Blocks panel appends the block's copper and wraps it in a group carrying the block's library id (pcbnew/tools/pcb_control.cpp:2246-2252) — which is exactly the link Apply Design Block Layout matches on.

  • For a board built from your own generated sheets, the route is F8 (Update PCB from Schematic) with both boxes ticked — Group footprints based on symbol group and Apply design block layouts to new groups. They are unticked every time the dialog opens, in every project, and they do not stick; there is no setting for them.

    Tick the first one first. Apply design block layouts to new groups is disabled until Group footprints based on symbol group is ticked, and unticking the group box clears the layout box again (pcbnew/dialogs/dialog_update_pcb.cpp:57, :139, :147-157). Somebody who reaches for the second one first finds it greyed out and concludes the feature is not implemented; a dogfooding trial did exactly that.

F8 needs the project, not the schematic

The route above only exists if you came in through the project manager:

Cannot update the PCB because the Schematic Editor is opened in stand-alone
mode. In order to create/update PCBs from schematics, launch the main KiCad
application and create a project.

That is SCH_EDIT_FRAME::OnUpdatePCB refusing on Kiface().IsSingle() (eeschema/sch_edit_frame.cpp:1354), and the flag is decided before any argument is read: common/single_top.cpp:78 constructs the stand-alone eeschema binary's KIWAY with KFCTL_STANDALONE. So eeschema board/trial.kicad_sch is always stand-alone, whatever you pass it, and so is kicad --frame sch. Open kicad board/trial.kicad_pro and take Tools ▸ Schematic Editor (Ctrl+E) from there; only then is F8 offered.

All of this is now written into each block's own block.json, so KiCad renders it in the details pane underneath the block you just clicked — which is where somebody is standing when they need it. ka block check prints the same thing at a terminal. The source citations are in docs/kicad-source-answers.md §7.4b.

And it cannot be hidden from the schematic editor, which was checked rather than assumed. There is one design-block-lib-table per scope, reached by both editors through one program-wide adapter (common/project.cpp:437-456); the tree is built by shared code whose only per-row filter is an editor-agnostic Hidden() flag (common/design_block_tree_model_adapter.cpp:64-77); enumeration is a directory glob that never opens the block (common/design_block_io.cpp:302-308); and the manifest has exactly three keys — description, keywords, fields — and no notion of kind (common/design_block_io.cpp:349-364). So the entry appears in eeschema whatever we do, and the only surfaces we can write on are the two we do write on. docs/decisions.md D41 is the argument, including why a placeholder .kicad_sch would be worse than the refusal.

The table itself goes in the KiCad project's directory, beside the .kicad_pro — which is board/ in a scaffolded project, not the repository root. cargo run -- board writes it there; the section above is what happens when something else puts the project somewhere else. Its URI goes through ${KICAD_DESIGN_BLOCK_LIB}, which the devshell sets, so it follows a rebuild without being edited.

cargo run -p ka-cli -- block check

prints all of that with the paths filled in, and fails on either of the two failures whose only other symptom is an empty panel: a block on the search path but missing from the merged library KiCad's table actually names, and a .kicad_pro whose table is a directory away.

This is not the same switch as the button's. That one is Preferences ▸ Plugins ▸ Enable KiCad API, and it is what makes KiCad load ka's re-render plugin. Both ship off, both need a restart, and neither does anything for the other — so having done one is not having done the other. ka plugin status reports on that one, ka block check on this one.

The two environment variables

KICAD_DESIGN_BLOCKS a :-separated search path, most specific first. This is what ka reads, un-merged, so a report can say which library a block came from
KICAD_DESIGN_BLOCK_LIB the single merged directory the design-block-lib-table row names. KiCad expands arbitrary environment variables in a table URI — measured, not assumed

Two of them, because there are two consumers and a library-table URI cannot hold a list. The merge is per block: your ldo_3v3 replaces the one you got from elsewhere and the other twenty-nine stay referenced rather than forked, which KiCad's own nickname shadowing cannot do.

The example boards

examples/src/ is one file per board, and they are the acceptance test for the API rather than a demo: every sheet each one draws is frozen under examples/tests/sheet-goldens/, so a change that moves a wire on a board nobody was looking at fails the build until somebody refreshes the fixture and says what moved it. The criteria are hard — none may contain a pin-name string, every value carries a unit, and a pin left unconnected is a compile error.

01_ldo through 06_wireless are ports of python/examples/; the boards after them were written for Rust, and each exists so that a group of blueprints has a board that fails when their arithmetic does.

Asking a board what it decided

cargo run -p ka-examples -- explain 10_crossover

builds the sheets and prints what every blueprint on the board was asked, what it chose, which limit bound it and the whole argument for it — then, for every part it placed, the designator, the printed value, the value the arithmetic wanted before a preferred series moved it, and the voltage, current and dissipation the design worked out. A figure nothing computed says not computed; it never reads as a zero.

It touches nothing on disk and needs no KiCad, no network and no lockfile. The same text is frozen per board under examples/tests/decision-goldens/, which is what makes it a test rather than a document: change a driver's Re, a crossover frequency or an amplifier rating and cargo test -p ka-examples --test decisions fails, naming the sheet, the part and both numbers. Refresh with KA_REGEN=1, the same way the sheet goldens are refreshed, and commit the fixture with the change that moved it.

For a project of your own the same document comes out as JSON from ka render --explain.

Asking a board which part it buys

examples/tests/part-goldens/ freezes the order code every position on every board buys, and beside it the rows the shelf offered: each admitted row's assembly tier, its value, its stock to the order of magnitude and its unit price, which is every figure the ranking reads. The lockfile already said what was on offer; this says which offer was taken.

That fixture is stable because the shelf is a committed file and a build reaches no network, so the part a board buys moves only when somebody commits a change. cargo test -p ka-examples --test part_goldens then says which change it was: the request the board makes, the rows the lockfile holds, or the ranking in ka_parts::choose.

KA_REGEN=1 refreshes the figures beside each part and refuses to write an order code the fixture does not already name. A part that really moved is typed in by hand, which is a person saying they looked at it.

nix flake check is what has to pass

nix flake check

That one command is the acceptance criterion for this repository: green means the tree is good, red means it is broken, and it does not count that the same commands passed in your shell. The sandbox has no network, no $HOME you have arranged and nothing on $PATH that flake.nix did not put there, which is what makes its answer the honest one. Twice now a check here was red for weeks because nobody ran it.

check what it is
rust the workspace built, cargo clippy --all-targets -D warnings, and cargo test --workspace — 686 tests and 80 doctests as of 2026-09-03, the same count a devshell runs
regress every board generated, and every sheet audited through KiCad
blocks every design block's stored layout checked against its circuit, the fragment run through kicad-cli pcb drc, and the library resolved through the variables the devshell sets
plugin the KiCad plugin's manifest through KiCad's own shipped JSON schema, and the plugin ka plugin install writes from it: entrypoint relative, resolving, executable, every icon a PNG that is there
formatting nix fmt has been run — nixfmt, rustfmt, and every Markdown table laid out, in .md files and in doc comments. The same three formatters nix fmt runs, in check mode, so what one writes is what the other demands
cargo-nix crate2nix generate run again in a sandbox and diffed, so a stale generated Cargo.nix cannot reach the tree
no-store-paths no /nix/store/<hash>-… path baked into a tracked file — a grep for the shape a real path has, not for the words, so the files that state the rule in prose still pass
readme-boards the board names this README writes are exactly the boards ka-examples builds, so a board added and not written up here is a failure rather than a silently out-of-date paragraph
emacs the emacs devshell's Emacs opened on a Rust file: lsp-mode and envrc load, the buffer gets a Rust major mode, and there is a rust-analyzer client to start in it. Then the .dir-locals.el ka block init writes is evaluated, and has to register an add-on ka-lsp client that declines a buffer with no ka-lsp to start — and to be a silent no-op in an Emacs without lsp-mode
docs cargo doc with -D warnings, so a broken intra-doc link is a build failure
docs-links every relative link, anchor and image in the Markdown resolves
docs-site the publishable documentation site still builds — nix build .#docs
docs-site-links every link in that site's rendered HTML resolves, fragments and links into /api/ included
python-reference the archived Python's own tests, which nothing else depends on

Three and a half minutes from cold on a 32-core machine, because they build in parallel, and seconds when nothing has changed. What it deliberately does not check, said out loud rather than skipped quietly:

  • External URLs. A Nix builder has no network and must not have one, so the link check runs --offline and reports http(s) links as excluded rather than as passing. nix run .#link-check-online is the same run with a network, started by hand.
  • Paths written in prose inside backticks, which is how most of this repository cross-references itself. docs/findings.md quotes paths out of KiCad's source tree as well as ours and nothing tells them apart.
  • Doctests marked ignore — 58 of them, so they compile nowhere. 48 are uom's own, generated by a macro; the other 10 are in ka and ka-codegen, where the example needs symbol types only a real board's build script produces.
  • A project scaffolded from nothing, built and run. ka block init writes a project that reaches ka over git and its dependencies over crates.io, so its first cargo build needs a network the sandbox does not have — and it would have to start a nix develop of its own, which a Nix build cannot do. nix run .#scaffold-trial is that run, started by hand: it scaffolds into a fresh directory and drives the README's own sequence, regenerate, git add blocks, board, check, failing on the first step that does not. Run it before releasing a change to templates/, to ka block init, to a build.rs or to the sequence above — every breakage a person hit over a weekend was invisible from inside this tree, where the crate the generated code names and the blocks the generated main.rs refers to both happen to be present. What the sandbox does cover is the compiling half: templates/block-library is a real workspace member generating symbols and land patterns exactly as the written project does, so checks.rust refuses a template whose generated code names a crate its manifest does not carry.
01_ldo 5 V → 3.3 V, one blueprint — the smallest board here
02_boost a boost converter — a cyclic topology, which is the hard layout case
03_rp2350 an RP2350 with its QSPI flash and crystal
04_flight_controller RP2350 quadcopter: IMU, barometer, four ESC outputs
05_esc a four-channel brushless ESC
06_wireless an ESP32-S3 sensor node on a battery
07_devboard the demonstrator
08_servo_driver a two-channel RC servo driver: a fuse, reverse-polarity protection, a bulk bank and an arming switch, with no microcontroller on it at all
09_sensor_node a USB-charged I²C logger: ESD on every line that leaves the board, a lithium charger, a bus sized for a cable, a reset, a button and an SWD header
10_crossover a three-way passive loudspeaker crossover for three named Dayton Audio drivers: no rail, no microcontroller, nothing but passives, and copper carrying amps at tens of volts
11_power_or a 12 V bench adapter ORed with a 3S pack: an active diode on each input, so whichever is higher carries the board and pulling either one does not interrupt it
12_preamp an electret microphone preamplifier on 5 V: a gain stage, a pole that limits the band and a buffer that drives the lead, with the two halves of one dual op-amp in two different stages of the signal path

Example 07 is the one to read. It is an RP2350 board you could order: USB-C in, a lithium cell it charges, three LEDs and 27 GPIO on headers — and not one part number is typed into it. Every resistor and every capacitor is described and the catalogue picks it. The only things named by hand are the ones nothing could choose for you: the microcontroller, the regulator, the charger, the flash, the crystal, the connectors and the inductor. Its module documentation is also where the sizing is explained — why the cell cutoff is 3.7 V and not 3.0 V, why the load capacitors come from the crystal's own C_L, and the two errors the tool raised at the first two attempts at its power path.

Its MCU sheet:

the devboard's MCU sheet

Sixty-one pads accounted for, six IOVDD pads and three DVDD pads each wired rather than one of each, one 100 nF per supply pad because a decoupling capacitor works by being close, and 27 GPIO leaving on labels in header order. There is no loop over u.gpio0..u.gpio29 and there cannot be: a pin is used once, so tying it moves it out of the struct, and a helper taking &u would not compile. That is the guarantee doing its job.

And where those 27 GPIO come out, two sheets away:

the devboard's header sheet

Nothing joins those two sheets except the names. GP0 leaves the MCU sheet as a label and arrives on the header sheet as a label, and that is the whole mechanism KiCad uses to make them one net — which is why a net name is one of the few strings left in the API, and why the netlist KiCad exports is compared against what was declared on every run rather than taken on trust. The order is not a coincidence either: the same HEADER_A and HEADER_B arrays drive both sheets, and an assertion fails the build if they ever disagree about which GPIO comes out.

Regenerating these pictures: build the boards, then

kicad-cli sch export svg --no-background-color --exclude-drawing-sheet \
    -o /tmp/svg out/devboard/gen/mcu.kicad_sch
rsvg-convert --background-color white -o mcu.png /tmp/svg/mcu.svg

--exclude-drawing-sheet drops the title block and border; the white background is so the drawing reads the same in a dark theme, since KiCad's stroke colours are all dark. That gives you the whole A4 page, most of which is empty — the committed images are then cropped to the drawing's own extent, which rsvg-convert's --page-width/--page-height/--left/--top will do once you know the bounding box.

Where to look next

crates/README.md is the map of the workspace: eighteen crates, what each is for, what it is accepted against, and the rules that apply to all of them. Read that before writing code here — one of the rules (never iterate a HashMap, and BTreeMap is not automatically the fix) exists because getting it wrong makes the layout tests flap intermittently and the cause is very hard to find.

Start from crates/ka-blueprints/src/lib.rs, which is the crate the rest of the project exists to make possible, and crates/ka-calc/src/lib.rs, which is where the arithmetic lives. For design blocks it is crates/ka/src/blocks.rs — the API — and crates/ka-blocks/src/lib.rs underneath it.

decisions.md every settled decision, with what would change it. The index to everything else
working-agreement.md how this repository is worked on: what has to pass, how work is delegated and merged, and the traps that have cost real time. Read this first if you are picking the project up.
task-board.md what is running, what is waiting to be merged, and what is queued. Kept current.
design-blocks.md design blocks and the layout cache: what a block is, the key over a circuit, the Nix build, and what KiCad does with the result. Its §0 says which of its five stages are built
layout-model.md how a sheet gets laid out: the model, its prior art, and what real designs forced
doc-style.md who the documentation is written for, and the six things they need
findings.md measured facts about KiCad's formats and tooling, several of which contradict the obvious plan. Each says whether it was measured or read, and against which version
rust-api-design.md the API's design and the measurements behind it
example-ergonomics.md the criteria the examples are judged against
hierarchical-sheets.md sheet pins instead of global labels: the plan, not yet the code
emacs-integration.md choosing a part without leaving the buffer: the ka.el specification, why an LSP server is the wrong vehicle for most of it, and how a project's Emacs finds that project's own ka. Its §8 says what is built — the devshell — and what is not
design-outline.md the original plan, written 2026-09-01. Historical; decisions.md supersedes it where they differ
MANUAL-TASKS.md what needs a human. Also from week one, and partly answered since

spikes/ is where those measurements were taken. Each directory is one question, a RESULTS.md answering it, and the files that answer it — the design-block handoff KiCad actually performs, three s-expression libraries found lossy, what tscircuit's layout is worth. The documents above cite them by number, and this is where those numbers point.

python/ is the Python this was ported from. It is an archive: no Rust test runs it and no Rust test needs it to have been run. It is kept because it was the oracle the port was measured against, because some of it is better commented than the code that replaced it, and because three small things in flake.nix still call it. Its README says exactly what those are and when the tree can go.

The port bought the authoring experience the Python API could not give: pin names the LSP completes rather than strings, a pin on two nets that is a compile error rather than a short, and units the compiler checks. Measured on the six ported examples, it cost nothing in length — 471 lines of Rust against the Python's 476, comments excluded on both sides.

All of it as a website

nix build .#docs

That writes result/: this README as the front page, every document above rendered to HTML with a table of contents and a sidebar, a search box that works offline, and the rustdoc for all twenty crates at result/api/. About a minute from cold, of which all but two seconds is cargo doc — mdBook renders the 900 kB of Markdown in one — and 59 MB on disk, of which 49 MB is the rustdoc and 6 MB is the search index. Inside nix flake check it costs those two seconds and nothing more: the rustdoc is checks.docs, which was being built anyway.

To read it, open result/index.html. To publish it, copy the directory somewhere:

cp -rL result /var/www/kicad-automation      # a web host
rsync -a --delete result/ user@host:/srv/docs/

or, for GitHub Pages, push result/'s contents to the branch Pages serves. Every link in it is relative, so it works from file://, from a domain root and from a /project/ prefix alike; and nothing in it fetches anything, so it works with no network at all. Use cp -rL rather than cp -r: result is a symlink into the Nix store, and what you want is its contents.

The site is built by mdBook, and the two things worth knowing about it are in flake.nix beside packages.docs: what is published (all of docs/, including the working documents — this README's own map links to the task board, and the site's sidebar is the only route there has ever been to the files nothing else links to), and how the links are made to resolve — mdBook turns .md links into .html ones, and links that pointed at a .rs file are pointed at rustdoc's rendered source page for that same file instead. checks.docs-site-links re-checks all of it in the rendered HTML, which is a different question from the one checks.docs-links asks of the Markdown.

Emacs, and an editor that finds this project's tools

nix develop .#emacs --command emacs .

A second devshell: the default one plus an Emacs carrying lsp-mode, envrc and rust-mode. M-x lsp in a .rs buffer starts the rust-analyzer this flake pins, in the environment it pins — which matters, because a rust-analyzer that cannot see KICAD_SYMBOLS indexes a workspace whose build.rs fails.

The same shell is in every project ka block init writes, and that is the point of it. A project pins its own ka, and an editor started inside that project's shell uses that ka — so your init.el never has to know this project exists, and two projects on different revisions do not fight. If you keep one Emacs open across several projects, envrc-mode gives each buffer its own project's environment off the .envrc that is already there; both routes were measured from projects outside this repository, and docs/emacs-integration.md §6.6 writes them out.

This shell does not put ka-lsp on the PATH — this repository builds it (nix build .#ka-lsp) rather than shipping it in a shell, the same way it does not put ka on one. A project written by ka block init is the other way round: ka-lsp is in its default devshell and its generated .dir-locals.el registers the client, which is completing a symbol the project has not declared yet.

That document is also the specification for ka.el — an interactive part picker, sym:: and fp:: insertion, and a part's stock and pinout under point. None of that is built; §8 is the plan, and §8.0 is why completing a footprint is already solved, why completing a symbol needed the server, and why neither answers "which of these four packages".

If your editor is using too much memory

Two processes are involved and they are worth separating, because the fix is different for each. A rust-analyzer session on this workspace holds about 1.3 GiB. The cargo check it runs on save is a second cost — with nothing configured that is --workspace --all-targets, which starts one rustc per core, and cold that peaks at 3.1–3.5 GiB depending on how many cores you have. rust-analyzer spawns it as a child, so most process monitors show you the sum and call it the editor.

The one setting that matters is the job count:

// rust-analyzer.check.extraArgs — about 900 MiB instead of about 3,500,
// for about 40% more time on each save.
"rust-analyzer.check.extraArgs": ["-j", "4"]

Pointing the editor at examples/ instead of the workspace root does not help — it is a workspace member, so Cargo resolves it to the same crate graph either way. Neither does scoping the check to one package: ka-examples depends on ka, which depends on everything. Both were measured; see D15b in docs/decisions.md.

The other half of this was a dependency. uom::si is a catalogue of 119 quantities — luminance, catalytic activity, angular jerk — and lowering all of it cost rust-analyzer 1,793 MB, over half of everything it spent on this repository. crates/ka-calc/src/si.rs declares seventeen instead — fourteen a circuit measures plus three the dimension algebra needs a base unit for — using uom's own macros. To re-measure any of this, or to check what a new dependency costs before you commit it:

nix develop --command python3 python/tools/ra_memory.py