Embedded HandbookApps
All sections › XVI. The circuit board
STM32F101 · level intermediate · 13 min read
Open in the interactive handbook

133. Altium Designer: project, rules, output

Altium Designer is the largest of the three programs: the schematic, the board, the libraries, the documentation output and the files for the fab all live in one project. The machine has Altium Designer Agile 26.0.0.14.

Here we take apart a project from the example set — Bluetooth Sentinel with the STM32F101 microcontroller — and we do it without Altium itself: the project, the rules and the output job are read by Python, and KiCad opens the board for cross-checking. This is how you look at someone else's project when Altium is not at hand — and how you check that the project will survive a move to another program.

When Altium is at hand, the same things are found in the menus of the PCB editor (the names are from the installed version 26):

What Where in Altium
rules (133.3) Design / Rules
layer stack (133.4) Design / Layer Stack Manager
design rule check Tools / Design Rule Check
output by job (133.5) open the .OutJob, on the output container — Generate content
a project from another program File / Import Wizard

Terms used in the documentation

Term Meaning
PCB project (.PrjPcb) the board project
schematic document (.SchDoc) a schematic sheet
PCB document (.PcbDoc) the board document
Output Job (.OutJob) the job that produces output files
design rule a rule the board is checked against
query, scope a query: which objects a rule applies to
layer stack the order and thickness of board layers
overlay silkscreen
solder mask, paste mask the mask, the solder paste stencil
NC Drill the drill file
device sheet a ready-made sheet block from a library
compound file a file system inside a single file

133.1 The project is a text file

.PrjPcb is text in INI format: sections in square brackets, lines "key=value". In the example:

Section How many What it is
[DocumentN] 12 project documents
[DeviceSheetN] 17 ready-made sheet blocks from a library
[ConfigurationN] 3 output configurations: Documentation, Assembly, Fabrication
[OutputGroupN] 10 groups of output files

There are 12 documents: five .SchDoc schematic sheets, one .PcbDoc board, three .OutJob output jobs, two .Harness and one .Annotation. The project is convenient to keep in git: it is text, and a change to it shows up as a line.

An Altium project: the text project refers to the schematic sheets, the board and the output jobs; KiCad opens the board for cross-checking

The trap is in the first three bytes: the file starts with a UTF-8 byte order mark, and Python's standard INI parser trips over it. It has to be read with the utf-8-sig encoding:

def ini(path):
    cp = configparser.ConfigParser(interpolation=None, strict=False)
    cp.optionxform = str
    cp.read(path, encoding="utf-8-sig")          # файл начинается с метки порядка байтов UTF-8
    return cp

133.2 Schematic and board are compound documents

.SchDoc and .PcbDoc start with the same eight bytes D0 CF 11 E0 A1 B1 1A E1 — this is an OLE compound document, a file system inside a file, like old Word documents. The board holds 47 data streams — one per kind of object: Rules6/Data — rules, Components6/Data — components, Nets6/Data — nets, Board6/Data — settings and layer stack, Tracks6/Data — tracks.

The rule, component, net and schematic streams hold text records of the form |KEY=value|…, each ending with a null byte. Pulling them out takes ten lines:

def kv(rec):
    d = {}
    for part in rec.split("|"):
        if "=" in part:
            k, v = part.split("=", 1)
            d.setdefault(k.upper(), v)
    return d


def stream(ole, name):
    s = ole.openstream(name).read().decode("latin-1")
    return [kv(m.group(0)) for m in re.finditer(r"\|[^\x00]+", s)]

The compound document is opened by the olefile package; here it lives in the stm-ole:1 image.

The schematic sheet with the microcontroller starts with a header record "Protel for Windows - Schematic Capture Binary File Version 5.0", followed by 948 records. The record kind is the RECORD number, and the keys tell you what object it is:

RECORD How many Keys What it is
1 16 LIBREFERENCE, PARTCOUNT component
2 130 NAME, DESIGNATOR, ELECTRICAL pin
27 85 LOCATIONCOUNT, X1, Y1 wire
25 53 TEXT net label
34 16 TEXT, NAME designator: U2, C15, R9 …
41 331 NAME, TEXT parameter

