main
John Lauer Publish independent Astra ESC source and full-pad Kelvin review lesson d057af8 22d ago
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688
#!/usr/bin/env python3
"""Build the placement demo fixture from a routed, placed KiCad 10 board.

    python3 tools/make_placement_fixture.py BOARD.kicad_pcb [--out DIR] [--gap 2] [--margin 5]

Writes two copies beside each other (default: next to the input):

  <name>-unplaced.kicad_pcb   every footprint moved out of the board outline into a parking
                              grid to the right of it (rows sorted by reference, 2 mm gaps,
                              rotation 0, side kept), every segment, arc and via deleted,
                              zones kept with their fills dropped
  <name>-unrouted.kicad_pcb   the original placement, only the copper and the fills stripped

The file is edited in place at the byte level: a small s-expression tokenizer records where
every node starts and ends, and only the nodes that change are rewritten, so everything
else (indentation, number formatting, uuids, the setup block) is byte for byte the input.
Teardrop zones (KiCad's generated copper around pads and vias, `(attr (teardrop ...))`)
belong to the copper that is removed and are dropped too unless --keep-teardrops.

Refuses a board with no Edge.Cuts outline: there is nowhere to park relative to.
Python 3, standard library only. `--self-test` runs the built-in checks.
"""
import argparse
import json
import math
import re
import sys
from pathlib import Path

GAP_MM = 2.0
MARGIN_MM = 5.0
COURTYARD_FALLBACK_MM = 0.25
COPPER_TAGS = ("segment", "via", "arc")
ANGLED_CHILDREN = ("pad", "property", "fp_text")


# ----------------------------------------------------------------------------- s-expressions

class Node:
    """A list node: tag, children (Node or Atom), and its [start, end) span in the text."""
    __slots__ = ("start", "end", "items")

    def __init__(self, start):
        self.start = start
        self.end = None
        self.items = []

    @property
    def tag(self):
        first = self.items[0] if self.items else None
        return first.text if isinstance(first, Atom) else None

    def find(self, tag):
        for c in self.items:
            if isinstance(c, Node) and c.tag == tag:
                return c
        return None

    def find_all(self, tag):
        return [c for c in self.items if isinstance(c, Node) and c.tag == tag]

    def atom(self, i):
        c = self.items[i] if i < len(self.items) else None
        return c.value if isinstance(c, Atom) else None

    def value(self):
        return self.atom(1)

    def number(self, i):
        v = self.atom(i)
        try:
            return float(v)
        except (TypeError, ValueError):
            return None


class Atom:
    __slots__ = ("text", "start", "end")

    def __init__(self, text, start, end):
        self.text, self.start, self.end = text, start, end

    @property
    def value(self):
        """The atom without quotes, escapes resolved (only what KiCad emits)."""
        if self.text.startswith('"') and self.text.endswith('"') and len(self.text) >= 2:
            return self.text[1:-1].replace('\\"', '"').replace("\\\\", "\\")
        return self.text


def parse(text):
    """Parse the first top-level list. Every node keeps its exact span in `text`."""
    n = len(text)
    i = 0
    root = None
    stack = []
    while i < n:
        c = text[i]
        if c == "(":
            node = Node(i)
            if stack:
                stack[-1].items.append(node)
            stack.append(node)
            i += 1
        elif c == ")":
            if not stack:
                raise ValueError("unbalanced ')' at offset %d" % i)
            node = stack.pop()
            node.end = i + 1
            if not stack:
                root = node
                break
            i += 1
        elif c == '"':
            j = i + 1
            while j < n:
                if text[j] == "\\":
                    j += 2
                    continue
                if text[j] == '"':
                    break
                j += 1
            if j >= n:
                raise ValueError("unterminated string at offset %d" % i)
            stack[-1].items.append(Atom(text[i:j + 1], i, j + 1))
            i = j + 1
        elif c.isspace():
            i += 1
        else:
            j = i
            while j < n and not text[j].isspace() and text[j] not in "()":
                j += 1
            if not stack:
                raise ValueError("atom outside any list at offset %d" % i)
            stack[-1].items.append(Atom(text[i:j], i, j))
            i = j
    if root is None:
        raise ValueError("no complete top-level list")
    return root


