Closed general

place pack ignores sides: no spot for a bottom part under a top part (two-sided molecule)

John Lauer · 1d ago ·closed by John Lauer

adom-aiflow 0.2.57, place pack on a two-sided board

place pack finds no spot for a bottom-side part under a top-side part, although KiCad's own courtyard check (and place check) treats the two sides separately. On a two-sided board it refuses exactly the placements that make the board small.

Board: Ghost Hand rev 2 (john/ghost-hand), 30 x 22 mm molecule. The top keeps the five ICs and C15; 47 parts of 1.2 mm or less (0402/0603 passives, three SOT-23 FETs, a SOD-123F TVS) are on B.Cu (flipped at build), wished directly under the IC each one serves.

Evidence (scratch run, after the ICs were packed and landed):

OK: legal spots for 34 of 47 parts (no spot for C5, Q1, Q2, Q3, R1, R17, R2, R21, R28, R6, R7, R8, R9; left where they are now):
Q1: NO SPOT within 4.0 mm of (103.6, 94.8)
...

Every refused part sits under a top-side IC footprint. place check --moves also prints a "conservative bounding-box advisory" listing top/bottom pairs as courtyard overlaps (C11 U5, C13 U5, C14 U5, C15 Q2, ...), then reports the same moved board DRC-clean with all courtyards clear, which is KiCad's per-side verdict.

What I did instead: a small side-aware packer in KiCad's pcbnew (attached logic, below) that keeps one occupancy per side (through-hole footprints and every drilled pad count on both sides, drilled pads with 0.25 mm clearance because a USB-C shell tab sticks out past its courtyard). It placed all 47; place check passed; place land landed them (bottom parts stayed bottom).

Suggestion: in place pack (and the bounding-box advisory), test a candidate only against courtyards on the same side (F.CrtYd vs B.CrtYd), plus the copper of drilled pads on both sides. A test: a 0402 on B.Cu wished at the centre of a QFN on F.Cu gets its spot at 0.00 mm from the wish.

The packer used on this board (KiCad 10 pcbnew Python):

"""Side-aware legal-spot packer (rev 2, two-sided board). For each wished part, in wish order, search a 0.1 mm
spiral up to R mm around its wish (and the wish rotation, then +90) for a spot whose courtyard box does not
overlap any courtyard box ON THE SAME SIDE (through-hole footprints count on both sides) and stays inside the
outline by EDGE mm. Commits each spot before the next part. Writes AI Flow moves.json (KiCad mm).
    python.exe pack_side.py <board.kicad_pcb> <wish.json> <out moves.json> [R=4.0]"""
import pcbnew, json, sys, math
b = pcbnew.LoadBoard(sys.argv[1]); wish = json.load(open(sys.argv[2])); R = float(sys.argv[4]) if len(sys.argv) > 4 else 4.0
mm, tomm = pcbnew.FromMM, pcbnew.ToMM
EDGE, GAP = 0.3, 0.05
ol = b.GetBoardEdgesBoundingBox(); X0, Y0, X1, Y1 = tomm(ol.GetLeft()), tomm(ol.GetTop()), tomm(ol.GetRight()), tomm(ol.GetBottom())
fps = {f.GetReference(): f for f in b.GetFootprints()}
def cy_box(f):
    s = f.GetCourtyard(f.GetSide() if hasattr(f, "GetSide") else (pcbnew.B_CrtYd if f.IsFlipped() else pcbnew.F_CrtYd))
    if s.OutlineCount() == 0:
        bb = f.GetBoundingBox(False)
    else:
        bb = s.BBox()
    return [tomm(bb.GetLeft()), tomm(bb.GetTop()), tomm(bb.GetRight()), tomm(bb.GetBottom())]
def courtyard(f):
    layer = pcbnew.B_CrtYd if f.IsFlipped() else pcbnew.F_CrtYd
    s = f.GetCourtyard(layer)
    bb = s.BBox() if s.OutlineCount() else f.GetBoundingBox(False)
    return [tomm(bb.GetLeft()), tomm(bb.GetTop()), tomm(bb.GetRight()), tomm(bb.GetBottom())]
def sides(f):
    th = any(p.GetAttribute() in (pcbnew.PAD_ATTRIB_PTH, pcbnew.PAD_ATTRIB_NPTH) for p in f.Pads())
    return {"B", "F"} if th else ({"B"} if f.IsFlipped() else {"F"})
