Fab files
Gerbers are written directly by gerber.py in RS-274X with a %FSLAX46Y46% coordinate format (4 integer, 6 decimal digits), not exported through KiCad. A Gerber coordinate is the pipeline's millimetre value with no origin shift, and that is the property the assembly files depend on.
| File | Layer |
|---|---|
<board>-F_Cu.gtl | top copper: pads and routed traces |
<board>-B_Cu.gbl | bottom copper |
<board>-F_Mask.gts / -B_Mask.gbs | soldermask openings, 0.05 mm larger than the pad per side |
<board>-F_Silkscreen.gto | each part's silkscreen drawing (see below), reference designators and the maker's mark |
<board>-F_Paste.gtp | stencil: SMD pads at exact size, through-hole pads excluded |
<board>-Edge_Cuts.gm1 | the board outline |
<board>.drl | Excellon drill file (METRIC,TZ), tools sorted by diameter, vias at 0.30 mm |
The silkscreen
Every part carries a drawing, and it is one drawing in three places: silk.strokes() answers "what ink does this part carry" for the Gerber (gerber._silk), the KiCad file (kicad.write, as fp_lines and fp_polys on F.SilkS) and the workbench (export.board_json, then the 2D view), so the outline on screen is the outline the fab prints, to the segment. Two sources, in this order:
- The vendor's own drawing where the footprint is the vendor's (
easyeda.package_for): the module outline with its antenna end, the pin-1 dot, the polarity bar, the connector's shroud, read from EasyEDA's top silk layer — tracks, arcs, circles, rectangles and filled regions, arcs and circles flattened to within 10 µm so every writer inks the same segments rather than three roundings of one curve. Text is left out (the designator is ours), and a path the reader does not understand drops its primitive whole rather than half of it. Pen widths are floored at 0.15 mm, the thinnest line a fab prints. - Drawn from the geometry for every hand-written and imported package, the way KiCad's footprint generators draw theirs: the body outline with every piece that would cross a pad cut away, the two lines between a chip's pads when nothing of the outline survives (an 0603 wears them; an 0402 has no room and wears none), a dot beside pin 1 on anything with three pads or more, and a bar at the cathode of a diode whose anode the part's own symbol named. Clear of copper by 0.12 mm edge to edge and inside the courtyard, both by construction;
tests/test_silk_outline.pyholds that over the whole catalogue.
Until 15 Sep 2026 the drawing was the courtyard as a box, on every part, in all three places — the vendor's drawing was read for its bounding box (easyeda.silk_extent) and dropped. Measured on the same board with the same judge (kicad-cli 10.0.6, the 26-part USB-C logger), the box drew 131 silk-over-silk and 24 silk-over-copper warnings and reached past the board outline; the drawing draws 19 and 19, every one of the 19 silk-over-copper hits a reference designator on a neighbour's pad, none of them an outline. The extent now covers every silk primitive, not the tracks alone: a part whose body is drawn as a circle or an arc — a 35 mm electrolytic (13.95 mm short), a CR2032 holder (7.5 mm), a TO-92 (1.4 mm); 120 of 766 cached footprints, median 0.2 mm — was being reserved smaller than it is, and such a part's placement moves on a rebuild.
The maker's mark
Every board carries the mark the README wears: ▚, the upper-left and lower-right quadrants of a square, with SOLDERMASK beside it in ISO 3098 — the standard a title block and a dimension are lettered in, from the same face the app letters its labels with (isofont.py, generated from the vendored osifont by tools/isofont.py; AutoCAD calls the same shapes ISOCPEUR).
Both are filled shapes rather than stroked paths, which is what a block character and a type face actually are. One list of polygons therefore reaches all four places that draw the decal — a Gerber region (G36/G37), a KiCad gr_poly, an SVG polygon and a three.js Shape — so the picture on screen cannot drift from the ink on the film. Counters (the bowls of O, D, R and A) are spliced out of their letters with a narrow slit when the glyph table is generated, so nothing downstream has to express a hole: Gerber would need a polarity flip, KiCad's gr_poly cannot say it at all, and the two would stop agreeing.
It is placed by one function, gerber.insignia_decal(), which the Gerbers, the .kicad_pcb and board.json all call — three answers to "where is the logo" would be three different boards. It takes the emptiest spot it can find, clear of every pad, part outline and designator by 0.3 mm and inside the outline by 1 mm, trying three lockups in turn:
| Lockup | Size | When |
|---|---|---|
| mark alone | 1.5 × 1.5 mm | the default — every board that does not earn one of the others |
| wide | 12.2 × 1.5 mm | the mark and the word side by side, on a board with an edge to spare |
| stacked | 10.3 × 3.2 mm | the word under the mark, for a pocket rather than a strip |
A worded lockup has to clear two tests, not one: it must fit, and it must take no more than a third of the board in either direction. Fitting alone was the wrong bar — 10.3 mm of lettering fits on a 25 mm breakout the way a bumper sticker fits on a postcard. So a 25 × 20 mm board carries the mark by itself, a 60 × 45 mm one gets its name across a fifth of the width, and a Raspberry Pi-sized 85 × 56 mm board would use 14% — the proportion a Pi's own name takes on the board it is printed on.
A board with nowhere clear even for the 1.5 mm mark carries nothing, and the build says so. Unlike a reference designator — printed even when it has to be reported, because an unlabelled part is worse than a badly labelled one — a maker's mark is worth less than one solder joint that does not wet.
The mark is a stamp, not a banner: 1.5 mm whether the board is 25 mm across or 60. The wordmark beside it, though, cannot be drawn smaller than it is — ISO 3098-B strokes are h/10, so lettering this face below about 1.25 mm puts its stems under the 0.12 mm a fab will hold, and it would come back widened, patchy or missing. 1.35 mm gives 0.129 mm stems, eight per cent over the floor, and is why SOLDERMASK needs 10.3 mm of board however small the mark next to it gets. A board that wants a smaller decal than that wants the mark on its own.
Assembly
assembly.py writes the two files JLCPCB needs to populate the board:
<board>-BOM.csv—Comment, Designator, Footprint, LCSC Part #, one row per distinct LCSC part with the designators joined. Comment carries the manufacturer part number: JLCPCB matches on the LCSC code.<board>-CPL.csv—Designator, Mid X, Mid Y, Layer, Rotation, positions inmm,Topfor every part, rotation counter-clockwise as JLCPCB reads it.
The one thing that can silently ruin a board here is the coordinate frame: a CPL whose origin differs from the Gerbers assembles every part in the wrong place and the files are individually valid. The CPL reads the same placement, in the same millimetres, with no offset on either side.
What the board is made of
The workbench's Layers tab opens on a stackup ladder — silkscreen down to silkscreen, each band at its own thickness — and the ladder is the layer control: clicking the F.Cu band takes the top copper off the board, because the list of layers and the list of switches were always the same list. Bands for layers the 2D view does not draw, the mask and the laminate, are inert rather than switches that do nothing.
Every number in that ladder is the fabricator's, and each one says so. A stackup has exactly two numbers this pipeline decides — how many copper layers the router used, and the thickness the .kicad_pcb is written to. Every thickness, material and permittivity under them is the vendor's standard build for a board of that shape, and each carries a dagger individually rather than a footnote nobody reads: a panel that shows a vendor's default in the same weight as the design's own decisions is inviting someone to quote impedance off it. soldermask/stackup.py holds the 2, 4 and 6 layer builds; the six-layer one is reproduced from a real fabrication file and checked against the 1.5660 mm that file reports.
Deriving copper thickness from the ounce was wrong by 0.0088 mm, and the arithmetic caught it. A fabricator's outer layer is 0.035 mm finished — half an ounce of foil plus the plating that fills the vias — while an inner layer is 0.0152 mm of bare foil also called half an ounce. The vendor's figures are the ground truth and the ounce is a label on them, which is why stackup.py writes both down rather than computing one from the other.
The ladder is drawn compressed, and the rule is in the model rather than the viewer. A 1.6 mm two-layer board is 94 % core: to scale its copper is 6 px, its mask 2 px, and silkscreen and paste have no thickness at all, so three of nine layers could not be drawn. Metal and finish get a fixed height — nobody reads copper thickness off a picture — and dielectrics get 22 + 70·√t, which keeps a thicker core taller than a thinner one while compressing 43:1 down to 6:1. Four layers is the worst case at 70:1, not two.
The fabrication sheet
Beside the ladder, fourteen rows grouped under Geometry, Copper, Holes and Assembly: a label, a figure, and the unit or the mark the figure carries. Measured off the board rather than taken from a convention, which is what earns it the space — priors says 0.20 mm clearance because that is what the corpus does; this says what happened, and the two come apart. The drone board was routed at 0.127 mm because a part's own pads are that close, which is a fabrication risk nothing else on screen would show.
Narrowest track, clearance against the convention, the full drill schedule and the smallest hole, copper length per layer, tracks and segments, pads and through-holes.
The router narrowing a trace is reported as a finding, not as a bar. A net asks for a width from the current it carries (see ERC); freerouting will run it thinner to get through. Drawn as a width histogram that would be a second bar the reader has to interpret, so it is counted instead, against the width the net asked for — 8 of the 29 board versions on disk carry one. Nets built before they declared a width are held to the corpus convention, without which the check finds nothing on any board that actually has the problem.
None of this needs a rebuild. export.attach_fab() derives the stackup and the sheet on read, from the wires, vias, pads and routing report a board already carries — the same bargain attach_symbols() makes — so versions of real work built before it existed show the panel now rather than when each is next built.
JLCPCB's own table
DRC judges the copper against the rules this pipeline writes into the .kicad_pro; it is never told the rest of the fab's table. fabrules.py is that table — JLCPCB's PCB capabilities and assembly capabilities pages as read on 24 Sep 2026, each figure a named constant — checked on board.json at the end of every build and stored as board["fab_rules"]: which assembly service the board needs, the findings, and what it could not see.
| Rule | JLCPCB's figure (1–2 layer, 1 oz) |
|---|---|
| board size, thickness | 3 × 3 mm to 670 × 600 mm; 0.4 / 0.6 / 0.8 / 1.0 / 1.2 / 1.6 / 2.0 mm |
| track | 0.10 mm (0.16 at 2 oz) |
| via | 0.15 mm hole / 0.25 mm diameter; a 0.15 mm hole, or 0.2–0.25 mm under a 0.45 mm via, costs more |
| component hole | 0.15–6.3 mm; ring 0.18 mm absolute, 0.25 recommended (0.254 at 2 oz) |
| non-plated hole | 0.50 mm |
| hole to hole, edge to edge | vias 0.20 mm, pad holes 0.45 mm |
| via in pad | not offered on two layers (filled vias are for six layers and up) |
| SMD pads of different nets | 0.15 mm apart; a mask dam needs 0.10 mm (0.13 black or white, 0.20 at 2 oz) |
| silkscreen | 0.15 mm line, 1.0 mm text, 0.15 mm from a pad |
| assembly, Economic | one side; 10 × 10 to 470 × 500 mm; 0.8–1.6 mm thick; 0402 and up; 0.4 mm pitch; BGA 0.5 mm |
| assembly, Standard | both sides; 70 × 70 to 460 × 500 mm single (smaller as a panel); 0201 and up; 0.35 mm pitch; BGA 0.3 mm |
Three levels, in the fab's words: below-minimum is outside what the page says JLCPCB makes or places; extra-cost is inside it only on a costlier option or service; risk is inside it against the page's recommendation or a hazard it names. A below-minimum finding about copper, holes, the board or assembly is blocking and goes in the board's gaps; silkscreen and mask never block, and they, the cost findings and the risks are notes. Reporting only, like the rubric: repair.passes() does not read it.
What it found on 24 Sep 2026 (tools/fab_check.py, first version of each project). On the 60 boards of the 16 Sep designs rebuilt that day: no copper, via or board finding below the minimum; every board's reference labels at 0.9 mm (gerber.SILK_TEXT_MM) are under the 1.0 mm minimum text height; 56 have silk nearer a pad than 0.15 mm (silk.CLEAR_MM clips ink at 0.12); 6 have a via hole opening into a pin's pad (on two the via's centre sits on it) and 4 more a via in an exposed pad, said as common practice; 9 a component-hole ring between the 0.18 absolute and the 0.25 recommended (the USB-C shell pads' 0.20); 3 put parts on the back and so need Standard assembly — and all three are under 70 × 70 mm, so they go as a panel; and one (043) has a GND stitching via whose hole touches a switch lug's 1.5 mm hole, 0.000 mm edge to edge, which kicad-cli passed. The four boards of fab run 1 have nothing blocking and assemble on Economic.
Not checked, and said so in unchecked: a slot's width (board.json keeps a drill as one number), a part's distance from the edge for assembly (the page gives no figure), and silkscreen text on anything but the pipeline's own labels.
python tools/fab_check.py .cache/eval-2026-09-24-control [more run dirs or board.json files] [--versions all] [--json out.json]
Verification
gerber.verify() reads the files back with gerbonara, an independent Gerber library that knows nothing about how they were made — the fair answer to a self-grading generator. It confirms top and bottom copper and an outline are present and that the outline is a shape a fab could cut to. The result is printed (fab OK read back by gerbonara — …) and recorded in evaluations; it is reported, not gating.
When several boards are built into one output directory, clear_stale() removes the previous board's layers first, because a fab that receives a zip with six boards' layers either rejects the order or builds the wrong thing.