def num(v):
    """KiCad-style trimmed float: 1.5, not 1.500000; -0 is 0."""
    s = ("%.6f" % v).rstrip("0").rstrip(".")
    if s in ("", "-", "-0"):
        return "0"
    return s


def normalize_deg(d):
    r = math.fmod(d, 360.0)
    if r <= -180.0:
        r += 360.0
    if r > 180.0:
        r -= 360.0
    return 0.0 if r == 0 else r


def ref_key(ref):
    letters = re.match(r"[^0-9]*", ref).group(0)
    rest = ref[len(letters):]
    digits = re.match(r"[0-9]*", rest).group(0)
    return (letters.upper(), int(digits) if digits else 0, rest[len(digits):])


def line_span(text, node):
    """The span to cut for a whole-line node: from the start of its line through its newline."""
    s = node.start
    ls = text.rfind("\n", 0, s) + 1
    whole_line = not text[ls:s].strip()
    if not whole_line:
        ls = s  # something else on the line before it: cut the node only
    e = node.end
    if whole_line:
        if text[e:e + 2] == "\r\n":
            e += 2
        elif text[e:e + 1] == "\n":
            e += 1
    return ls, e


# ----------------------------------------------------------------------------- geometry

def points_of(node, coords=("start", "mid", "end", "center")):
    pts = []
    for tag in coords:
        c = node.find(tag)
        if c is not None:
            x, y = c.number(1), c.number(2)
            if x is not None and y is not None:
                pts.append((x, y))
    p = node.find("pts")
    if p is not None:
        for xy in p.find_all("xy"):
            x, y = xy.number(1), xy.number(2)
            if x is not None and y is not None:
                pts.append((x, y))
    return pts


def shape_extent(node):
    """Vertices that bound one graphic shape (rect corners, circle extremes, arc points)."""
    tag = node.tag or ""
    if tag.endswith("rect"):
        a, b = node.find("start"), node.find("end")
        if a is None or b is None:
            return []
        ax, ay, bx, by = a.number(1), a.number(2), b.number(1), b.number(2)
        return [(ax, ay), (bx, ay), (bx, by), (ax, by)]
    if tag.endswith("circle"):
        c, e = node.find("center"), node.find("end")
        if c is None or e is None:
            return []
        cx, cy = c.number(1), c.number(2)
        r = math.hypot(e.number(1) - cx, e.number(2) - cy)
        return [(cx - r, cy), (cx + r, cy), (cx, cy - r), (cx, cy + r)]
    return points_of(node)


def bbox(points):
    if not points:
        return None
    xs = [p[0] for p in points]
    ys = [p[1] for p in points]
    return (min(xs), min(ys), max(xs), max(ys))


def layer_of(node):
    l = node.find("layer")
    return l.value() if l is not None else None


def outline_bbox(root):
    pts = []
    for tag in ("gr_line", "gr_arc", "gr_rect", "gr_circle", "gr_poly"):
        for node in root.find_all(tag):
            if layer_of(node) == "Edge.Cuts":
                pts.extend(shape_extent(node))
    for fp in root.find_all("footprint"):
        at = fp.find("at")
        fx, fy = (at.number(1), at.number(2)) if at is not None else (0.0, 0.0)
        for tag in ("fp_line", "fp_arc", "fp_rect", "fp_circle", "fp_poly"):
            for node in fp.find_all(tag):
                if layer_of(node) == "Edge.Cuts":
                    pts.extend((x + fx, y + fy) for x, y in shape_extent(node))
    return bbox(pts)