The microcontroller is MC-ARM-STM32F101-SQ37: its description says "36-Pin VFQFPN" and "32 kB Flash, 6 kB Internal RAM". On the sheet it is drawn in two parts — the designator U2 appears twice. On the board its footprint is VFQFPN36_N.


133.3 Board rules

The example has 38 rules. Each has a name, a kind (RULEKIND), a priority and a scope. The rules with numbers:

Rule In the file In millimeters
Clearance — clearance 3.937mil 0.1
Width — track width 3.937–19.685mil, preferred 3.937mil 0.1–0.5, preferred 0.1
RoutingVias — via 19.685mil, hole 11.811mil 0.5, hole 0.3
HoleToHoleClearance — between holes 3.937mil 0.1
ComponentClearance — between packages 7.874mil 0.2
DiffPairsRouting — differential pair 10mil 0.254

Altium stores lengths in mils even when the board was laid out in millimeters: 3.937 mil is 0.1 mm written in another unit. The conversion is mil × 0.0254.

The scope is set by a query. General rules apply to all objects (All), specific ones to selected objects: (CompPinCount < 5) — packages with fewer than five pins, IsSOIC, IsBGA, IsVia, InComponentClass('PCBModule'). Among rules of one kind the senior one wins — the one with the smaller priority number: for pin fanout the specific rules have priorities 1–4, the general Fanout_Default has priority 5.

The five FanoutControl rules of the example: for a pin of an SOIC package the queries IsSOIC with priority 3 and All with priority 5 both match — Fanout_SOIC, the senior of the matching rules, wins


133.4 Layer stack

The board has four layers. The stack from the Board6/Data stream:

Layer Thickness εr
Top Solder — mask 0.3937 mil = 0.01 mm 3.5
Top Layer — copper 1.4173 mil = 0.036 mm
Dielectric 1 12.5984 mil = 0.32 mm 4.8
Mid-Layer 1 — copper 0.036 mm
Dielectric 2 0.32 mm 4.8
Mid-Layer 2 — copper 0.036 mm
Dielectric 3 0.4921 mil = 0.0125 mm 4.8
Bottom Layer — copper 0.036 mm
Bottom Solder — mask 0.01 mm 3.5

The example stack is asymmetric: the third dielectric is only 12.5 µm. A board for ordering is not made like that — the stack is taken from the fab's standard ones, symmetric about the middle of the board, otherwise the board warps during soldering.

The total is 32.1455 mil, 0.8165 mm:

    out["thickness_mm"] = round(sum((l["copper_mil"] or 0) + (l["diel_mil"] or 0) for l in stack) * MIL, 4)

KiCad, having opened this board, got the same thickness — 0.8165 mm (133.6).

The example board stack to scale: four copper layers of 0.036 mm each, dielectrics of 0.32, 0.32 and 0.0125 mm, masks of 0.01 mm each — 32.1455 mil = 0.8165 mm in total


133.5 Output: the .OutJob job

Altium produces the files for the fab from a job — .OutJob, also INI text. The example's Fabrication.OutJob has three outputs: ODB++, Gerber and NC Drill. The settings the fab must know:

Output Setting Value
Gerber units Imperial — inches
digits after the decimal point 5
zeros SuppressLeadingZeroes — leading zeros omitted
NC Drill units Imperial — inches
format 2 digits before the point, 5 after — "2:5"
zeros SuppressTrailingZeroes — trailing zeros omitted
plated separately False — all holes in one file

If the fab reads inches as millimeters or takes the wrong format, the holes will miss the pads. That is why the units and the drill format are also written in the cover letter.

The project itself knows more outputs than this job uses: the Fabrication Outputs group has 12 of them — among them Gerber X2 and IPC-2581, the Netlist Outputs group has 21 netlist formats, Export Outputs has 11, from STEP to Specctra.


133.6 Checking someone else's project without Altium

The altium_read.py script reads the whole project — documents, rules, stack, components, nets, the sheet with the microcontroller, the output job — and returns JSON. For cross-checking, KiCad 9 opens the same board: it has an Altium importer.

KiCad did not open the board on the first try: LoadBoard returned an empty result — the format is not guessed from the file. The format has to be named:

