| name | vibe-pcb |
| description | Take a small PCB from a natural-language spec to an ERC/DRC-clean, review-grade KiCad design and a fab order — written for a PCB beginner driving KiCad via Claude Code. Use when designing a NEW dev board / module-carrier PCB, SWAPPING the module or sensor on an existing carrier, or CHANGING the layout / outline / net map. Key facts this skill encodes: the KiCad project is GENERATED BY SCRIPT — you edit `gen_sch.py` / `gen_pcb.py`, never the `.kicad_*` files; geometry is COMPUTED by `scripts/pcblib/` (relations in, coordinates out: contracts feed the outline and connector positions, `beside/align_pads/at_edge/Cluster` place parts off real courtyards, numeric gates fail bad floorplans before copper, `placement.json` is exported as evidence); every net lives ONCE in `parts.yaml` (schematic and copper both derive from it); validation is ERC/DRC driven to 0 ERROR-severity + 0 unconnected (silk WARNINGS are cosmetic, don't conflate) with launchers that degrade honestly on kicad-cli < 8; fab is `scripts/fab_export.sh` → JLCPCB Gerber upload, NEVER importing the KiCad project into EasyEDA; a flush-soldered module needs the belly keep-out gate (`scripts/belly_check.py` — DRC can't see it). Review the routed board INTERACTIVELY in the browser via `scripts/pcb_view.sh`. The worked, gate-clean reference is `examples/voice-buddy/pcb/`. For the enclosure around the board, hand off to `vibe-cad` (see `references/fab-and-3d.md`). |
Text → PCB (beginner + Claude Code → fab order)
A reproducible pipeline for small boards (module-carriers, audio boards, sensor
tiles). You write the intent in prose; Claude Code emits KiCad by script,
drives ERC/DRC to clean, reviews it, fits it in 3D, and packages the fab order.
You never hand-place a track in a GUI.
Worked reference: examples/voice-buddy/pcb/
— a 70×70 ESP32-S3 + dual-codec speaker board, generated end-to-end with the
method below (contracts → relations → gates → scripted route → DRC 0/0).
Why "by script", not the KiCad GUI
The .kicad_sch / .kicad_pcb are outputs (gitignored, regenerated). The
sources of truth are data files + two small generators:
| Source | Owns | Consumed by |
|---|
../cad/constraints.yaml | outline, mount holes, port/window positions, keepouts | pcblib.Board draws them; the enclosure model reads the same file |
pinmap.yaml | signal ↔ GPIO (the pcb→firmware contract) | firmware header; plm_check.py cross-checks |
parts.yaml | ref → footprint/value/pad→net map/cluster/DNP/LCSC | both generators — sch and pcb cannot drift |
gen_sch.py | symbol grid + labels (via pcblib.sch) | .kicad_sch |
gen_pcb.py | placement relations + routing (via pcblib) | .kicad_pcb, placement.json, board.step |
The representation rule: relations in, coordinates out. A language model is
worst at one-shot absolute geometry and best at relations ("the cap hugs the
codec's supply pins", "USB-C sits at the contract's port"). scripts/pcblib/
keeps the relations as code and computes the millimetres:
from pcblib import (load_constraints, load_parts, Board, Cluster, place,
beside, align_pads, row, at_edge, scorecard,
export_placement, wire, via, gnd_pours)
C, P = load_constraints("../../cad/constraints.yaml"), load_parts("../parts.yaml")
brd = Board(pcb, C)
PWR = Cluster(brd, "PWR", (55, 25))
u5 = PWR.place("Package_TO_SOT_SMD", "SOT-223-3_TabPin2", "U5",
value=P["U5"].value, nets=P.netmap("U5"))
beside(u5, c4, side="right", gap=0.7)
at_edge(brd, j1, "usb_c")
fails = scorecard(brd, budgets={"hpwl_mm": 2000})
export_placement(brd, "../placement.json", kinds={...})
A part's pad→net map comes from parts.yaml (nets=P.netmap(ref)), never
typed inline — that single table also generates the schematic, so ERC, the
netlist, and the copper agree by construction.
The loop
spec (prose) ──► generate ──► validate ──► review ──► 3D fit ──► fab
_brief.md gen_sch.py scorecard + kicad-happy STEP + gerbers
+ contracts gen_pcb.py erc / drc + dispositions renders + BOM/CPL
(net map, fit (pcblib) → 0/0/0
numbers) (iterate)
- Module/chip reference — FIRST, before any generation. For every purchased
module pull the vendor reference (released KiCad land / pinout / 3D) and
derive land/symbol/model from it verbatim; for bare chips work from the
datasheet pin table and record every guessed number as EST in the brief's
§10 checklist. A guessed land has caused both a bench-dead miswire and an
unsolderable footprint (war stories in
references/design-rules.md). When no
stock footprint exists, generate it from source like
examples/voice-buddy/pcb/kicad/gen_footprints.py (QFN-32 4×4 + a MEMS mic
land) — regenerated at build time, never hand-drawn.
- Spec — write/update
<proj>_brief.md (references/spec-template.md) and
the three data files. The heart is the net map (parts.yaml +
pinmap.yaml); the enclosure interlock numbers go in constraints.yaml
(owned jointly with vibe-cad — one number, one place).
- Generate + floorplan loop — edit
gen_pcb.py relations, then iterate the
staged skeleton: pcb_skeleton.sh <proj> floorplan|place renders a PDF +
PNG you READ, and the scorecard prints the numeric gates (courtyard/cluster
overlaps, keepout violations = hard fail; HPWL tracked against a budget).
Nudge cluster origins without editing code: MOVE="AUDIO:-2,3" pcb_skeleton.sh ….
Placement is converged when the gates are 0 AND the render reads right.
- Route — freerouting when available (
scripts/autoroute.sh; accept by
committing the .ses as routing.ses — route.apply_ses replays it so the
routed board regenerates from source). Otherwise route by script with the
pad-anchored vocabulary (wire(brd, net, [a.pad(3), b.pad(1)], bend="x"),
via, path — endpoints are pad lookups, waypoints are relative). GND is
never point-to-point: gnd_pours() + stitching vias.
- Validate —
pcb_check.sh <proj>: regenerates, ERC + DRC to 0
error-severity / 0 unconnected (warnings are cosmetic; the split matters —
see design-rules). Flush module → add the belly gate (BELLY_BOX=…).
On a kicad-cli < 8 host the script runs DRC through pcbnew and prints
PASS* with ERC loudly SKIPPED — rerun on KiCad ≥ 8 before fab.
- Review —
pcb_view.sh (browser, 2D/3D/split) + optional kicad-happy
pass; record dispositions in REVIEW.md.
- 3D fit — export
board.step (kicad-cli pcb export step) — it is a
contract artifact the enclosure's check_fit.py consumes. The vibe-cad
side reads constraints.yaml + placement.json; plm_check.py verifies
contract-vs-evidence drift.
- Fab —
scripts/fab_export.sh <proj> <hand-soldered-refs…> → JLCPCB
Gerber upload (never EasyEDA import), then the physical-verify checklist.
Layered layout + the visual feedback loop (placement is the hard part)
No CLI one-shots place & route, and a model is worst exactly there. The fix is
representation + feedback, both shipped in pcblib:
- Two-level floorplan.
Cluster(name, origin) = MACRO block; members sit
at relations/slots relative to it. cluster.bbox() derives from member
courtyards, so the floorplan stage shows true block sizes. Mark groups whose
members sit on CONTRACT positions (panel buttons, edge connectors) as
pinned=True — they're not free blocks and skip the cluster-overlap gate.
- Coarse → fine, gated.
STAGE=floorplan (boxes + anchors) →
STAGE=place (parts, scorecard, placement.json) → full (copper). Fix
placement before routing and routing's search space collapses.
- Perception in the loop. Read the stage renders; the numeric gates catch
what eyeballs miss (a 0.1 mm courtyard graze) and the render catches what
numbers miss (signal flow, antenna orientation, ugly).
- Placement serves routing. Reserve explicit channels (the voice-buddy
board keeps x≈9.5–13.5 clear as the I²S/I²C trunk down to both codecs);
support passives go in rows/columns beside their IC, never scattered into
future bus paths. If a route fights, move a part — placement is cheaper than
copper. QFN 0.4 mm pitch: 0.2 mm stubs straight out of the pad row.
Division of labour for routing. freerouting runs headless and is preferred
(recipe + gotchas: references/autorouting.md); scripted wire()/via() is the
sanctioned fallback and stays regenerable. Either way the model only reads
the render + DRC to accept/reject — never hand-draws copper blind.
Details + the shipped gate/enforcer mapping: references/design-rules.md,
references/gated-workflow.md, references/professional-standards.md.
Run it
The launchers auto-resolve tools (KICAD_CLI/KICAD_PY env override → mac
KiCad-10 bundle → PATH) and set PYTHONPATH so import pcblib works; on old
kicad-cli they fall back per step (see scripts/_kicad_env.sh):
S=skills/vibe-pcb/scripts
$S/pcb_skeleton.sh <proj> floorplan
MOVE="AUDIO:-2,3" $S/pcb_skeleton.sh <proj> place
$S/autoroute.sh <proj>
$S/pcb_check.sh <proj>
BELLY_BOX="x0,y0,x1,y1" $S/pcb_check.sh <proj>
$S/pcb_view.sh <proj> &
$S/fab_export.sh <proj> A1 J1
Scaffolding a new board
Copy the voice-buddy layout (don't start from a blank KiCad project):
<product>/pcb/
<name>_brief.md # the spec (references/spec-template.md)
pinmap.yaml # pcb -> firmware contract (plm cross-checked)
parts.yaml # THE net source: ref -> footprint/value/pads/cluster
placement.json board.step # generated contract artifacts (committed at a revision)
.gitignore # .kicad_* and reports are outputs — never committed
kicad/
gen_sch.py gen_pcb.py # the generators (edit these)
gen_footprints.py # custom lands, generated from source (if needed)
routing.py | routing.ses # scripted route / accepted freerouting session
Hard rules (full list + war stories in references/design-rules.md)
- Edit the generators + data files, never the
.kicad_*. Re-run to regenerate.
- A cross-domain number appears in exactly one file (
constraints.yaml,
pinmap.yaml, parts.yaml). If a generator contains a literal that also
exists in a contract, that's a bug (the STANDOFF_H=16 story in fab-and-3d.md).
- Placement gates before copper: courtyard/cluster overlaps = 0, keepout
violations = 0, printed by
scorecard() — a generator that fails its own
gate exits nonzero.
- Power must be live out of the fab — default solder-jumpers closed.
- No F.Cu/vias under a flush module's belly (route beneath on B.Cu) —
belly_check.py proves it; DRC can't.
- Fab via Gerber upload, not EasyEDA import.
- Vendor reference before generation for purchased modules; EST lands only
when none exists, tracked in the brief §10 and closed before ordering.
When to reach for this skill vs others
- New board / module swap / layout change → here.
- The enclosure that holds the board →
vibe-cad; the shared numbers live
in constraints.yaml, the fit check eats board.step.
- Product-level contracts / release gates →
vibe-plm (plm_check.py now
verifies contract contents: pinmap↔firmware header, constraints↔placement).
Keeping this current (living doc)
This skill exists to compound hard-won PCB experience. When a new board
surfaces a gotcha, a fix, or a better practice, fold it back in (SKILL.md /
references/ / scripts/pcblib/) and commit it with the work.