def courtyard_local(fp):
    """The footprint's courtyard box in local coordinates at rotation 0, and its source."""
    pts = []
    for tag in ("fp_line", "fp_arc", "fp_rect", "fp_circle", "fp_poly"):
        for node in fp.find_all(tag):
            if layer_of(node) in ("F.CrtYd", "B.CrtYd"):
                pts.extend(shape_extent(node))
    b = bbox(pts)
    if b:
        return b, "courtyard"
    corners = []
    for pad in fp.find_all("pad"):
        at, size = pad.find("at"), pad.find("size")
        if at is None:
            continue
        x, y = at.number(1), at.number(2)
        w = (size.number(1) if size is not None else 0.0) / 2.0
        h = (size.number(2) if size is not None else 0.0) / 2.0
        corners.extend([(x - w, y - h), (x + w, y + h)])
    b = bbox(corners)
    if b:
        m = COURTYARD_FALLBACK_MM
        return (b[0] - m, b[1] - m, b[2] + m, b[3] + m), "pads+0.25mm"
    return (-0.5, -0.5, 0.5, 0.5), "position"


def reference_of(fp):
    for p in fp.find_all("property"):
        if p.value() == "Reference":
            return p.atom(2) or ""
    return ""


# ----------------------------------------------------------------------------- the edit

def parking_grid(footprints, outline, gap=GAP_MM, margin=MARGIN_MM):
    """Rows of footprints to the right of the outline, sorted by reference.

    footprints: list of (ref, courtyard_local_bbox). Returns {ref: (x, y)} footprint
    origins and the parking bbox. Row width grows with the total area so the block stays
    roughly square rather than one long strip."""
    cells = sorted(footprints, key=lambda f: ref_key(f[0]))
    area = sum((c[2] - c[0] + gap) * (c[3] - c[1] + gap) for _, c in cells)
    row_width = max(40.0, math.ceil(math.sqrt(area) * 1.15))
    x0 = outline[2] + margin
    y0 = outline[1]
    cursor_x, cursor_y, row_h = x0, y0, 0.0
    origins = {}
    pts = []
    for ref, c in cells:
        w, h = c[2] - c[0], c[3] - c[1]
        if cursor_x > x0 and cursor_x + w > x0 + row_width:
            cursor_x = x0
            cursor_y += row_h + gap
            row_h = 0.0
        origins[ref] = (cursor_x - c[0], cursor_y - c[1])
        pts.extend([(cursor_x, cursor_y), (cursor_x + w, cursor_y + h)])
        cursor_x += w + gap
        row_h = max(row_h, h)
    return origins, bbox(pts)


def at_edit(at_node, x, y, angle, keep_angle_atom):
    """Rewrite one (at ...) node. angle None keeps the node's own angle handling."""
    parts = ["at", num(x), num(y)]
    if angle is not None and (keep_angle_atom or angle != 0.0):
        parts.append(num(angle))
    return (at_node.start, at_node.end, "(" + " ".join(parts) + ")")


