master
John Lauer Hero: datasheet note moved below the Worldsemi logo and reworded for the dashed boxes; hidden 300 um solder-ball layer 982b906 16d ago
#!/usr/bin/env python3
"""Hero GLB: a hidden 'Solder balls' layer showing where the 300 um jet-paste balls land on the footprint's pads.

Follows the Adom footprint standard (john/adom-solder-ball-layer-standard): balls 0.30 mm across at 0.25 mm pitch
(adjacent balls overlap slightly, by design), each drawn as a hemisphere with its flat face on the pad, dull leaded
solder grey (63Sn/37Pb), the layer hidden by default. Ball positions use the same rule as adom-footprint's paste-dot
overlay (1.0.26): per SMD pad, a grid at 0.25 mm pitch centred on the pad, kept 0.06 mm inside the pad edge.
For this footprint that is 2 x 2 balls per 0.7 x 0.7 mm pad, 16 in all, identical to `adom-footprint embed`.

usage: hero_post_solderballs.py HERO.glb [FOOTPRINT.kicad_mod]   (in place; refuses to run twice)
The footprint defaults to the single .kicad_mod beside the GLB."""
import glob, json, math, os, re, struct, sys

D, PITCH, MARGIN = 0.30, 0.25, 0.06          # mm
MM = 0.001
Z0 = -0.016                                  # flat face just above the copper fill (-0.018 mm), under the chip seat
SEG, RINGS = 16, 6
NODE = "Solder balls - 300 um jet paste"
TIP = ("Jet-paste solder ball, 0.30 mm across, 0.25 mm pitch (Adom 300 um solder-ball layer standard).\n"
       "63Sn/37Pb leaded paste; 2 x 2 per pad on this footprint, 16 in all.")


def read_glb(p):
    b = open(p, "rb").read(); n = struct.unpack("<I", b[12:16])[0]; js = json.loads(b[20:20 + n]); o = 20 + n
    return js, bytearray(b[o + 8:o + 8 + struct.unpack("<I", b[o:o + 4])[0]] if o < len(b) else b"")


def write_glb(p, js, bin_):
    j = json.dumps(js, separators=(",", ":")).encode(); j += b" " * (-len(j) % 4); bin_ += b"\0" * (-len(bin_) % 4)
    open(p, "wb").write(struct.pack("<III", 0x46546C67, 2, 12 + 8 + len(j) + 8 + len(bin_)) + struct.pack("<II", len(j), 0x4E4F534A) + j
                       + struct.pack("<II", len(bin_), 0x004E4942) + bytes(bin_))


def pads(mod):
    t = open(mod).read(); out = []
    for m in re.finditer(r'\(pad\s+"?([^"\s]*)"?\s+smd\s+\w+\s+\(at\s+([-\d.]+)\s+([-\d.]+)(?:\s+[-\d.]+)?\)\s+\(size\s+([-\d.]+)\s+([-\d.]+)\)', t):
        out.append(dict(num=m.group(1), x=float(m.group(2)), y=-float(m.group(3)), w=float(m.group(4)), h=float(m.group(5))))   # KiCad y down -> y up
    return out


def grid(c, span):
    n = max(1, int(math.floor((span - 2 * MARGIN - D) / PITCH + 1e-9)) + 1)
    return [c + (i - (n - 1) / 2) * PITCH for i in range(n)]


glb = sys.argv[1]
mod = sys.argv[2] if len(sys.argv) > 2 else (glob.glob(os.path.join(os.path.dirname(os.path.abspath(glb)), "*.kicad_mod")) or [None])[0]
if not mod: sys.exit("no .kicad_mod beside the GLB; pass one")
js, bin_ = read_glb(glb)
if any(n.get("name") == NODE for n in js["nodes"]): sys.exit(json.dumps(dict(glb=glb, skip="solder balls already present")))
balls = [(x, y, p["num"]) for p in pads(mod) for y in grid(p["y"], p["h"]) for x in grid(p["x"], p["w"])]