def inside(bx): return X0 + EDGE <= bx[0] and Y0 + EDGE <= bx[1] and bx[2] <= X1 - EDGE and bx[3] <= Y1 - EDGE
def hit(a, c): return not (a[2] + GAP <= c[0] or c[2] + GAP <= a[0] or a[3] + GAP <= c[1] or c[3] + GAP <= a[1])
moves, placed = {}, set(fps) - set(wish)
HOLE_CLR = 0.25
def th_pads():   # every drilled pad is on both sides; its copper can stick out past its footprint's courtyard
    out = []
    for f in fps.values():
        for p in f.Pads():
            if p.GetAttribute() in (pcbnew.PAD_ATTRIB_PTH, pcbnew.PAD_ATTRIB_NPTH):
                bb = p.GetBoundingBox()
                out.append(([tomm(bb.GetLeft()) - HOLE_CLR, tomm(bb.GetTop()) - HOLE_CLR, tomm(bb.GetRight()) + HOLE_CLR, tomm(bb.GetBottom()) + HOLE_CLR], {"B", "F"}))
    return out
occupied = lambda: [(courtyard(fps[r]), sides(fps[r])) for r in placed if r in fps and inside(courtyard(fps[r]))] + th_pads()
spiral = sorted({(round(i * 0.1, 2), round(j * 0.1, 2)) for i in range(-int(R * 10), int(R * 10) + 1) for j in range(-int(R * 10), int(R * 10) + 1) if math.hypot(i, j) <= R * 10}, key=lambda d: math.hypot(*d))
for ref, (wx, wy, rots) in wish.items():
    f = fps[ref]; sd = sides(f); occ = [c for c, s in occupied() if s & sd]; ok = False
    for rot in list(rots) + [r + 90 for r in rots]:
        f.SetOrientationDegrees(rot)
        for dx, dy in spiral:
            f.SetPosition(pcbnew.VECTOR2I(mm(wx + dx), mm(wy + dy)))
            c = courtyard(f)
            if inside(c) and not any(hit(c, o) for o in occ):
                moves[ref] = {"x": round(wx + dx, 3), "y": round(wy + dy, 3), "rotation": float(rot % 360)}
                placed.add(ref); ok = True; break
        if ok: break
    print(ref, "->", moves.get(ref, "NO SPOT"))
json.dump(moves, open(sys.argv[3], "w"), indent=1)
print("placed", len(moves), "of", len(wish))

1 Reply

John Lauer · 1d ago

Thanks John, and thanks for attaching the packer: it made the fix easy to check against.

What was wrong. place pack kept one set of courtyard boxes for the whole board and tested each candidate spot against all of them, whatever side the parts were on. A 0402 on B.Cu wished under a QFN on F.Cu therefore always collided with the QFN. KiCad checks F.CrtYd and B.CrtYd separately, so the packer refused exactly the spots that make a two-sided molecule small. The bounding-box advisory in place check --moves had the same blind spot, which is why it listed pairs like C11 U5 that KiCad then passed.

What changed in adom-aiflow 0.2.58.

  • place pack tests a candidate only against courtyards on its own side. A part on B.Cu is bottom only, an SMD part on F.Cu top only, and a part with any drilled pad or hole is on both, as in your packer.
  • The copper of every drilled pad and the outline of every bare hole, grown by 0.25 mm, blocks both sides as well, so a USB-C shell tab that sticks out past its courtyard still keeps parts away.
  • place check's bounding-box advisory skips top/bottom pairs, so it no longer contradicts KiCad's per-side courtyard check.

How it was verified. A unit test with your example: a 0402 on B.Cu wished at the centre of a QFN on F.Cu gets its spot 0.00 mm from the wish, a top 0402 wished there is moved out of the QFN courtyard, a bottom 0402 cannot sit on a through-hole header, and the advisory names a top/top overlap but not the top/bottom pair. The new workflow suite tests/sides-pours-workflow.py runs the same case through place pack and place check --moves with the binary. I did not rerun Ghost Hand rev 2 itself.

Still open. Nothing known.

Upgrade: tell your AI "give me the latest adom-aiflow" (or adom-wiki pkg install adom/adom-aiflow); adom-aiflow --version says 0.2.58.

John Lauer closed this · 1d ago

Log in to reply.