def plan_edits(text, root, park, gap, margin, keep_teardrops, strip_zones=False, strip_graphics=False, keep_refs=None):
    """All edits for one copy. Returns (edits, summary)."""
    edits = []
    removed = {"segments": 0, "vias": 0, "arcs": 0, "filledPolygons": 0, "teardropZones": 0, "groupsDropped": 0, "groupMembersDropped": 0}
    deleted_ids = set()
    deleted_nodes = set()

    def uuid_of(node):
        u = node.find("uuid")
        return u.value() if u is not None else None

    for node in root.items:
        if not isinstance(node, Node):
            continue
        if node.tag in COPPER_TAGS:
            edits.append((*line_span(text, node), ""))
            removed[{"segment": "segments", "via": "vias", "arc": "arcs"}[node.tag]] += 1
            if uuid_of(node):
                deleted_ids.add(uuid_of(node))
        elif node.tag == "zone" and strip_zones:
            edits.append((*line_span(text, node), ""))
            deleted_nodes.add(id(node))
            removed["zonesStripped"] = removed.get("zonesStripped", 0) + 1
            if uuid_of(node):
                deleted_ids.add(uuid_of(node))
            continue
        elif strip_graphics and node.tag in ("gr_line", "gr_arc", "gr_rect", "gr_circle", "gr_poly", "gr_text", "gr_text_box", "dimension", "image", "table") and layer_of(node) != "Edge.Cuts":
            edits.append((*line_span(text, node), ""))
            deleted_nodes.add(id(node))
            removed["graphicsStripped"] = removed.get("graphicsStripped", 0) + 1
            if uuid_of(node):
                deleted_ids.add(uuid_of(node))
            continue
        elif node.tag == "zone" and not keep_teardrops:
            attr = node.find("attr")
            if attr is not None and attr.find("teardrop") is not None:
                edits.append((*line_span(text, node), ""))
                deleted_nodes.add(id(node))
                removed["teardropZones"] += 1
                if uuid_of(node):
                    deleted_ids.add(uuid_of(node))
                continue

    def strip_fills(node):
        for c in node.items:
            if not isinstance(c, Node) or id(c) in deleted_nodes:
                continue
            if c.tag == "filled_polygon":
                edits.append((*line_span(text, c), ""))
                removed["filledPolygons"] += 1
            elif c.tag in ("zone", "footprint"):
                strip_fills(c)
    strip_fills(root)

    outline = outline_bbox(root)
    parked = 0
    parking = None
    fps = root.find_all("footprint")
    import re as _re
    keep_re = _re.compile(keep_refs) if keep_refs else None
    kept_refs = []
    if keep_re:
        for fp in fps:
            ref = reference_of(fp)
            if keep_re.fullmatch(ref or ""):
                kept_refs.append(ref)
                if fp.find("locked") is None:
                    # insert (locked yes) right after the layer line of the footprint
                    layer = fp.find("layer")
                    if layer is not None:
                        st, en = line_span(text, layer)
                        edits.append((en, en, "\t\t(locked yes)\n"))
        fps = [fp for fp in fps if not keep_re.fullmatch(reference_of(fp) or "")]
    if park:
        cells = []
        for fp in fps:
            cells.append((reference_of(fp), courtyard_local(fp)[0]))
        origins, parking = parking_grid(cells, outline, gap, margin)
        for fp in fps:
            ref = reference_of(fp)
            at = fp.find("at")
            if at is None:
                continue
            rot = at.number(3) or 0.0
            x, y = origins[ref]
            edits.append(at_edit(at, x, y, None, False))
            if rot != 0.0:
                # Child angles are stored absolute (footprint angle included): keep each
                # child's angle relative to its footprint when the footprint goes to 0.
                for child in fp.items:
                    if isinstance(child, Node) and child.tag in ANGLED_CHILDREN:
                        cat = child.find("at")
                        if cat is None:
                            continue
                        has_angle = cat.atom(3) is not None
                        old = cat.number(3) or 0.0
                        edits.append(at_edit(cat, cat.number(1), cat.number(2), normalize_deg(old - rot), has_angle))
            parked += 1

    # Groups that referenced removed copper: drop the dead members, drop empty groups.
    for g in root.find_all("group"):
        members = g.find("members")
        if members is None:
            continue
        ids = [a for a in members.items[1:] if isinstance(a, Atom)]
        dead = [a for a in ids if a.value in deleted_ids]
        if not dead:
            continue
        if len(dead) == len(ids):
            edits.append((*line_span(text, g), ""))
            removed["groupsDropped"] += 1
        else:
            keep = [a.text for a in ids if a.value not in deleted_ids]
            edits.append((members.start, members.end, "(members " + " ".join(keep) + ")"))
            removed["groupMembersDropped"] += len(dead)

    summary = {
        "footprints": len(fps), "footprintsParked": parked, "removed": removed,
        "outline": {"minX": outline[0], "minY": outline[1], "maxX": outline[2], "maxY": outline[3]},

        "parking": None if parking is None else {"minX": round(parking[0], 3), "minY": round(parking[1], 3), "maxX": round(parking[2], 3), "maxY": round(parking[3], 3)},
    }
    summary["keptInPlace"] = kept_refs
    return edits, summary


