- Rust 92.6%
- Nix 4.3%
- Python 2.9%
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Wsct4bxTmseK4cuK6nFgRD |
||
|---|---|---|
| blocks/ldo_3v3 | ||
| crates | ||
| docs | ||
| examples | ||
| nix | ||
| python | ||
| spikes | ||
| templates/block-library | ||
| .envrc | ||
| .gitignore | ||
| Cargo.lock | ||
| Cargo.nix | ||
| Cargo.toml | ||
| flake.lock | ||
| flake.nix | ||
| README.md | ||
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:
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.
- The Design Blocks panel is off by default. View ▸ Panels ▸ Design Blocks, four menus in, unticked.
- 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.
-
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_schand 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 owndesign_block_io.cppsays a block needs "a schematic or board file", so board-only is legal, anddocs/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 readingFile '' 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
--offlineand reports http(s) links as excluded rather than as passing.nix run .#link-check-onlineis 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.mdquotes 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 inkaandka-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 initwrites a project that reacheskaover git and its dependencies over crates.io, so its firstcargo buildneeds a network the sandbox does not have — and it would have to start anix developof its own, which a Nix build cannot do.nix run .#scaffold-trialis 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 totemplates/, toka block init, to abuild.rsor 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 generatedmain.rsrefers to both happen to be present. What the sandbox does cover is the compiling half:templates/block-libraryis a real workspace member generating symbols and land patterns exactly as the written project does, sochecks.rustrefuses 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:
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:
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