def buf(data, target=None):
    global bin_
    bin_ += b"\0" * (-len(bin_) % 4)
    bv = dict(buffer=0, byteOffset=len(bin_), byteLength=len(data)); bv.update({"target": target} if target else {})
    js.setdefault("bufferViews", []).append(bv); bin_ += data
    return len(js["bufferViews"]) - 1


# one hemisphere mesh at the origin (mm), dome up (+Z), flat disc down; every ball is a node translated onto its pad
r = D / 2; P, N, I = [], [], []
for k in range(RINGS + 1):                   # dome: rings from the equator (k=0) to the pole
    th = (math.pi / 2) * k / RINGS
    for s in range(SEG + 1):
        ph = 2 * math.pi * s / SEG; nx, ny, nz = math.cos(th) * math.cos(ph), math.cos(th) * math.sin(ph), math.sin(th)
        P.append((r * nx, r * ny, r * nz)); N.append((nx, ny, nz))
for k in range(RINGS):
    for s in range(SEG):
        a = k * (SEG + 1) + s; b = a + SEG + 1
        I += [a, a + 1, b, a + 1, b + 1, b]
c0 = len(P); P.append((0, 0, 0)); N.append((0, 0, -1))
for s in range(SEG + 1):                     # flat face on the pad
    ph = 2 * math.pi * s / SEG; P.append((r * math.cos(ph), r * math.sin(ph), 0)); N.append((0, 0, -1))
for s in range(SEG):
    I += [c0, c0 + 2 + s, c0 + 1 + s]
pos = b"".join(struct.pack("<3f", x * MM, y * MM, z * MM) for x, y, z in P)
nrm = b"".join(struct.pack("<3f", *n) for n in N)
idx = b"".join(struct.pack("<H", i) for i in I)
acc = js.setdefault("accessors", [])
acc.append(dict(bufferView=buf(pos, 34962), componentType=5126, count=len(P), type="VEC3",
                min=[-r * MM, -r * MM, 0], max=[r * MM, r * MM, r * MM]))
acc.append(dict(bufferView=buf(nrm, 34962), componentType=5126, count=len(N), type="VEC3"))
acc.append(dict(bufferView=buf(idx, 34963), componentType=5123, count=len(I), type="SCALAR"))
js.setdefault("materials", []).append(dict(name="Solder ball - 63Sn/37Pb, dull", doubleSided=True,
                                           pbrMetallicRoughness=dict(baseColorFactor=[0.60, 0.61, 0.63, 1.0], metallicFactor=0.55, roughnessFactor=0.6)))
js.setdefault("meshes", []).append(dict(name="solder_ball_300um", primitives=[dict(
    attributes=dict(POSITION=len(acc) - 3, NORMAL=len(acc) - 2), indices=len(acc) - 1, material=len(js["materials"]) - 1)]))
mesh_i = len(js["meshes"]) - 1
kids = []
for x, y, num in balls:
    js["nodes"].append(dict(name="solder_ball_pad%s_%+.3f_%+.3f" % (num, x, y), mesh=mesh_i, translation=[x * MM, y * MM, Z0 * MM],
                            extras=dict(tooltip="Pad %s solder ball\n%s" % (num, TIP))))
    kids.append(len(js["nodes"]) - 1)
js["nodes"].append(dict(name=NODE, children=kids, extras=dict(
    adomLayer=dict(key="solder_balls", label="Solder balls (300 um jet paste)", group="Footprint", hidden=True), tooltip=TIP)))
js["scenes"][js.get("scene", 0)]["nodes"].append(len(js["nodes"]) - 1)
js["buffers"][0]["byteLength"] = len(bin_) + (-len(bin_) % 4)
js["asset"].setdefault("extras", {}).setdefault("adomHero", {}).setdefault("layers", {})["solder_balls"] = dict(
    what="300 um jet-paste solder balls on the footprint pads, hidden by default", source="john/adom-solder-ball-layer-standard; " + os.path.basename(mod))
write_glb(glb, js, bin_)
print(json.dumps(dict(glb=glb, balls=len(balls), per_pad=len(balls) // max(1, len(pads(mod))), footprint=os.path.basename(mod))))