def apply_edits(text, edits):
    edits = sorted(edits, key=lambda e: (e[0], e[1]))
    for i in range(1, len(edits)):
        if edits[i][0] < edits[i - 1][1]:
            raise ValueError("overlapping edits at %d" % edits[i][0])
    out = []
    pos = 0
    for s, e, rep in edits:
        out.append(text[pos:s])
        out.append(rep)
        pos = e
    out.append(text[pos:])
    return "".join(out)


def make(text, park, gap=GAP_MM, margin=MARGIN_MM, keep_teardrops=False, strip_zones=False, strip_graphics=False, keep_refs=None):
    root = parse(text)
    if root.tag != "kicad_pcb":
        raise ValueError("not a kicad_pcb file")
    if outline_bbox(root) is None:
        raise ValueError("no Edge.Cuts outline found; refusing (nothing to park relative to)")
    edits, summary = plan_edits(text, root, park, gap, margin, keep_teardrops, strip_zones, strip_graphics, keep_refs)
    return apply_edits(text, edits), summary


def main(argv=None):
    ap = argparse.ArgumentParser(description=__doc__.split("\n\n")[0])
    ap.add_argument("board", nargs="?", help="a .kicad_pcb (KiCad 10 s-expression)")
    ap.add_argument("--out", help="output directory (default: beside the input)")
    ap.add_argument("--gap", type=float, default=GAP_MM, help="gap between parked footprints, mm")
    ap.add_argument("--margin", type=float, default=MARGIN_MM, help="gap between the outline and the parking grid, mm")
    ap.add_argument("--keep-teardrops", action="store_true", help="keep teardrop zones (generated copper) instead of dropping them")
    ap.add_argument("--strip-zones", action="store_true", help="drop every zone (the demo board starts with no copper pours at all)")
    ap.add_argument("--keep-refs", help="regex of references that stay where they are and get locked (a molecule's machine pins and contacts, e.g. 'M[CP]\\d+')")
    ap.add_argument("--strip-graphics", action="store_true", help="drop board-level graphics and text on every layer except Edge.Cuts (labels and logos that describe the original layout)")
    ap.add_argument("--self-test", action="store_true")
    a = ap.parse_args(argv)
    if a.self_test:
        return self_test()
    if not a.board:
        ap.error("board is required")
    src = Path(a.board)
    # newline="" keeps the file's own line endings (KiCad on Windows writes CRLF).
    with open(src, "r", encoding="utf-8", newline="") as f:
        text = f.read()
    out_dir = Path(a.out) if a.out else src.parent
    out_dir.mkdir(parents=True, exist_ok=True)
    stem = src.name[: -len(".kicad_pcb")] if src.name.endswith(".kicad_pcb") else src.stem
    report = {"source": str(src), "outputs": {}}
    for kind, park in (("unplaced", True), ("unrouted", False)):
        new_text, summary = make(text, park, a.gap, a.margin, a.keep_teardrops, a.strip_zones, a.strip_graphics, a.keep_refs)
        dest = out_dir / ("%s-%s.kicad_pcb" % (stem, kind))
        with open(dest, "w", encoding="utf-8", newline="") as f:
            f.write(new_text)
        parse(new_text)  # the output must still be one balanced document
        summary["path"] = str(dest)
        summary["bytes"] = len(new_text.encode("utf-8"))
        report["outputs"][kind] = summary
        r = summary["removed"]
        line = "%s: %d footprints" % (dest.name, summary["footprints"])
        if park:
            p = summary["parking"]
            line += ", %d parked in x %s..%s y %s..%s" % (summary["footprintsParked"], num(p["minX"]), num(p["maxX"]), num(p["minY"]), num(p["maxY"]))
        line += "; removed %d segments, %d vias, %d arcs, %d filled polygons, %d teardrop zones" % (
            r["segments"], r["vias"], r["arcs"], r["filledPolygons"], r["teardropZones"])
        if r.get("zonesStripped") or r.get("graphicsStripped"):
            line += ", %d zones stripped, %d graphics stripped" % (r.get("zonesStripped", 0), r.get("graphicsStripped", 0))
        if r["groupsDropped"] or r["groupMembersDropped"]:
            line += ", %d groups dropped, %d group members dropped" % (r["groupsDropped"], r["groupMembersDropped"])
        print(line)
    o = report["outputs"]["unplaced"]["outline"]
    print("outline bbox: x %s..%s y %s..%s (%s x %s mm); source %d bytes" % (
        num(o["minX"]), num(o["maxX"]), num(o["minY"]), num(o["maxY"]), num(o["maxX"] - o["minX"]), num(o["maxY"] - o["minY"]), len(text.encode("utf-8"))))
    (out_dir / ("%s-placement-fixture.json" % stem)).write_text(json.dumps(report, indent=2) + "\n", encoding="utf-8")
    return 0


