"""The silkscreen-audit input, measured from the saved board itself with KiCad's pcbnew (10.0.6), with the shapes
as they are, not their bounding boxes:

  * each label is one box per stroke of KiCad's stroke font (GetEffectiveTextShape, the width included), so a 45 deg
    label is its ink, not the square around it; the union still spans the whole text, as `--record` requires;
  * a round pad, via-less round part (machine pin, contact, test point) or silk circle is covered by four boxes
    stacked inside a slightly larger square (every point of the circle is inside one of them, at most 8 % past it),
    so its empty corners are not taken as copper; other pads are their box;
  * every pad + 0.05 mm, each part's fitted body (F.Fab outline; round parts by their courtyard circle), every via as
    its full box (the audit's rule), the footprints' own silk artwork.

Adapted from ~/project/am32-esc-32/kicad/silk/build_audit.py (boxes only), 2026-11-05: on Noah's 45 deg test-point
labels the box version reported 54 collisions where KiCad's own shape test finds ink on one pad.

  python3 build_silk_audit.py <board.kicad_pcb> <audit.json>
"""
import json, math, sys
import pcbnew

b = pcbnew.LoadBoard(sys.argv[1])
mm = pcbnew.ToMM
SILK = {pcbnew.F_SilkS: "F.SilkS", pcbnew.B_SilkS: "B.SilkS"}


def box(bb, g=0.0):
    return [round(mm(bb.GetX()) - g, 4), round(mm(bb.GetY()) - g, 4), round(mm(bb.GetRight()) + g, 4), round(mm(bb.GetBottom()) + g, 4)]


def circle_cover(cx, cy, r):
    """Four boxes whose union holds every point of the circle: box i spans |x| <= r cos(a_i), |y| <= r sin(a_i+1)
    for a = 0, 22.5, 45, 67.5, 90 deg (a point at angle t in [a_i, a_i+1] has |cos t| <= cos a_i, |sin t| <= sin a_i+1)."""
    a = [math.radians(d) for d in (0, 22.5, 45, 67.5, 90)]
    return [[round(cx - r * math.cos(a[i]), 4), round(cy - r * math.sin(a[i + 1]), 4),
             round(cx + r * math.cos(a[i]), 4), round(cy + r * math.sin(a[i + 1]), 4)] for i in range(4)]


def pad_boxes(p, g=0.05):
    if p.GetShape(pcbnew.F_Cu) == pcbnew.PAD_SHAPE_CIRCLE:
        c = p.GetPosition()
        return circle_cover(mm(c.x), mm(c.y), mm(p.GetSize(pcbnew.F_Cu).x) / 2 + g)
    return [box(p.GetBoundingBox(), g)]


def graphic_boxes(g):
    if g.GetClass() in ("PCB_SHAPE", "FP_SHAPE") and g.GetShape() == pcbnew.SHAPE_T_CIRCLE:
        c = g.GetCenter()
        return circle_cover(mm(c.x), mm(c.y), mm(g.GetRadius()) + mm(g.GetWidth()) / 2)
    return [box(g.GetBoundingBox())]


def stroke_est(text, h, th, angle):
    """AI Flow's own lower bound for a stroke-font text (aiflow_board::silk::text_extent)."""
    w = sum(0.3 if c in "iljI1.,:;'!|` " else 0.6 for c in text) * h + th
    hh = h + th
    s, c = abs(math.sin(math.radians(angle))), abs(math.cos(math.radians(angle)))
    return w * c + hh * s, w * s + hh * c