b = pcbnew.LoadBoard(path, pcbnew.PCB_IO_MGR.ALTIUM_DESIGNER)
What File parsing KiCad after import
components 73 73
nets 57 57 plus the service "no net"
copper layers 4 4
thickness 0.8165 mm 0.8165 mm
clearance, smallest 0.1 mm 0.1 mm
track, smallest 0.1 mm 0.1 mm
via, smallest 0.5 mm, hole 0.3 mm 0.5 mm, hole 0.3 mm
between holes 0.1 mm 0.1 mm

Two independent readings agree. But the move is not free: KiCad could not map 28 Altium layers and skipped them, and replaced three fonts with its own. And the net classes — All Nets from Altium and its own Default — got KiCad's own settings: 0.2 mm clearance and 0.2 mm track, not the 0.1 mm from the example's rules. The board minimums carried over, the classes did not; after import you set them again.


133.7 The example's rules against the profile from section 130

The profile from section 130 is the rules KiCad uses to create a new board. The example's rules are finer in four places:

Rule Example Profile 130
clearance 0.1 mm 0.2 mm
track 0.1 mm 0.2 mm
via 0.5 mm 0.6 mm
between holes 0.1 mm 0.25 mm

This is not a mistake in the example: the board has small packages — the footprints VFQFPN36_N, CSR-CSR1010A05-IQQM-R-QFN32_M, LGA16-3X3X1_L. But it is a requirement for the fab: such a board is ordered where 0.1 mm is an ordinary capability, not the limit. Check this before routing, not after.


133.8 Pitfalls

Symptom Cause
configparser: "File contains no section headers" on .PrjPcb the file starts with a UTF-8 byte order mark — read it as utf-8-sig
the rules say "3.937mil" instead of 0.1 mm Altium stores lengths in mils, even for a metric board
KiCad does not open the .PcbDoc, LoadBoard returned nothing the format was not guessed — specify PCB_IO_MGR.ALTIUM_DESIGNER
layers disappeared after import into KiCad 28 layers were not mapped — check the stack after import
net classes in KiCad are 0.2 mm, but in Altium they were 0.1 the classes got KiCad's settings, not the Altium rules
the fab drills off the pads the 2:5 inch drill format was not passed to the fab
the fab refuses the order the rules are finer than its capabilities — check before routing

133.9 Summary

Full source code

altium_read.py — reading an Altium project without Altium: documents, rules, stack, output.

# -*- coding: utf-8 -*-
"""Раздел 133: что лежит в проекте Altium Designer — без самого Altium. Запуск в образе stm-ole:1.

python altium_read.py <папка проекта> <имя>.PrjPcb     → JSON в stdout

Проект .PrjPcb и задание на выпуск .OutJob — текст в формате INI. Схема .SchDoc и плата .PcbDoc —
составные документы OLE: внутри потоки по видам объектов (Rules6/Data, Components6/Data, …),
в потоках — текстовые записи «|КЛЮЧ=значение|…», каждая оканчивается нулевым байтом. Длины
в записях — с единицами: «3.937mil»."""
import configparser, json, os, re, sys
import olefile

MIL = 0.0254                                     # мм в одном миле


def kv(rec):
    d = {}
    for part in rec.split("|"):
        if "=" in part:
            k, v = part.split("=", 1)
            d.setdefault(k.upper(), v)
    return d


def stream(ole, name):
    s = ole.openstream(name).read().decode("latin-1")
    return [kv(m.group(0)) for m in re.finditer(r"\|[^\x00]+", s)]


def mil(v):
    m = re.fullmatch(r"(-?[\d.]+)mil", (v or "").strip())
    return float(m.group(1)) if m else None


def ini(path):
    cp = configparser.ConfigParser(interpolation=None, strict=False)
    cp.optionxform = str
    cp.read(path, encoding="utf-8-sig")          # файл начинается с метки порядка байтов UTF-8
    return cp


def sections(cp, prefix):
    return [cp[s] for s in cp.sections() if re.fullmatch(prefix + r"\d+", s)]