# ----------------------------------------------------------------------------- self-test

SAMPLE = """(kicad_pcb
\t(version 20260206)
\t(generator "pcbnew")
\t(layers
\t\t(0 "F.Cu" signal)
\t\t(2 "B.Cu" signal)
\t)
\t(footprint "T:R2"
\t\t(layer "F.Cu")
\t\t(uuid "r2")
\t\t(at 120 110 90)
\t\t(property "Reference" "R2"
\t\t\t(at 0 -1.17 90)
\t\t\t(layer "F.SilkS")
\t\t)
\t\t(fp_rect
\t\t\t(start -1 -0.5)
\t\t\t(end 1 0.5)
\t\t\t(layer "F.CrtYd")
\t\t)
\t\t(pad "1" smd rect
\t\t\t(at -0.5 0 90)
\t\t\t(size 0.6 0.5)
\t\t\t(layers "F.Cu")
\t\t\t(net "A")
\t\t)
\t\t(pad "2" smd rect
\t\t\t(at 0.5 0)
\t\t\t(size 0.6 0.5)
\t\t\t(layers "F.Cu")
\t\t\t(net "B")
\t\t)
\t)
\t(footprint "T:R10"
\t\t(layer "B.Cu")
\t\t(uuid "r10")
\t\t(at 130 110)
\t\t(property "Reference" "R10"
\t\t\t(at 0 -1.17 0)
\t\t)
\t\t(pad "1" smd rect
\t\t\t(at -0.5 0)
\t\t\t(size 0.6 0.5)
\t\t\t(layers "B.Cu")
\t\t\t(net "A")
\t\t)
\t)
\t(gr_rect
\t\t(start 100 100)
\t\t(end 150 130)
\t\t(layer "Edge.Cuts")
\t\t(uuid "edge")
\t)
\t(segment
\t\t(start 119.5 110)
\t\t(end 129.5 110)
\t\t(width 0.2)
\t\t(layer "F.Cu")
\t\t(net "A")
\t\t(uuid "seg1")
\t)
\t(via
\t\t(at 125 110)
\t\t(size 0.6)
\t\t(drill 0.3)
\t\t(layers "F.Cu" "B.Cu")
\t\t(net "A")
\t\t(uuid "via1")
\t)
\t(zone
\t\t(net "GND")
\t\t(layer "F.Cu")
\t\t(uuid "z1")
\t\t(polygon
\t\t\t(pts
\t\t\t\t(xy 100 100) (xy 150 100) (xy 150 130) (xy 100 130)
\t\t\t)
\t\t)
\t\t(filled_polygon
\t\t\t(layer "F.Cu")
\t\t\t(pts
\t\t\t\t(xy 100 100) (xy 150 100) (xy 150 130)
\t\t\t)
\t\t)
\t)
\t(zone
\t\t(net "A")
\t\t(layer "F.Cu")
\t\t(uuid "td1")
\t\t(attr
\t\t\t(teardrop
\t\t\t\t(type padvia)
\t\t\t)
\t\t)
\t\t(filled_polygon
\t\t\t(layer "F.Cu")
\t\t\t(pts
\t\t\t\t(xy 1 1) (xy 2 2) (xy 3 3)
\t\t\t)
\t\t)
\t)
\t(group ""
\t\t(uuid "g1")
\t\t(members "seg1" "edge")
\t)
\t(group ""
\t\t(uuid "g2")
\t\t(members "seg1" "via1")
\t)
\t(embedded_fonts no)
)
"""