obstacles, inventory, marks = [], [], []
for f in b.GetFootprints():
    ref = f.GetReference()
    lib = str(f.GetFPID().GetLibItemName())
    top_pads = [bx for p in f.Pads() if p.IsOnLayer(pcbnew.F_Cu) or p.IsOnLayer(pcbnew.F_Mask) for bx in pad_boxes(p)]
    bot_pads = [bx for p in f.Pads() if p.IsOnLayer(pcbnew.B_Cu) or p.IsOnLayer(pcbnew.B_Mask) for bx in pad_boxes(p)]
    fab = [box(g.GetBoundingBox()) for g in f.GraphicalItems() if g.GetLayer() == pcbnew.F_Fab and g.GetClass() != "PCB_TEXT"]
    crt = [g for g in f.GraphicalItems() if g.GetLayer() == pcbnew.F_CrtYd]
    bcrt = [g for g in f.GraphicalItems() if g.GetLayer() == pcbnew.B_CrtYd]
    u = lambda bs: [min(x[0] for x in bs), min(x[1] for x in bs), max(x[2] for x in bs), max(x[3] for x in bs)]
    fabg = [g for g in f.GraphicalItems() if g.GetLayer() == pcbnew.F_Fab and g.GetClass() != "PCB_TEXT"]
    if lib.startswith("Adom_TestPoint") and fabg:
        body_top = [bx for g in fabg for bx in graphic_boxes(g)]          # a test point's body is its pad (F.Fab circle)
    elif lib.startswith(("MachineContact", "MachinePin")) and crt:
        body_top = [bx for g in crt for bx in graphic_boxes(g)]           # a pin or contact: its courtyard circle
    else:
        body_top = [u(fab)] if fab else [u([box(g.GetBoundingBox()) for g in crt])] if crt else []
    layers = ["F.SilkS"]
    obstacles.append(dict(id=ref, kind="footprint", layer="F.SilkS", boxes=body_top + top_pads,
                          source="fitted body (F.Fab outline; round parts by their courtyard circle) and every pad + 0.05 mm (round pads as their circle)"))
    if bot_pads:
        layers.append("B.SilkS")
        obstacles.append(dict(id=ref, kind="footprint", layer="B.SilkS", boxes=[bx for g in bcrt for bx in graphic_boxes(g)] + bot_pads,
                              source="the pin under the board (B.CrtYd circle) and every pad + 0.05 mm"))
    inventory.append(dict(id=ref, kind="footprint", layers=layers))
    for g in f.GraphicalItems():
        if g.GetLayer() in SILK and g.GetClass() != "PCB_TEXT":
            obstacles.append(dict(id=f"{ref}:silk", kind="silk", layer=SILK[g.GetLayer()], boxes=graphic_boxes(g)))
vias = [t for t in b.GetTracks() if t.GetClass() == "PCB_VIA"]
for i, v in enumerate(vias):
    for layer in ("F.SilkS", "B.SilkS"):
        obstacles.append(dict(id=f"via{i}", kind="via", layer=layer, box=box(v.GetBoundingBox())))
    inventory.append(dict(id=f"via{i}", kind="via", layers=["F.SilkS", "B.SilkS"]))


def mark_of(t, mid):
    sh = t.GetEffectiveTextShape()
    strokes = [box(s.BBox()) for s in sh.GetSubshapes()]
    h, th, ang = mm(t.GetTextHeight()), mm(t.GetTextThickness()), t.GetTextAngleDegrees()
    ew, eh = stroke_est(t.GetShownText(True), h, th, ang)
    ux0, uy0 = min(s[0] for s in strokes), min(s[1] for s in strokes)
    ux1, uy1 = max(s[2] for s in strokes), max(s[3] for s in strokes)
    # the strokes' union must span at least AI Flow's lower bound of the text: stretch the outermost strokes if not
    if ux1 - ux0 < ew or uy1 - uy0 < eh:
        cx, cy = (ux0 + ux1) / 2, (uy0 + uy1) / 2
        strokes.append([round(cx - max(ew, ux1 - ux0) / 2, 4), round(cy - max(eh, uy1 - uy0) / 2, 4),
                        round(cx + max(ew, ux1 - ux0) / 2, 4), round(cy + max(eh, uy1 - uy0) / 2, 4)])
    return dict(id=mid, text=t.GetShownText(True), layer=SILK[t.GetLayer()], boxes=strokes, heightMm=round(h, 3),
                angleDeg=round(ang, 1))


for f in b.GetFootprints():
    r = f.Reference()
    if r.IsVisible() and r.GetLayer() in SILK:
        marks.append(mark_of(r, f"{f.GetReference()}-ref"))
seen = {}
for d in b.GetDrawings():
    if d.GetClass() == "PCB_TEXT" and d.GetLayer() in SILK:
        k = d.GetShownText(True)
        seen[k] = seen.get(k, 0) + 1
        marks.append(mark_of(d, f"text:{k}:{seen[k]}"))
json.dump(dict(obstacles=obstacles, inventory=inventory, marks=marks, source=sys.argv[1],
               note="measured from the saved board with pcbnew 10.0.6: one box per stroke of KiCad's stroke font, round pads and circles as four stacked boxes"),
          open(sys.argv[2], "w"), indent=0)
print(f"{len(inventory)} inventory items ({len(vias)} vias), {len(obstacles)} obstacles, {len(marks)} marks")