def main(folder, prj):
    cp = ini(os.path.join(folder, prj))
    docs = [s["DocumentPath"] for s in sections(cp, "Document")]
    out = {"documents": docs,
           "device_sheets": len(sections(cp, "DeviceSheet")),
           "configurations": [s["Name"] for s in sections(cp, "Configuration")],
           "output_groups": {s.get("Name"): sum(1 for k in s if re.fullmatch(r"OutputType\d+", k))
                             for s in sections(cp, "OutputGroup")}}

    # ── плата ──
    pcb = os.path.join(folder, next(d for d in docs if d.lower().endswith(".pcbdoc")))
    out["pcb_magic"] = open(pcb, "rb").read(8).hex()
    ole = olefile.OleFileIO(pcb)
    out["pcb_streams"] = len([s for s in ole.listdir() if s[-1] == "Data"])
    rules = []
    for r in stream(ole, "Rules6/Data"):
        if "RULEKIND" in r:
            rules.append({"name": r.get("NAME"), "kind": r["RULEKIND"], "priority": int(r.get("PRIORITY", "0")),
                          "scope1": r.get("SCOPE1EXPRESSION"), "scope2": r.get("SCOPE2EXPRESSION"),
                          **{k.lower(): mil(r[k]) for k in ("GAP", "MINLIMIT", "MAXLIMIT", "PREFEREDWIDTH", "MINWIDTH",
                                                              "MAXWIDTH", "MINHOLEWIDTH", "MAXHOLEWIDTH") if k in r}})
    out["rules"] = rules
    board = stream(ole, "Board6/Data")[0]
    stack, i = [], 0
    while "V9_STACK_LAYER%d_NAME" % i in board:
        p = "V9_STACK_LAYER%d_" % i
        stack.append({"name": board[p + "NAME"], "copper_mil": mil(board.get(p + "COPTHICK")),
                      "diel_mil": mil(board.get(p + "DIELHEIGHT")), "er": float(board.get(p + "DIELCONST") or 0) or None})
        i += 1
    out["stack"] = stack
    out["copper_layers"] = [l["name"] for l in stack if l["copper_mil"]]
    out["thickness_mm"] = round(sum((l["copper_mil"] or 0) + (l["diel_mil"] or 0) for l in stack) * MIL, 4)
    comps = [c for c in stream(ole, "Components6/Data") if "PATTERN" in c]
    out["components"] = len(comps)
    out["patterns"] = sorted({c["PATTERN"] for c in comps})
    out["nets"] = len([n for n in stream(ole, "Nets6/Data") if "NAME" in n])

    # ── лист схемы с микроконтроллером ──
    for d in docs:
        if not d.lower().endswith(".schdoc"):
            continue
        recs = stream(olefile.OleFileIO(os.path.join(folder, d)), "FileHeader")
        mcu = [r for r in recs if r.get("RECORD") == "1" and "STM32" in r.get("LIBREFERENCE", "")]
        if not mcu:
            continue
        kinds = {}
        for r in recs[1:]:
            kinds[r.get("RECORD")] = kinds.get(r.get("RECORD"), 0) + 1
        out["sheet"] = {"file": d, "header": recs[0].get("HEADER"), "records": len(recs) - 1,
                        "kinds": {k: kinds.get(k, 0) for k in ("1", "2", "25", "27", "34", "41")},
                        "keys": {k: sorted(next(r for r in recs if r.get("RECORD") == k)) for k in ("1", "2", "25", "27", "34", "41")},
                        "magic": open(os.path.join(folder, d), "rb").read(8).hex(),
                        "mcu": mcu[0]["LIBREFERENCE"], "mcu_parts_drawn": len(mcu),
                        "mcu_description": mcu[0].get("COMPONENTDESCRIPTION", ""),
                        "designators": sorted(r.get("TEXT") for r in recs if r.get("RECORD") == "34")}

    # ── выпуск для завода ──
    oj = ini(os.path.join(folder, "Fabrication.OutJob"))
    g = oj["OutputGroup1"]
    outs = []
    for i in range(1, 20):
        t = g.get("OutputType%d" % i)
        if t:
            item = dict(p.split("=", 1) for p in g.get("Configuration%d_Item1" % i, "").split("|") if "=" in p)
            outs.append({"type": t, "name": g.get("OutputName%d" % i), "settings": item})
    out["fabrication"] = outs
    json.dump(out, sys.stdout, ensure_ascii=False, indent=1)


if __name__ == "__main__":
    main(sys.argv[1], sys.argv[2])

Next: section 134 — other programs and moving boards between them.

Open in the interactive handbook