KiCad schematic layout
Drawing a schematic in code is easy. Drawing one that reads like a
professional's is not. The usual failure is a netlist-style sheet:
floating symbols, every pin a stub with a label stacked on it, rails
spelled out as text, a small drawing lost on a big page. It is electrically
correct and unreadable.
This skill gives you six things:
scripts/schlib.py — a builder. You place parts, draw wires, power
ports, labels and notes in sheet coordinates, and declare which functional
block each belongs to; it draws the outlines, arranges the blocks, and
spaces the drawing out for you. It emits native KiCad 10 files
(format 20260306, no "older version" prompt), including hierarchy, checks
the geometry as it goes, and after every write proves each sheet is exactly
what KiCad 10 itself would save (Design.check_native()). KiCad 9 cannot
open the output.
- A spacing engine that measures instead of guessing. Glyph advances come
from KiCad itself (
scripts/calibrate_text.py), so schlib knows the exact
width of every string it draws. It then bundles repeated power pins onto one
bar, stands rotated port names upright, pushes wire-connected groups apart
and slides labels along their own stubs until nothing crowds -- without
changing a single connection.
- A method in which correctness is never taken on trust: write the
netlist spec first, draw to match it, then let KiCad's own connectivity
engine prove the drawing matches, and KiCad's own render prove the spacing.
Where schlib has to reimplement a KiCad rule, a probe script proves the
copy is the same rule:
scripts/probe_diffpair.py checks the
differential-pair name matcher against pcbnew's DpCoupledNet() over
2317 names, scripts/probe_fields.py and scripts/probe_transforms.py
do the same for field rotation.
scripts/block_crops.py — one full-size picture per block for the
visual pass, because a whole sheet in one screenshot shows nothing.
scripts/subcircuits.py — proven blocks (regulator, USB-C, ESP32-S3,
boost, loop input...) that each carry their PARTS, NETS and drawing, so
the spec and the sheet come from one place.
- The drafting rules and file-format facts, learned the hard way on real
boards, in
references/.
Workflow
0. Don't clobber the user's work
Before you write over an existing .kicad_sch, check whether it was edited
by hand since it was last generated. Signs of that:
- a newer modification time than the generator script;
(generator "eeschema") in the file where the generator emits something else;
- a
.history/ folder with recent "SCH Save" entries.
If it has been edited, ask before regenerating, and back the file up.
schlib enforces this: Design.write() refuses to overwrite files whose
hash differs from what it last wrote, and backs them up to
.schgen-backup/. Port the user's edits into the generator, then pass
force=True.
1. Write the spec first
In the generator script, before any geometry:
PARTS = {"R1": ("Device:R", "10k", "Resistor_SMD:R_0402_1005Metric", {"LCSC": "C25744", ...}), ...}
NETS = {"GND": [("U1", "1"), ("C1", "2")], "+3V3": [...], "EN": [...], ...}
NO_CONNECT = [("U1", "4"), ...]
Take pin numbers from the actual symbol. Never guess them:
Library(project_dir).get("Device:LED").pins() lists number, name and
position. NETS is the source of truth. The drawing is an implementation
of it, and step 5 checks that it matches.
Power nets are named by their power symbol (+3V3, GND). If a rail
used to be a label such as VUSB_5V, it becomes +5V; tell the user,
because the PCB needs Update PCB from Schematic.
Name every differential pair <BASE>_P / <BASE>_N. KiCad stores no
list of pairs — it infers one from the two names, and it only accepts P,
N, + or -, upper case, at the end (trailing digits and underscores are
skipped). USB_DP/USB_DM is not a pair to KiCad, and neither is
TD1P/TD1M or USB_p/USB_n, however obvious it looks to a human. The
cost is silent: no differential-pair router, no length tuner, no skew or
coupling DRC, and no diff_pair_width — that netclass constraint is gated on
A.inDiffPair(), so both legs route at the ordinary track width at whatever
impedance that gives. lint_spec catches it (rule dp-name) and prints the
names to use. Fix it in the spec: renaming later means the schematic, the
netlist, the netclass patterns and any cable pinout already written down.
1b. Lint the spec before drawing
verify() later proves the drawing matches NETS; nothing proves NETS is
a sensible circuit. Run the design-rule lint on the spec first, while a fix
costs one line and no geometry:
from schlib import lint_spec
lint_spec(PARTS, NETS, NO_CONNECT, project_dir=project_dir) # or d.lint(PARTS, NETS, NO_CONNECT)
- Errors (fix before drawing): unknown refs, pin numbers the symbol does
not have (it lists the real ones), a pin in two nets or in a net and
NO_CONNECT, a power-input pin left open.
- Warnings: an IC rail with no capacitor to ground, a rail with no
= 10 uF bulk cap, SDA/SCL with no pull-up, a reset/enable input with no
pull-up and nothing driving it, a net of inputs only, a single-pin net (the
usual sign of a typo in a net name), pins in no net and not no-connect.
- Warnings, naming:
dp-name — two nets that are plainly a pair but
that KiCad will not couple, with the <BASE>_P / <BASE>_N replacements
spelled out.
- Notes: fewer small caps than ICs on a rail;
test_points=True adds
rails without a TP; dp-suffix — single-ended nets that KiCad reads as
half a pair (usually active-low ..._N signals), which couple silently the
day a net with the complement name appears.
Pin roles come from the symbol's electrical types, falling back to pin names
for LCSC/EasyEDA symbols whose pins are all "unspecified". It is a
heuristic: keep a warning you have a reason to keep, and say why in a sheet
note. The kicad review skill still does the real review of the finished
sheets.
2. Plan sheets and blocks before coordinates
- Past ~25 parts or ~3 functions, go hierarchical: one sheet per function
plus a root block diagram.
- Decide which nets cross sheets. Only signals get hierarchical labels
and sheet pins; rails are global power ports.
- On each sheet, lay out left→right signal flow in labelled blocks: input,
processing, output. Declare the blocks in that order —
arrange()
places them in declaration order, left to right and wrapping top to
bottom, which is how a reader scans the page.
Read references/layout-conventions.md now if you haven't this session.
It has the decision rules and the routing recipes for the hard cases.
3. Draw with schlib
Copy scripts/schlib.py into the user's project, next to their generator,
so the project stays self-contained and reproducible. Then:
from schlib import Design
d = Design(project_dir, "myboard", title="My Board", rev="A", company="...")
s = d.sheet("01-power.kicad_sch", "Power Input & 3.3 V", paper="auto",
comment="USB-C 5 V in, 3.3 V LDO")
s.place("U1", "Regulator_Linear:AMS1117-3.3", "AMS1117-3.3", 71.12, 50.8,
props={"Footprint": "...", "LCSC": "C6186"})
vi, vo = s.pin("U1", "VI"), s.pin("U1", "VO") # by number or unique name
s.wire(vi, (vi[0] - 12.7, vi[1])) # orthogonal polylines
s.power("+5V", vi[0] - 12.7, vi[1]) # rail = power port
s.stub("U1", "GND", 2.54, power="GND") # pin -> short wire -> port
s.stub("U2", 5, 2.54, label="SDA") # ... or -> net label
s.place("J1", "Connector_Generic:Conn_01x03", "IN", 25.4, 50.8, mirror="y")
s.place("U3", "Amplifier_Operational:LM358", "LM358", 132.08, 55.88, unit=1) # multi-unit
s.nc("U1", "4")
s.note("Vout = 0.6 x (1 + R1/R2) = 24.0 V", 20, 90, 1.0)
root = d.root_sheet(title="My Board") # hierarchical designs
d.link(s, 20.32, 55.88, 63.5, 40.64, pins=[("SDA", "right", 71.12)])
root.wire((83.82, 71.12), (111.76, 71.12)) # wires between sheet pins
s.arrange() # flow the blocks: left to right, top to bottom
d.write() # relieves crowding, checks, centres, writes, patches .kicad_pro
d.verify(NETS, NO_CONNECT) # KiCad netlist vs spec -- must match exactly
d.erc() # ERC by category
d.check_text() # KiCad's own render: collisions and crowding
d.render(outdir) # p1.svg... + schematic.pdf + view.html
Every part of a sheet belongs to a block. Wrap each function as you
draw it and never compute a rectangle by hand:
with s.block("3.3 V REGULATION"): # or block_start()/block_end()
s.place("U1", ...) # everything drawn in here is in it
s.note("100 nF at pin 8", ...)
- The outline is derived from the contents, so it is never too tight and
never clips a note.
BLOCK_PAD (5.08 mm) is the clearance inside it,
BLOCK_GUTTER (12.7 mm) the space between neighbours.
- Blocks nest. A big IC's core and each of its support groups are children
of one container block;
arrange() flows the children inside the parent,
then places the parent on the page. pad=None makes a container a layout
group with no outline of its own, width=0 stacks its children in one
column, and an explicit width= sets their wrap width.
arrange() also grows the paper (A4 → A3 → A2 → A1) until the sheet fits.
- A note that sits loose inside a container keeps its old coordinates
while the children move around it. Give it a block of its own
(
s.block_start(None, pad=None)) so it flows too.
box() still exists for a one-off rectangle that is not a block, but a
hand-placed outline is what produces overlapping boxes, clipped titles and
labels lying across a dashed line.
One block per circuit, not per sub-function. A boost converter is one
block -- load switch, input cap, controller, rectifier, output cap and
feedback divider together -- not "BOOST INPUT", "BOOST CONTROLLER" and
"24 V OUTPUT & FEEDBACK". A module is one block with its reset RC, boot
button, pull-ups, decoupling and strapping notes, not "MODULE" beside
"SUPPORT CIRCUITRY". The test: would this box get its own heading in the
part's reference design? Vendors' sheets for the same parts (Adafruit's
ESP32-S3 Feather: "POWER AND FILTERING", "USB TO SERIAL CONVERTER", "LIPO
CHARGING", the module and all that serves it in one region) run to five to
eight regions for a 50-part board. Splitting finer scatters one circuit's
wires across box lines, which is exactly where labels and outlines collide,
and it makes the reader reassemble the circuit. Twelve boxes on a 50-part
sheet was too many; the owner asked for them to be merged.
Nothing may touch an outline. check_text() reads KiCad's render and
reports every string and part body within OUTLINE_CLEAR (0.8 mm) of any
rectangle on the sheet -- block outlines and box() alike -- counted with
the collisions; write() warns about the same from the model. A sheet of
content-derived blocks passes by construction; a sheet of hand-typed
rectangles does not (about a hundred faults on the 50-part sheet this was
written for). A fault on a derived outline means the extent model missed
something: fix _bbox, don't nudge the part.
Spacing is computed, not typed
write() runs a relief pass over every sheet first (Sheet.relieve(), also
called by arrange()). You do not call these by hand; know what they do,
because they are why the output is not what your coordinates literally said:
| Pass |
What it does |
Why |
bundle_ports() |
joins a part's repeated power pins on one flank onto a single bar with one symbol |
twenty GND flags on a connector is twenty names fighting for a 2.54 mm pitch, and the twentieth tells the reader nothing |
upright_power_ports() |
stands a rotated port back up -- rails above the wire, grounds below -- and picks how far along the stub it sits |
a port on a horizontal stub is rotated to face the wire, which turns its name through 90 degrees; a column of those is an unreadable smear |
spread() |
pushes wire-connected groups apart to CLEAR_GROUP |
parts drawn at a hand-typed pitch crowd once their text is real |
| label / note / port relief |
slides a label or port further along its own stub, moves a note, slides an elbow |
this is also how a dense flank gets its stagger |
_nudge_crowded() |
moves a whole group one grid step when nothing else worked |
last resort, still connectivity-safe |
None of it can change a connection. Every move is checked against the
wires, pins and bodies first (_safe_wire, _move_is_safe), groups are the
pieces a wire holds together, and anything that only a label joins is free
to move. verify() proves it afterwards on every run.
Pass relieve=False to write() (or arrange()) if you want your
coordinates taken literally.
The full API is documented in docstrings: place, power, gnd, pin,
pin_dir, pins_of, wire, route, stub, label, hlabel, nc,
bus, bus_entry, bus_label, pins_to_bus, lint_spec/Design.lint,
note, block/block_start/block_end, arrange, box, check,
check_blocks, check_spacing, suggest_paper, center_on_page. A single
sheet with no root_sheet() becomes a flat design automatically.
Coordinates are millimetres with Y pointing down. Keep every pin, wire
end and label on the 1.27 mm grid (place parts on 2.54 mm). Don't bother
centring by hand: write() re-centres each drawing on its page.
3a. Start from a subcircuit when one fits
scripts/subcircuits.py has blocks the user's boards already use, built on
stock KiCad symbols, with LCSC numbers only where they come from a verified
order (blank otherwise -- fill them with the lcsc skill, never from memory):
| Block |
What it draws |
Ldo |
AP2112K-3.3 (EN tied or brought out) or AMS1117-3.3, in/out caps |
UsbCSink |
USB-C 2.0 receptacle, 5.1k on each CC, flip pairs joined, USBLC6 ESD, PWR_FLAGs. Pass dp="USB_D_P", dm="USB_D_N" so KiCad sees a differential pair; esd_vbus=False leaves the array's VBUS pin open for 2-layer flow-through layouts |
Esp32S3Core |
WROOM-1, 10 uF + 100 nF, EN RC + RESET, BOOT on IO0, native USB, GPIOs on labels, the rest NC |
Mt3608Boost |
5 V -> 24 V (Vout = 0.6 x (1 + Rtop/Rbot)), EN pull-up when EN is a GPIO, PWR_FLAG |
Ina226LoopSense |
4-20 mA terminal, TVS, low-side shunt, Kelvin RC filter, INA226 at 0x40 |
I2cPullups, LedIndicator |
one pair per bus; rail -> R -> LED -> GND |
from subcircuits import Refs, Spec, UsbCSink, Esp32S3Core
refs, spec = Refs(), Spec()
usb = spec.add(UsbCSink(refs, vbus="+5V"))
mcu = spec.add(Esp32S3Core(refs, gpio={"IO8": "I2C_SDA", "IO9": "I2C_SCL"}))
spec.lint(lib=d.lib) # step 1b, on the assembled spec
for blk in (usb, mcu):
blk.draw(s) # each in its own titled block, notes included
s.arrange(); d.write(); d.verify(dict(spec.NETS), spec.NO_CONNECT)
Each block draws into its own titled block and starts clear of whatever is
already on the sheet: relief runs before arrange(), and two blocks drawn
over each other look connected to it. Hand-drawn parts mix freely: add them
to spec.PARTS/spec.NETS and refs.reserve() their designators. Change a
part by editing block.parts[ref] before drawing. A new block is a Block
subclass -- parts and conn() in __init__, geometry in _draw() -- and it
is not done until verify(), erc() and check_text() are clean on a test
sheet. Copy subcircuits.py into the project next to schlib.py.
3b. Draw the conventions, don't reinvent them
s.rail_bank(refs, x, y, rail, place=...) draws a decoupling or bulk bank the
way a schematic draws one: the parts side by side between a rail bar and a
ground bar, one rail symbol and one ground symbol, each on its own drop. The
pitch comes from the parts' own text (part_pitch()), not from a number you
liked. flag=True adds the rail's PWR_FLAG on the far end of the bar;
rail_kind="label" names a local rail instead of a global one.
Repeating a rail port and a GND flag beside every capacitor is the single
most common way a generated sheet reads as a crowd.
3c. Buses
A bus is drawing, not connectivity: KiCad joins the member nets by their
labels whether or not the bus is there. It earns its place where eight or
sixteen parallel lines would otherwise be as many wires or loose labels -- a
data/address bus, a parallel display, a row of GPIOs to a header.
end, start = s.pins_to_bus("U1", range(20, 28), "D[0..7]") # stubs, member labels, entries, trunk, name
s.bus(end, (end[0], 120.65), (200.66, 120.65)) # carry the trunk on
s.bus_entry(x, y, 2.54, 2.54) # the primitives, when drawing by hand
s.bus_label("D[0..7]", x, y)
- Names:
D[0..7] (vector), {SDA SCL} (group), I2C{SDA SCL} (named
group: members are I2C.SDA, I2C.SCL). bus_members() expands them. The
member labels are what actually connect, so name those nets in NETS
exactly that way.
pins_to_bus takes a row of pins on one flank of one part; toward=
picks which way the trunk leaves (default down or right), and the tail is
long enough for the bus name.
- A bus run stays inside one block.
arrange() moves blocks
independently, so a trunk drawn between two blocks is cut when they move.
Between blocks, give each side its own labelled bus stub with the same
name -- KiCad joins same-named buses on a sheet as it joins labels.
check() errors on an entry that does not reach a bus, an entry with no
wire on its free end, and a wire ending on a bus without an entry.
- Pins on a top or bottom flank put their member labels side by side at the
2.54 mm pin pitch;
check_text() reports those pairs as 1.02 mm apart.
That is the pitch, not a layout fault.
4. Fix what write() reports
check() runs inside write(). It aborts on errors:
- off-grid points, which KiCad won't join;
- dangling wire ends;
- two blocks overlapping each other (
check_blocks);
- two part bodies drawn on top of each other (
check_spacing).
It also prints warnings, plus a crossing count:
- a pin landing mid-wire (KiCad connects it — usually an accidental short);
- a wire running through a part's body;
- a wire that leaves a pin back across its own part -- the fault that
draws a connection straight through a resistor;
- a note written over a part or over another note (
check_clearance);
- a drawing that reaches into the title block.
Aim for zero crossings. Every one is a place a reader has to stop and check
for a dot.
5. Verify connectivity against KiCad — non-negotiable
d.verify(NETS, NO_CONNECT) exports the netlist with kicad-cli and
compares nets by their pin sets. Hand-authored coordinates fail
silently: one mistyped number can short two nets or orphan a pin, and the
drawing still looks fine. A rename (for example EN -> Net-(U1-EN)) is
only informational; add a label if the name matters. Any mismatch is a
bug — fix the geometry, not the spec.
6. ERC
Target 0 errors:
power_pin_not_driven → add a PWR_FLAG where that rail enters the
board.
pin_to_pin warnings on LCSC/EasyEDA symbols are expected, because their
pins are typed "unspecified".
lib_symbol_mismatch is expected for generated files.
7. Render it and look — then iterate
A netlist match says nothing about appearance, so look at every sheet.
Render: d.render(<scratch dir>).
Serve that directory in the background:
python -m http.server 8731 --bind 127.0.0.1 --directory <dir>.
The Browser pane blocks file://.
Open http://127.0.0.1:8731/view.html for the page list.
view.html?f=p1.svg fits a whole page.
view.html?f=p1.svg&box=120,90,60,34 zooms to a region given in
sheet millimetres (x, y, width, height). That's the same coordinate
system as your generator and the part's (at x y), so you can aim
straight at a component. Keep the box roughly 16:9.
If a screenshot times out, retry it on its own. If screenshots keep failing
or look stale, the pane may not be painting. Don't guess: rely on
check_text() (below), and use the browser's JavaScript tool to query
element positions.
Look at every block at full size:
python scripts/block_crops.py <root.kicad_sch> <dir> --serve 8760 writes
one cropped SVG per block (p1_b0.svg, ...) and an index.html. A static
crop paints at once where the pan/zoom viewer times out, and it is cut from
the rectangle KiCad saved, so it cannot be aimed wrong. A whole A2 page in
one screenshot only proves the blocks are in the right places.
Use a port nobody else is on. A server left over from an earlier
session keeps answering on 8731 from its old folder, and you review last
week's drawing with no error anywhere; the SVG's <title> carries the
render time -- check it when something looks familiar.
Before the visual pass, run d.check_text(). It renders every sheet and
reads KiCad's own SVG, where every string is an element with an exact
position and length. This is the authority on spacing -- not the model, not
your reading of the code -- and it reports two things:
- collisions: text drawn sideways, on a wire, or over a part body -- and
any string or part body touching a block outline;
- crowding: any two strings closer than the clearance, with the pair
named and the gap measured. Strings belonging to one library symbol (a pin
name against its own pin number) are excluded: that spacing is the
symbol's, not the layout's.
The two clearances are deliberately different, and the difference matters:
|
clearance |
why |
side by side (CLEAR_ROW) |
1.27 mm |
one string ending where the next begins reads as a single word |
stacked (CLEAR_STACK) |
0.85 mm |
a column of labels on the 2.54 mm pin grid leaves 1.0 mm and is exactly how a schematic is supposed to look |
Target zero. A real 200-part, 7-sheet design finishes at 2 collisions and 6
pairs within 0.6 mm of target, and every one of those is named.
- Reference and value placement is automatic: a collision-aware placer tries
the conventional spot first, then the alternatives.
- If a part is boxed in by wires on every side and even the best spot
collides, move the wiring to open a gap.
Check at page scale (does it fill the page? do blocks read left→right?) and
zoomed in:
- text overlapping parts or wires;
- labels crowding each other;
- ground symbols pointing up;
- wires hugging bodies.
Fix the problems, then regenerate, verify and render again. Expect two or
three passes. Stop the server when you're done.
8. Deliver
UUIDs are deterministic, so regenerating never breaks PCB footprint links.
Drafting rules in brief
Details and the reasons behind each rule are in references/layout-conventions.md.
- Signals flow left → right. Supplies enter at the top and grounds leave
at the bottom.
- Never type a spacing number you could measure.
text_width() knows how
wide a string is, part_pitch() how far apart two parts have to stand,
and the relief pass fixes what is left. A pitch that "looked right" at
design time is the reason generated sheets read as crowded.
- Every rail is a power port, never a text label. Give each supply pin
its own port at the end of a short stub, rather than running wires to one
shared symbol.
- Wire local connections that take one or two segments. Use a label
where a wire would cross another net, run through a body or detour.
- A label always sits at the end of a ≥ 2.54 mm stub, never on the pin.
This is what cures the "stacked labels" look.
- Where pin order interleaves nets (A-B-A-B), a crossing is unavoidable,
so label those nets instead.
- When adjacent pins must pass each other's rows, one turns early and the
other ends in a port or label.
- Orient parts so their pins face what they connect to: mirror an
input-side connector, and rotate an IC whose inputs are on the wrong side.
- Decoupling caps hang below their rail or sit in a cap bank, with a
placement note.
- Put a no-connect flag on every unused pin, and a
PWR_FLAG where each
rail enters the board.
- Put design maths and constraints on the sheet as notes: divider values,
shunt sizing, strapping-pin rules, "place near pin X".
- Group functional blocks in dashed outlines with bold titles, declared in
signal order and arranged left→right, top→bottom. One block per
circuit (what a reference design would give its own heading), notes
inside the block they explain. Let
arrange() place them and never type
a rectangle: a hand-placed outline is how boxes end up overlapping and
how labels end up lying across the dashed line.
- Size the paper to the content (
paper="auto", fill 70–100%). Fill in
the title block on every sheet.
- Differential pairs are
<BASE>_P / <BASE>_N, never DP/DM, P/M
or lower case — that is the only spelling KiCad recognises, and every
pair-aware tool and the impedance netclass depend on it.
Geometry facts you will need
The full file-format notes are in references/kicad-format.md.
- A library pin's
(at …) is its tip, the connection point. The symbol's
local frame is Y-up; the sheet is Y-down.
- Rotation moves local +Y (a resistor's pin 1) as follows:
0 → up, 90 → left, 180 → down, 270 → right. A two-pin part's rotation
therefore decides which pin number faces which way.
- Mirroring: KiCad rotates first, then mirrors in sheet space.
mirror="x"
flips top↔bottom and mirror="y" flips left↔right. This was verified for
all 12 combinations by scripts/probe_transforms.py; rerun the probe after
a KiCad major upgrade.
- Connectivity rules:
- Wires that cross without a junction are not connected.
- A pin touching a wire's middle is connected.
- Overlapping collinear wires merge nets.
Gotchas that cost hours
Each is explained in references/troubleshooting.md.
- Invisible wires in SVG/PDF, even though the netlist is fine: the
.kicad_pro netclass is missing wire_width. write() patches it. The
stroke type is irrelevant; this was tested.
- Embedded symbols must be named with the full lib_id (
"Device:R"), and
derived (extends …) symbols must be flattened. schlib does both.
- A sub-sheet instance path is
/<root uuid>/<uuid of the sheet symbol on the root>, not the sub-sheet file's own UUID.
- Symbols from easyeda2kicad keep their pins in sub-unit
_0_1; stock
symbols keep them in _1_1.
- Field text angle and justification are relative to the part's rotation,
and KiCad does not rescue you the way it does in the editor.
- At 90° or 270°, text at angle 0 draws sideways. Cancelling the rotation
(angle =
-rotation) fixes those two: KiCad then normalises the result
to read bottom-to-top.
- At 180° that same formula gives angle 180, which KiCad draws literally
— upside down. 180 takes angle 0 instead, and because justification
is read in the symbol's frame, left and right swap with it. A
mirror="y"
swaps them too, so the two together cancel.
- schlib handles all of it; all 24 rotation × mirror cases are verified by
scripts/probe_fields.py, and check_text() now fails on upside-down
text as well as sideways text, so a regression here cannot pass silently.
- Decide whether a two-pin part is vertical from its pin positions, not
from its body's shape.
- A power port at the end of a horizontal stub is rotated to face the
wire, which turns its name through 90°. A name is longer than a 2.54 mm
pin pitch, so on a dense IC flank the rotated names overlap into an
unreadable smear. Use horizontal net labels on the flank (the rail keeps
its identity from the power port on its decoupling cap), and bundle the
ground pins onto one bar with a single GND symbol clear of the label lane.
- A net name ending in
N is half a differential pair as far as KiCad is
concerned. GPS_RF_IN sent it looking for GPS_RF_IP; RESET_N will
couple to RESET_P the moment such a net exists. For active-low signals
prefer nRESET or ~{RESET} over RESET_N, and check the dp-suffix
note before shipping.
- Take the title-block warning seriously until the render proves otherwise.
Notes drift under the title block easily.
- Measure; don't reason. When a result looks contradictory (a rotation
"works" for one part and not another), build a tiny probe schematic and ask
KiCad. That is how the rotation table was settled.
Worked examples
references/example_minimal.py: one A5 sheet using stock libraries
only, so it runs in any empty folder. It shows the core idioms: a mirrored
connector, derived symbols, a multi-unit op-amp including its power unit,
stub(), rails with caps, a feedback network and PWR_FLAGs. Start here
for small designs.
scripts/subcircuits.py: read the blocks themselves for real-world
patterns:
- the USB-C flip pairing done with labels;
- a reset RC with button;
- staggered I²C pull-ups;
- a boost converter whose interleaved pins use labelled stubs;
- a Kelvin-sensed shunt.
- A complete generated board built with this skill is public:
USBC_M2E_HaLow_Adapter
(
generate_schematic.py, 30 parts, fabricated).
Related skills
Use the lcsc / bom skills to source parts. Use easyeda2kicad to
fetch LCSC symbols and footprints into a project library, then resolve them
through the project's sym-lib-table. schlib reads that table
automatically, as well as the global one and KiCad's stock libraries. Use
the kicad skill for an electrical design review once the sheets are
drawn, and the kicad-pcb-placement skill to lay the board out afterwards
— it consumes the netlist this skill verifies.
1---2name: kicad-schematic-layout3description: Generate or redraw KiCad schematics (.kicad_sch) from Python so they look professionally drafted — hierarchical sheets, power-port symbols, real wires and junctions, buses, titled functional blocks (one per circuit, outlines derived from their contents so nothing lies across a box line), on-sheet design notes, ready-made subcircuits (LDO, USB-C sink, ESP32-S3 core, MT3608 boost, INA226 4-20 mA input, I2C pull-ups, LED) — lint the spec against design rules before drawing, and prove the result against KiCad's own netlist. Use this whenever the user wants a KiCad schematic created from a circuit description, BOM or netlist; wants an existing generated or netlist-style schematic made readable ("fix the stacked labels", "clean up", "relayout", "make it look professional", "split into sheets", "too many group boxes", "text is on the box lines"); or wants code that writes .kicad_sch files — even if they never say "generate". Not for PCB layout or for judging whether a circuit is electrically sound (the kicad review 4---56# KiCad schematic layout78Drawing a schematic in code is easy. Drawing one that reads like a9professional's is not. The usual failure is a *netlist-style* sheet:10floating symbols, every pin a stub with a label stacked on it, rails11spelled out as text, a small drawing lost on a big page. It is electrically12correct and unreadable.1314This skill gives you six things:1516- **`scripts/schlib.py`** — a builder. You place parts, draw wires, power17 ports, labels and notes in sheet coordinates, and declare which functional18 block each belongs to; it draws the outlines, arranges the blocks, and19 **spaces the drawing out for you**. It emits **native KiCad 10 files**20 (format `20260306`, no "older version" prompt), including hierarchy, checks21 the geometry as it goes, and after every write proves each sheet is exactly22 what KiCad 10 itself would save (`Design.check_native()`). KiCad 9 cannot23 open the output.24- **A spacing engine that measures instead of guessing.** Glyph advances come25 from KiCad itself (`scripts/calibrate_text.py`), so schlib knows the exact26 width of every string it draws. It then bundles repeated power pins onto one27 bar, stands rotated port names upright, pushes wire-connected groups apart28 and slides labels along their own stubs until nothing crowds -- without29 changing a single connection.30- **A method** in which correctness is never taken on trust: write the31 netlist spec first, draw to match it, then let KiCad's own connectivity32 engine prove the drawing matches, and KiCad's own render prove the spacing.33 Where schlib has to reimplement a KiCad rule, a probe script proves the34 copy is the same rule: `scripts/probe_diffpair.py` checks the35 differential-pair name matcher against pcbnew's `DpCoupledNet()` over36 2317 names, `scripts/probe_fields.py` and `scripts/probe_transforms.py`37 do the same for field rotation.38- **`scripts/block_crops.py`** — one full-size picture per block for the39 visual pass, because a whole sheet in one screenshot shows nothing.40- **`scripts/subcircuits.py`** — proven blocks (regulator, USB-C, ESP32-S3,41 boost, loop input...) that each carry their PARTS, NETS and drawing, so42 the spec and the sheet come from one place.43- **The drafting rules and file-format facts**, learned the hard way on real44 boards, in `references/`.4546## Workflow4748### 0. Don't clobber the user's work49Before you write over an existing `.kicad_sch`, check whether it was edited50by hand since it was last generated. Signs of that:51- a newer modification time than the generator script;52- `(generator "eeschema")` in the file where the generator emits something else;53- a `.history/` folder with recent "SCH Save" entries.5455If it has been edited, **ask** before regenerating, and back the file up.56schlib enforces this: `Design.write()` refuses to overwrite files whose57hash differs from what it last wrote, and backs them up to58`.schgen-backup/`. Port the user's edits into the generator, then pass59`force=True`.6061### 1. Write the spec first62In the generator script, before any geometry:6364```python65PARTS = {"R1": ("Device:R", "10k", "Resistor_SMD:R_0402_1005Metric", {"LCSC": "C25744", ...}), ...}66NETS = {"GND": [("U1", "1"), ("C1", "2")], "+3V3": [...], "EN": [...], ...}67NO_CONNECT = [("U1", "4"), ...]68```6970Take pin numbers from the actual symbol. Never guess them:71`Library(project_dir).get("Device:LED").pins()` lists number, name and72position. `NETS` is the source of truth. The drawing is an *implementation*73of it, and step 5 checks that it matches.7475Power nets are named by their power symbol (`+3V3`, `GND`). If a rail76used to be a label such as `VUSB_5V`, it becomes `+5V`; tell the user,77because the PCB needs *Update PCB from Schematic*.7879**Name every differential pair `<BASE>_P` / `<BASE>_N`.** KiCad stores no80list of pairs — it infers one from the two *names*, and it only accepts `P`,81`N`, `+` or `-`, upper case, at the end (trailing digits and underscores are82skipped). `USB_DP`/`USB_DM` is **not** a pair to KiCad, and neither is83`TD1P`/`TD1M` or `USB_p`/`USB_n`, however obvious it looks to a human. The84cost is silent: no differential-pair router, no length tuner, no skew or85coupling DRC, and no `diff_pair_width` — that netclass constraint is gated on86`A.inDiffPair()`, so both legs route at the ordinary track width at whatever87impedance that gives. `lint_spec` catches it (rule `dp-name`) and prints the88names to use. Fix it in the spec: renaming later means the schematic, the89netlist, the netclass patterns and any cable pinout already written down.9091### 1b. Lint the spec before drawing92`verify()` later proves the drawing matches `NETS`; nothing proves `NETS` is93a sensible circuit. Run the design-rule lint on the spec first, while a fix94costs one line and no geometry:9596```python97from schlib import lint_spec98lint_spec(PARTS, NETS, NO_CONNECT, project_dir=project_dir) # or d.lint(PARTS, NETS, NO_CONNECT)99```100101- **Errors** (fix before drawing): unknown refs, pin numbers the symbol does102 not have (it lists the real ones), a pin in two nets or in a net and103 `NO_CONNECT`, a power-input pin left open.104- **Warnings**: an IC rail with no capacitor to ground, a rail with no105 >= 10 uF bulk cap, SDA/SCL with no pull-up, a reset/enable input with no106 pull-up and nothing driving it, a net of inputs only, a single-pin net (the107 usual sign of a typo in a net name), pins in no net and not no-connect.108- **Warnings, naming**: `dp-name` — two nets that are plainly a pair but109 that KiCad will not couple, with the `<BASE>_P` / `<BASE>_N` replacements110 spelled out.111- **Notes**: fewer small caps than ICs on a rail; `test_points=True` adds112 rails without a TP; `dp-suffix` — single-ended nets that KiCad reads as113 half a pair (usually active-low `..._N` signals), which couple silently the114 day a net with the complement name appears.115116Pin roles come from the symbol's electrical types, falling back to pin names117for LCSC/EasyEDA symbols whose pins are all "unspecified". It is a118heuristic: keep a warning you have a reason to keep, and say why in a sheet119note. The **kicad** review skill still does the real review of the finished120sheets.121122### 2. Plan sheets and blocks before coordinates123- Past ~25 parts or ~3 functions, go hierarchical: one sheet per function124 plus a root block diagram.125- Decide which nets cross sheets. Only **signals** get hierarchical labels126 and sheet pins; rails are global power ports.127- On each sheet, lay out left→right signal flow in labelled blocks: input,128 processing, output. **Declare the blocks in that order** — `arrange()`129 places them in declaration order, left to right and wrapping top to130 bottom, which is how a reader scans the page.131132Read **`references/layout-conventions.md`** now if you haven't this session.133It has the decision rules and the routing recipes for the hard cases.134135### 3. Draw with schlib136Copy `scripts/schlib.py` into the user's project, next to their generator,137so the project stays self-contained and reproducible. Then:138139```python140from schlib import Design141d = Design(project_dir, "myboard", title="My Board", rev="A", company="...")142143s = d.sheet("01-power.kicad_sch", "Power Input & 3.3 V", paper="auto",144 comment="USB-C 5 V in, 3.3 V LDO")145s.place("U1", "Regulator_Linear:AMS1117-3.3", "AMS1117-3.3", 71.12, 50.8,146 props={"Footprint": "...", "LCSC": "C6186"})147vi, vo = s.pin("U1", "VI"), s.pin("U1", "VO") # by number or unique name148s.wire(vi, (vi[0] - 12.7, vi[1])) # orthogonal polylines149s.power("+5V", vi[0] - 12.7, vi[1]) # rail = power port150s.stub("U1", "GND", 2.54, power="GND") # pin -> short wire -> port151s.stub("U2", 5, 2.54, label="SDA") # ... or -> net label152s.place("J1", "Connector_Generic:Conn_01x03", "IN", 25.4, 50.8, mirror="y")153s.place("U3", "Amplifier_Operational:LM358", "LM358", 132.08, 55.88, unit=1) # multi-unit154s.nc("U1", "4")155s.note("Vout = 0.6 x (1 + R1/R2) = 24.0 V", 20, 90, 1.0)156157root = d.root_sheet(title="My Board") # hierarchical designs158d.link(s, 20.32, 55.88, 63.5, 40.64, pins=[("SDA", "right", 71.12)])159root.wire((83.82, 71.12), (111.76, 71.12)) # wires between sheet pins160161s.arrange() # flow the blocks: left to right, top to bottom162d.write() # relieves crowding, checks, centres, writes, patches .kicad_pro163d.verify(NETS, NO_CONNECT) # KiCad netlist vs spec -- must match exactly164d.erc() # ERC by category165d.check_text() # KiCad's own render: collisions and crowding166d.render(outdir) # p1.svg... + schematic.pdf + view.html167```168169**Every part of a sheet belongs to a block.** Wrap each function as you170draw it and never compute a rectangle by hand:171172```python173with s.block("3.3 V REGULATION"): # or block_start()/block_end()174 s.place("U1", ...) # everything drawn in here is in it175 s.note("100 nF at pin 8", ...)176```177178- The outline is derived from the contents, so it is never too tight and179 never clips a note. `BLOCK_PAD` (5.08 mm) is the clearance inside it,180 `BLOCK_GUTTER` (12.7 mm) the space between neighbours.181- Blocks nest. A big IC's core and each of its support groups are children182 of one container block; `arrange()` flows the children inside the parent,183 then places the parent on the page. `pad=None` makes a container a layout184 group with no outline of its own, `width=0` stacks its children in one185 column, and an explicit `width=` sets their wrap width.186- `arrange()` also grows the paper (A4 → A3 → A2 → A1) until the sheet fits.187- A note that sits loose inside a *container* keeps its old coordinates188 while the children move around it. Give it a block of its own189 (`s.block_start(None, pad=None)`) so it flows too.190191`box()` still exists for a one-off rectangle that is not a block, but a192hand-placed outline is what produces overlapping boxes, clipped titles and193labels lying across a dashed line.194195**One block per circuit, not per sub-function.** A boost converter is one196block -- load switch, input cap, controller, rectifier, output cap and197feedback divider together -- not "BOOST INPUT", "BOOST CONTROLLER" and198"24 V OUTPUT & FEEDBACK". A module is one block with its reset RC, boot199button, pull-ups, decoupling and strapping notes, not "MODULE" beside200"SUPPORT CIRCUITRY". The test: *would this box get its own heading in the201part's reference design?* Vendors' sheets for the same parts (Adafruit's202ESP32-S3 Feather: "POWER AND FILTERING", "USB TO SERIAL CONVERTER", "LIPO203CHARGING", the module and all that serves it in one region) run to five to204eight regions for a 50-part board. Splitting finer scatters one circuit's205wires across box lines, which is exactly where labels and outlines collide,206and it makes the reader reassemble the circuit. Twelve boxes on a 50-part207sheet was too many; the owner asked for them to be merged.208209**Nothing may touch an outline.** `check_text()` reads KiCad's render and210reports every string and part body within `OUTLINE_CLEAR` (0.8 mm) of any211rectangle on the sheet -- block outlines and `box()` alike -- counted with212the collisions; `write()` warns about the same from the model. A sheet of213content-derived blocks passes by construction; a sheet of hand-typed214rectangles does not (about a hundred faults on the 50-part sheet this was215written for). A fault on a derived outline means the extent model missed216something: fix `_bbox`, don't nudge the part.217218### Spacing is computed, not typed219220`write()` runs a relief pass over every sheet first (`Sheet.relieve()`, also221called by `arrange()`). You do not call these by hand; know what they do,222because they are why the output is not what your coordinates literally said:223224| Pass | What it does | Why |225|---|---|---|226| `bundle_ports()` | joins a part's repeated power pins on one flank onto a single bar with one symbol | twenty GND flags on a connector is twenty names fighting for a 2.54 mm pitch, and the twentieth tells the reader nothing |227| `upright_power_ports()` | stands a rotated port back up -- rails above the wire, grounds below -- and picks how far along the stub it sits | a port on a horizontal stub is rotated to face the wire, which turns its name through 90 degrees; a column of those is an unreadable smear |228| `spread()` | pushes wire-connected groups apart to `CLEAR_GROUP` | parts drawn at a hand-typed pitch crowd once their text is real |229| label / note / port relief | slides a label or port further along its own stub, moves a note, slides an elbow | this is also how a dense flank gets its stagger |230| `_nudge_crowded()` | moves a whole group one grid step when nothing else worked | last resort, still connectivity-safe |231232**None of it can change a connection.** Every move is checked against the233wires, pins and bodies first (`_safe_wire`, `_move_is_safe`), groups are the234pieces a *wire* holds together, and anything that only a label joins is free235to move. `verify()` proves it afterwards on every run.236237Pass `relieve=False` to `write()` (or `arrange()`) if you want your238coordinates taken literally.239240The full API is documented in docstrings: `place`, `power`, `gnd`, `pin`,241`pin_dir`, `pins_of`, `wire`, `route`, `stub`, `label`, `hlabel`, `nc`,242`bus`, `bus_entry`, `bus_label`, `pins_to_bus`, `lint_spec`/`Design.lint`,243`note`, `block`/`block_start`/`block_end`, `arrange`, `box`, `check`,244`check_blocks`, `check_spacing`, `suggest_paper`, `center_on_page`. A single245sheet with no `root_sheet()` becomes a flat design automatically.246247Coordinates are millimetres with Y pointing down. Keep **every** pin, wire248end and label on the **1.27 mm grid** (place parts on 2.54 mm). Don't bother249centring by hand: `write()` re-centres each drawing on its page.250251### 3a. Start from a subcircuit when one fits252`scripts/subcircuits.py` has blocks the user's boards already use, built on253stock KiCad symbols, with LCSC numbers only where they come from a verified254order (blank otherwise -- fill them with the lcsc skill, never from memory):255256| Block | What it draws |257|---|---|258| `Ldo` | AP2112K-3.3 (EN tied or brought out) or AMS1117-3.3, in/out caps |259| `UsbCSink` | USB-C 2.0 receptacle, 5.1k on each CC, flip pairs joined, USBLC6 ESD, PWR_FLAGs. Pass `dp="USB_D_P", dm="USB_D_N"` so KiCad sees a differential pair; `esd_vbus=False` leaves the array's VBUS pin open for 2-layer flow-through layouts |260| `Esp32S3Core` | WROOM-1, 10 uF + 100 nF, EN RC + RESET, BOOT on IO0, native USB, GPIOs on labels, the rest NC |261| `Mt3608Boost` | 5 V -> 24 V (Vout = 0.6 x (1 + Rtop/Rbot)), EN pull-up when EN is a GPIO, PWR_FLAG |262| `Ina226LoopSense` | 4-20 mA terminal, TVS, low-side shunt, Kelvin RC filter, INA226 at 0x40 |263| `I2cPullups`, `LedIndicator` | one pair per bus; rail -> R -> LED -> GND |264265```python266from subcircuits import Refs, Spec, UsbCSink, Esp32S3Core267refs, spec = Refs(), Spec()268usb = spec.add(UsbCSink(refs, vbus="+5V"))269mcu = spec.add(Esp32S3Core(refs, gpio={"IO8": "I2C_SDA", "IO9": "I2C_SCL"}))270spec.lint(lib=d.lib) # step 1b, on the assembled spec271for blk in (usb, mcu):272 blk.draw(s) # each in its own titled block, notes included273s.arrange(); d.write(); d.verify(dict(spec.NETS), spec.NO_CONNECT)274```275276Each block draws into its own titled block and starts clear of whatever is277already on the sheet: relief runs before `arrange()`, and two blocks drawn278over each other look connected to it. Hand-drawn parts mix freely: add them279to `spec.PARTS`/`spec.NETS` and `refs.reserve()` their designators. Change a280part by editing `block.parts[ref]` before drawing. A new block is a `Block`281subclass -- parts and `conn()` in `__init__`, geometry in `_draw()` -- and it282is not done until `verify()`, `erc()` and `check_text()` are clean on a test283sheet. Copy `subcircuits.py` into the project next to `schlib.py`.284285### 3b. Draw the conventions, don't reinvent them286`s.rail_bank(refs, x, y, rail, place=...)` draws a decoupling or bulk bank the287way a schematic draws one: the parts side by side between a rail bar and a288ground bar, one rail symbol and one ground symbol, each on its own drop. The289pitch comes from the parts' own text (`part_pitch()`), not from a number you290liked. `flag=True` adds the rail's PWR_FLAG on the far end of the bar;291`rail_kind="label"` names a local rail instead of a global one.292293Repeating a rail port and a GND flag beside every capacitor is the single294most common way a generated sheet reads as a crowd.295296### 3c. Buses297A bus is drawing, not connectivity: KiCad joins the member nets by their298labels whether or not the bus is there. It earns its place where eight or299sixteen parallel lines would otherwise be as many wires or loose labels -- a300data/address bus, a parallel display, a row of GPIOs to a header.301302```python303end, start = s.pins_to_bus("U1", range(20, 28), "D[0..7]") # stubs, member labels, entries, trunk, name304s.bus(end, (end[0], 120.65), (200.66, 120.65)) # carry the trunk on305s.bus_entry(x, y, 2.54, 2.54) # the primitives, when drawing by hand306s.bus_label("D[0..7]", x, y)307```308309- Names: `D[0..7]` (vector), `{SDA SCL}` (group), `I2C{SDA SCL}` (named310 group: members are `I2C.SDA`, `I2C.SCL`). `bus_members()` expands them. The311 member labels are what actually connect, so name those nets in `NETS`312 exactly that way.313- `pins_to_bus` takes a row of pins on one flank of one part; `toward=`314 picks which way the trunk leaves (default down or right), and the tail is315 long enough for the bus name.316- **A bus run stays inside one block.** `arrange()` moves blocks317 independently, so a trunk drawn between two blocks is cut when they move.318 Between blocks, give each side its own labelled bus stub with the same319 name -- KiCad joins same-named buses on a sheet as it joins labels.320- `check()` errors on an entry that does not reach a bus, an entry with no321 wire on its free end, and a wire ending on a bus without an entry.322- Pins on a top or bottom flank put their member labels side by side at the323 2.54 mm pin pitch; `check_text()` reports those pairs as 1.02 mm apart.324 That is the pitch, not a layout fault.325326### 4. Fix what `write()` reports327`check()` runs inside `write()`. It aborts on **errors**:328- off-grid points, which KiCad won't join;329- dangling wire ends;330- two blocks overlapping each other (`check_blocks`);331- two part bodies drawn on top of each other (`check_spacing`).332333It also prints **warnings**, plus a crossing count:334- a pin landing mid-wire (KiCad connects it — usually an accidental short);335- a wire running through a part's body;336- **a wire that leaves a pin back across its own part** -- the fault that337 draws a connection straight through a resistor;338- a note written over a part or over another note (`check_clearance`);339- a drawing that reaches into the title block.340341Aim for zero crossings. Every one is a place a reader has to stop and check342for a dot.343344### 5. Verify connectivity against KiCad — non-negotiable345`d.verify(NETS, NO_CONNECT)` exports the netlist with `kicad-cli` and346compares nets **by their pin sets**. Hand-authored coordinates fail347silently: one mistyped number can short two nets or orphan a pin, and the348drawing still looks fine. A *rename* (for example `EN -> Net-(U1-EN)`) is349only informational; add a label if the name matters. Any *mismatch* is a350bug — fix the geometry, not the spec.351352### 6. ERC353Target **0 errors**:354- `power_pin_not_driven` → add a `PWR_FLAG` where that rail enters the355 board.356- `pin_to_pin` warnings on LCSC/EasyEDA symbols are expected, because their357 pins are typed "unspecified".358- `lib_symbol_mismatch` is expected for generated files.359360### 7. Render it and look — then iterate361A netlist match says nothing about appearance, so look at every sheet.3623631. Render: `d.render(<scratch dir>)`.3642. Serve that directory in the background:365 `python -m http.server 8731 --bind 127.0.0.1 --directory <dir>`.366 The Browser pane blocks `file://`.3673. Open `http://127.0.0.1:8731/view.html` for the page list.368 - `view.html?f=p1.svg` fits a whole page.369 - `view.html?f=p1.svg&box=120,90,60,34` zooms to a region given in370 **sheet millimetres** (x, y, width, height). That's the same coordinate371 system as your generator and the part's `(at x y)`, so you can aim372 straight at a component. Keep the box roughly 16:9.373374 If a screenshot times out, retry it on its own. If screenshots keep failing375 or look stale, the pane may not be painting. Don't guess: rely on376 `check_text()` (below), and use the browser's JavaScript tool to query377 element positions.3783794. **Look at every block at full size**:380 `python scripts/block_crops.py <root.kicad_sch> <dir> --serve 8760` writes381 one cropped SVG per block (`p1_b0.svg`, ...) and an `index.html`. A static382 crop paints at once where the pan/zoom viewer times out, and it is cut from383 the rectangle KiCad saved, so it cannot be aimed wrong. A whole A2 page in384 one screenshot only proves the blocks are in the right places.385 **Use a port nobody else is on.** A server left over from an earlier386 session keeps answering on 8731 from its old folder, and you review last387 week's drawing with no error anywhere; the SVG's `<title>` carries the388 render time -- check it when something looks familiar.389390Before the visual pass, run **`d.check_text()`**. It renders every sheet and391reads KiCad's own SVG, where every string is an element with an exact392position and length. This is the authority on spacing -- not the model, not393your reading of the code -- and it reports two things:394395- **collisions**: text drawn sideways, on a wire, or over a part body -- and396 any string or part body touching a block outline;397- **crowding**: any two strings closer than the clearance, with the pair398 named and the gap measured. Strings belonging to one library symbol (a pin399 name against its own pin number) are excluded: that spacing is the400 symbol's, not the layout's.401402The two clearances are deliberately different, and the difference matters:403404| | clearance | why |405|---|---|---|406| side by side (`CLEAR_ROW`) | 1.27 mm | one string ending where the next begins reads as a single word |407| stacked (`CLEAR_STACK`) | 0.85 mm | a column of labels on the 2.54 mm pin grid leaves 1.0 mm and is exactly how a schematic is supposed to look |408409Target zero. A real 200-part, 7-sheet design finishes at **2 collisions and 6410pairs within 0.6 mm of target**, and every one of those is named.411- Reference and value placement is automatic: a collision-aware placer tries412 the conventional spot first, then the alternatives.413- If a part is boxed in by wires on every side and even the best spot414 collides, move the wiring to open a gap.415416Check at page scale (does it fill the page? do blocks read left→right?) and417zoomed in:418- text overlapping parts or wires;419- labels crowding each other;420- ground symbols pointing up;421- wires hugging bodies.422423Fix the problems, then regenerate, verify and render again. Expect two or424three passes. Stop the server when you're done.425426### 8. Deliver427- Export a PDF (`render()` writes `schematic.pdf`) and send it to the user.428- Leave the generator plus `schlib.py` in the project, and document how to429 regenerate:430 ```431 python generate_schematic.py432 ```433 Note that the `.kicad_sch` files are generated, so edits belong in the434 script.435- Mention any net renames, and remind the user to run *Update PCB from436 Schematic*.437438UUIDs are deterministic, so regenerating never breaks PCB footprint links.439440## Drafting rules in brief441Details and the reasons behind each rule are in `references/layout-conventions.md`.4424431. Signals flow left → right. Supplies enter at the top and grounds leave444 at the bottom.4450. Never type a spacing number you could measure. `text_width()` knows how446 wide a string is, `part_pitch()` how far apart two parts have to stand,447 and the relief pass fixes what is left. A pitch that "looked right" at448 design time is the reason generated sheets read as crowded.4492. Every rail is a **power port**, never a text label. Give each supply pin450 its own port at the end of a short stub, rather than running wires to one451 shared symbol.4523. **Wire** local connections that take one or two segments. Use a **label**453 where a wire would cross another net, run through a body or detour.4544. A label always sits at the end of a ≥ 2.54 mm stub, never on the pin.455 This is what cures the "stacked labels" look.4565. Where pin order interleaves nets (A-B-A-B), a crossing is unavoidable,457 so label those nets instead.4586. When adjacent pins must pass each other's rows, one turns early and the459 other ends in a port or label.4607. Orient parts so their pins face what they connect to: mirror an461 input-side connector, and rotate an IC whose inputs are on the wrong side.4628. Decoupling caps hang below their rail or sit in a cap bank, with a463 placement note.4649. Put a no-connect flag on every unused pin, and a `PWR_FLAG` where each465 rail enters the board.46610. Put design maths and constraints on the sheet as notes: divider values,467 shunt sizing, strapping-pin rules, "place near pin X".46811. Group functional blocks in dashed outlines with bold titles, declared in469 signal order and arranged left→right, top→bottom. **One block per470 circuit** (what a reference design would give its own heading), notes471 inside the block they explain. Let `arrange()` place them and never type472 a rectangle: a hand-placed outline is how boxes end up overlapping and473 how labels end up lying across the dashed line.47412. Size the paper to the content (`paper="auto"`, fill 70–100%). Fill in475 the title block on every sheet.47613. Differential pairs are `<BASE>_P` / `<BASE>_N`, never `DP`/`DM`, `P`/`M`477 or lower case — that is the only spelling KiCad recognises, and every478 pair-aware tool and the impedance netclass depend on it.479480## Geometry facts you will need481The full file-format notes are in `references/kicad-format.md`.482483- A library pin's `(at …)` is its **tip**, the connection point. The symbol's484 local frame is Y-up; the sheet is Y-down.485- Rotation moves local +Y (a resistor's pin 1) as follows:486 `0 → up, 90 → left, 180 → down, 270 → right`. A two-pin part's rotation487 therefore decides *which pin number* faces which way.488- Mirroring: KiCad rotates first, then mirrors in sheet space. `mirror="x"`489 flips top↔bottom and `mirror="y"` flips left↔right. This was verified for490 all 12 combinations by `scripts/probe_transforms.py`; rerun the probe after491 a KiCad major upgrade.492- Connectivity rules:493 - Wires that cross without a junction are **not** connected.494 - A pin touching a wire's middle **is** connected.495 - Overlapping collinear wires **merge** nets.496497## Gotchas that cost hours498Each is explained in `references/troubleshooting.md`.499500- **Invisible wires in SVG/PDF**, even though the netlist is fine: the501 `.kicad_pro` netclass is missing `wire_width`. `write()` patches it. The502 stroke type is irrelevant; this was tested.503- Embedded symbols must be named with the full lib_id (`"Device:R"`), and504 derived `(extends …)` symbols must be flattened. schlib does both.505- A sub-sheet instance path is `/<root uuid>/<uuid of the sheet symbol on506 the root>`, not the sub-sheet file's own UUID.507- Symbols from easyeda2kicad keep their pins in sub-unit `_0_1`; stock508 symbols keep them in `_1_1`.509- Field text angle *and* justification are relative to the part's rotation,510 and KiCad does **not** rescue you the way it does in the editor.511 - At 90° or 270°, text at angle 0 draws sideways. Cancelling the rotation512 (angle = `-rotation`) fixes those two: KiCad then normalises the result513 to read bottom-to-top.514 - **At 180° that same formula gives angle 180, which KiCad draws literally515 — upside down.** 180 takes angle **0** instead, and because justification516 is read in the symbol's frame, left and right swap with it. A `mirror="y"`517 swaps them too, so the two together cancel.518 - schlib handles all of it; all 24 rotation × mirror cases are verified by519 `scripts/probe_fields.py`, and `check_text()` now fails on upside-down520 text as well as sideways text, so a regression here cannot pass silently.521 - Decide whether a two-pin part is vertical from its pin positions, not522 from its body's shape.523- **A power port at the end of a horizontal stub is rotated to face the524 wire, which turns its name through 90°.** A name is longer than a 2.54 mm525 pin pitch, so on a dense IC flank the rotated names overlap into an526 unreadable smear. Use horizontal net labels on the flank (the rail keeps527 its identity from the power port on its decoupling cap), and bundle the528 ground pins onto one bar with a single GND symbol clear of the label lane.529- **A net name ending in `N` is half a differential pair as far as KiCad is530 concerned.** `GPS_RF_IN` sent it looking for `GPS_RF_IP`; `RESET_N` will531 couple to `RESET_P` the moment such a net exists. For active-low signals532 prefer `nRESET` or `~{RESET}` over `RESET_N`, and check the `dp-suffix`533 note before shipping.534- Take the title-block warning seriously until the render proves otherwise.535 Notes drift under the title block easily.536- Measure; don't reason. When a result looks contradictory (a rotation537 "works" for one part and not another), build a tiny probe schematic and ask538 KiCad. That is how the rotation table was settled.539540## Worked examples541- **`references/example_minimal.py`**: one A5 sheet using stock libraries542 only, so it runs in any empty folder. It shows the core idioms: a mirrored543 connector, derived symbols, a multi-unit op-amp including its power unit,544 `stub()`, rails with caps, a feedback network and `PWR_FLAG`s. Start here545 for small designs.546- **`scripts/subcircuits.py`**: read the blocks themselves for real-world547 patterns:548 - the USB-C flip pairing done with labels;549 - a reset RC with button;550 - staggered I²C pull-ups;551 - a boost converter whose interleaved pins use labelled stubs;552 - a Kelvin-sensed shunt.553- A complete generated board built with this skill is public:554 [USBC_M2E_HaLow_Adapter](https://github.com/Diode663/USBC_M2E_HaLow_Adapter)555 (`generate_schematic.py`, 30 parts, fabricated).556557## Related skills558Use the **lcsc** / **bom** skills to source parts. Use `easyeda2kicad` to559fetch LCSC symbols and footprints into a project library, then resolve them560through the project's `sym-lib-table`. schlib reads that table561automatically, as well as the global one and KiCad's stock libraries. Use562the **kicad** skill for an electrical design review once the sheets are563drawn, and the **kicad-pcb-placement** skill to lay the board out afterwards564— it consumes the netlist this skill verifies.