skill
EDA Skillpack
Public Made by Adomby adom
All skills for all EDA capabilities across the Adom ecosystem.
main
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())