def self_test():
    unplaced, s = make(SAMPLE, True)
    root = parse(unplaced)
    assert s["removed"] == {"segments": 1, "vias": 1, "arcs": 0, "filledPolygons": 1, "teardropZones": 1, "groupsDropped": 1, "groupMembersDropped": 1}, s
    assert s["footprintsParked"] == 2
    assert s["outline"] == {"minX": 100.0, "minY": 100.0, "maxX": 150.0, "maxY": 130.0}
    assert not root.find_all("segment") and not root.find_all("via")
    zones = root.find_all("zone")
    assert len(zones) == 1 and zones[0].find("uuid").value() == "z1", "user zone kept, teardrop dropped"
    assert not zones[0].find("filled_polygon") and zones[0].find("polygon") is not None
    groups = root.find_all("group")
    assert len(groups) == 1 and [a.value for a in groups[0].find("members").items[1:]] == ["edge"]
    fps = {reference_of(fp): fp for fp in root.find_all("footprint")}
    # R2 sorts before R10; both start at outline.maxX + 5 on the outline's top row.
    r2, r10 = fps["R2"], fps["R10"]
    assert r2.find("at").atom(3) is None, "rotation zeroed"
    assert r2.find("at").number(1) == 156.0 and r2.find("at").number(2) == 100.5, unplaced
    assert r10.find("at").number(1) > r2.find("at").number(1) + 2.0
    assert layer_of(r10) == "B.Cu", "side kept"
    # Child angles stay relative: the 90 absolute became 0, the missing angle became -90.
    pads = r2.find_all("pad")
    assert pads[0].find("at").atom(3) == "0", pads[0].find("at").atom(3)
    assert pads[1].find("at").atom(3) == "-90", pads[1].find("at").atom(3)
    assert r2.find_all("property")[0].find("at").atom(3) == "0"
    assert r10.find_all("pad")[0].find("at").atom(3) is None, "untouched at rotation 0"
    # Everything else is byte for byte the input.
    assert "(generator \"pcbnew\")" in unplaced and "(embedded_fonts no)" in unplaced
    assert unplaced.count("\t(footprint") == 2 and unplaced.endswith(")\n")
    unrouted, s2 = make(SAMPLE, False)
    assert s2["footprintsParked"] == 0 and s2["parking"] is None
    assert "(at 120 110 90)" in unrouted and "(at 130 110)" in unrouted
    assert "(segment" not in unrouted and "(via" not in unrouted and "filled_polygon" not in unrouted
    kept, s3 = make(SAMPLE, False, keep_teardrops=True)
    assert s3["removed"]["teardropZones"] == 0 and kept.count("(zone") == 2
    try:
        make("(kicad_pcb (version 20260206) (footprint \"X\" (at 1 1)))", True)
        raise AssertionError("no outline must refuse")
    except ValueError as e:
        assert "Edge.Cuts" in str(e)
    crlf, s4 = make(SAMPLE.replace("\n", "\r\n"), True)
    assert "\n" not in crlf.replace("\r\n", "") and s4["removed"]["segments"] == 1, "CRLF files keep CRLF and lose no line"
    assert crlf.count("\r\n") == unplaced.count("\n"), (crlf.count("\r\n"), unplaced.count("\n"))
    assert num(1.5) == "1.5" and num(100.0) == "100" and num(-0.0) == "0" and num(0.1234567) == "0.123457"
    assert normalize_deg(270) == -90 and normalize_deg(-270) == 90 and normalize_deg(180) == 180 and normalize_deg(360) == 0
    assert ref_key("C2") < ref_key("C10") < ref_key("R1")
    print("self-test ok")
    return 0


if __name__ == "__main__":
    sys.exit(main())