main
John Lauer 3D model from KiCad's own package generator at the datasheet's values: radiused gull-wing leads in matte tin, reviewed… 9ad862b
#!/usr/bin/env python3
"""Put a KiCad-generator chip model onto its component page (staged copy), with every record kept consistent.
usage: update_page.py <slug> <page dir>   (chip facts in CHIPS below; assets from work/<slug>/out)
Files written into the page dir (the push lists them; nothing is deleted):
  <slug>.step, <slug>-etched.step, <slug>.glb, <slug>-etched.glb, <slug>-hero.glb (+ .overlay.json),
  lod/* (4 GLB + 4 previews + manifest), docs/proof/* (overlay, before/after, etch check, mark plan, hero
  preview), docs/models/* thumbnails, provenance/previous/<slug>-cadquery-square-fold.step (the replaced
  model), provenance/tools/kicad-generator/* (how it was made), models.json, provenance/3d-model.json,
  PROVENANCE.md, README.md."""
import sys, os, json, shutil, hashlib, subprocess, datetime, re

W = os.path.expanduser("~/project/kicad-gen/work")
PY = os.path.expanduser("~/project/kicad-gen/venv/bin/python")
TODAY = "2026-10-21"
GEN = "kicad-packages3D-generator (gitlab.com/kicad/libraries/kicad-packages3D-generator, commit e272fcc, GW_QFP_SOIC_SSOP_TSSOP_SOT/make_gw, CadQuery 2.8.0)"
METHOD = ("section through one lead's centre line (plane y = lead centre), cut at the 0.25 mm gauge plane (z = 0.25); "
          "L = distance from the lead tip to where the lead's lower outer surface crosses the gauge plane (JEDEC definition), "
          "provenance/tools/kicad-generator/measure.py")

CHIPS = {
    "stm32g431cbt6": dict(
        mpn="STM32G431CBT6", key="LQFP-48_7x7mm_Pitch0.5mm", params="stm32.json", lead_y=0.25,
        overlay="stm32g431cbt6-overlay-ds12589-p174.png",
        kicad_lib="KiCad packages3D LQFP-48_7x7mm_P0.5mm.step (CC-BY-SA 4.0)",
        prev_kicad="provenance/previous/stm32g431cbt6-kicad-generic.step",
        check=dict(what="L foot to the 0.25 mm gauge plane", ds="0.45 / 0.60 / 0.75", lib="0.444", new="0.600",
                   verdict="KiCad's shipped STEP is 6 um under ST's 0.45 mm minimum. KiCad's current generator, run with its own LQFP-48 parameters, already gives 0.600 mm; it was run with ST Table 94's typical values."),
        values="ST DS12589 Table 94 typical values: D = E 9.00, D1 = E1 7.00, A1 0.10, A2 1.40 (A 1.50), b 0.22, e 0.50, L 0.60 to the gauge plane; c 0.145 (midpoint of 0.09 to 0.20: no typical printed); KiCad's bend radii R1 = R2 = 0.10 (JEDEC MS-026 range), body draft 12 deg and edge fillet 0.05.",
        rows=[("D, E lead tip to tip", "8.80 / 9.00 / 9.20", "9.000"), ("D1, E1 body", "6.80 / 7.00 / 7.20", "7.000"), ("A overall height", "max 1.60", "1.500"),
              ("A1 standoff", "0.05 / - / 0.15", "0.100"), ("A2 body thickness", "1.35 / 1.40 / 1.45", "1.400"), ("b lead width", "0.17 / 0.22 / 0.27", "0.220"),
              ("c lead thickness", "0.09 / - / 0.20", "0.145"), ("e pitch", "0.50", "0.500"), ("L foot (to the 0.25 mm gauge plane)", "0.45 / 0.60 / 0.75", "0.600 (measured)"),
              ("L1 lead length", "1.00", "1.000"), ("Bend radii R1, R2 (not printed; JEDEC MS-026)", "R1 >= 0.08, R2 0.08 to 0.20", "0.10, 0.10")],
        etch_note="etched with `--over-vendor-mark`: the generated body has no printed marking, but its 0.05 mm top edge fillets read as glyph faces to the vendor-mark detector; the date and revision codes were added with provenance/tools/extra_marks.py exactly as before",
        pin1="KiCad's pin-1 dimple (generator default) replaces the former 0.75 mm dimple; ST's figure shows a pin-1 identification circle at that corner",
    ),
    "opa2365aidr": dict(
        mpn="OPA2365AIDR", key="SOIC-8_3.9x4.9mm_Pitch1.27mm", params="opa.json", lead_y=0.635,
        overlay="opa2365aidr-overlay-sbos365e-p31.png",
        kicad_lib="KiCad packages3D SOIC-8_3.9x4.9mm_P1.27mm.step (CC-BY-SA 4.0)",
        prev_kicad="provenance/previous/opa2365aidr-kicad-generic.step",
        check=dict(what="L foot to the 0.25 mm gauge plane", ds="0.40 / - / 1.27", lib="0.362", new="0.460",
                   verdict="KiCad's shipped STEP is 38 um under TI's 0.40 mm minimum, so the package was regenerated with KiCad's own generator from TI's drawing values."),
        values="TI SBOS365E p.31 (D, R-PDSO-G8, 4040047-3/M; JEDEC MS-012 AA): body 4.90 x 3.90, span 6.00, b 0.41, e 1.27 (printed mid/typ); A1 0.16, height 1.69, c 0.21 and L 0.46 to the gauge plane measured off TI's to-scale drawing (TI prints limits only); KiCad's bend radii 0.10, body draft 9 deg.",
        rows=[("Body length", "4.80 / - / 5.00", "4.900"), ("Body width", "3.80 / - / 4.00", "3.900"), ("Lead tip to tip", "5.80 / - / 6.20", "6.000"),
              ("Overall height", "max 1.75", "1.690"), ("Standoff", "0.10 / - / 0.25", "0.160"), ("Lead width", "0.31 / - / 0.51", "0.410"),
              ("Lead thickness", "0.13 / - / 0.25", "0.210"), ("Pitch", "1.27", "1.270"), ("Foot (to the 0.25 mm gauge plane)", "0.40 / - / 1.27", "0.460 (measured)"),
              ("Foot angle", "0 to 8 deg", "0 deg (KiCad's generator draws a flat foot)"), ("Bend radii (drawn curved, not printed)", "-", "0.10, 0.10")],
        etch_note="etched with the same inputs as before (line O2365A, planner layout); gate PASS",
        pin1="KiCad's pin-1 dimple (generator default) inside TI's Pin 1 Index Area; TI does not specify its form",
    ),
    "ap2112k-3-3trg1": dict(
        mpn="AP2112K-3.3TRG1", key="SOT-23-5", params="ap.json", lead_y=0.0,
        overlay="ap2112k-3-3trg1-overlay-ds39724-p14.png",
        kicad_lib="KiCad packages3D SOT-23-5.step (CC-BY-SA 4.0)",
        prev_kicad="provenance/previous/ap2112k-3-3trg1-kicad-generic.step",
        check=dict(what="overall height (J + K)", ds="max 1.40 (K 1.00 / 1.10 / 1.30)", lib="1.55", new="1.15",
                   verdict="No KiCad library model fits: SOT-23-5 is 1.55 mm tall (0.15 over Diodes' 1.40 maximum) and TSOT-23-5 0.95 mm (under the 1.00 minimum body). KiCad's generator was run with Diodes' printed typical values."),
        values="Diodes DS39724 p.14 (SOT25) typical values: H 3.00 body length, B 1.60 body width, C 2.80 tip to tip, K 1.10 body + J 0.05 standoff (top 1.15), A 0.38 lead width, M 0.15 lead thickness, L 0.40 flat foot, D 0.95 pitch, N 0.75 parting line above the body bottom; KiCad's bend radii 0.10 and body draft 8 deg. N needed a one-line generator patch (provenance/tools/kicad-generator/kicad-generator-A2_b.patch): the stock generator always centres the leads on the body (N would be 0.55).",
        rows=[("H body length", "2.90 / 3.00 / 3.10", "3.000"), ("B body width", "1.50 / 1.60 / 1.70", "1.600"), ("C lead tip to tip", "2.70 / 2.80 / 3.00", "2.800"),
              ("K body thickness", "1.00 / 1.10 / 1.30", "1.100"), ("J standoff", "0.013 / 0.05 / 0.10", "0.050"), ("Overall height J + K", "max 1.40", "1.150"),
              ("A lead width", "0.35 / 0.38 / 0.50", "0.380"), ("M lead thickness", "0.10 / 0.15 / 0.20", "0.150"), ("L foot (flat)", "0.35 / 0.40 / 0.55", "0.400"),
              ("D pitch", "0.95", "0.950"), ("N parting line above body bottom", "0.70 / 0.75 / 0.80", "0.750 (measured)"), ("Bend radii (not printed)", "-", "0.10, 0.10")],
        etch_note="etched with the same inputs as before (line G3P, planner layout); gate PASS",
        pin1="KiCad's small pin-1 dimple (generator default for SOT-23-5); Diodes' SOT25 drawing shows no pin-1 mark",
    ),
}

def sha(p): return hashlib.sha256(open(p, "rb").read()).hexdigest()
def blob(p): return subprocess.run(["git", "hash-object", p], capture_output=True, text=True).stdout.strip()
def cp(src, dst):
    os.makedirs(os.path.dirname(dst) or ".", exist_ok=True); shutil.copy2(src, dst)

def main(slug, page):
    c = CHIPS[slug]; D = f"{W}/{slug}"; O = f"{D}/out"
    stats = {s["file"]: s for s in json.load(open(f"{O}/stats.json"))}
    # 1. keep the replaced square-fold model, then copy the new files in
    old_plain = f"{page}/{slug}.step"; prev_sq = f"{page}/provenance/previous/{slug}-cadquery-square-fold.step"
    if not os.path.exists(prev_sq): cp(old_plain, prev_sq)
    old_hash = json.load(open(f"{page}/provenance/3d-model.json"))["model"]["sha256"]
    for f in (f"{slug}.step", f"{slug}-etched.step"): cp(f"{D}/{f}", f"{page}/{f}")
    for f in (f"{slug}.glb", f"{slug}-etched.glb", f"{slug}-hero.glb", f"{slug}-hero.overlay.json"): cp(f"{O}/{f}", f"{page}/{f}")
    for l in ("high", "medium", "low", "super-low"):
        for ext in ("glb", "png"): cp(f"{O}/lod/{slug}-{l}.{ext}", f"{page}/lod/{slug}-{l}.{ext}")
    cp(f"{O}/{c['overlay']}", f"{page}/docs/proof/{c['overlay']}")
    cp(f"{O}/{slug}-leads-before-after.png", f"{page}/docs/proof/{slug}-leads-before-after.png")
    cp(f"{O}/{slug}-hero-preview.png", f"{page}/docs/proof/{slug}-hero-preview.png")
    for f in (f"{slug}-etched.check.png", f"{slug}-etched.check.json", f"{slug}-etched.mark-plan.json"):
        if os.path.exists(f"{D}/{f}"): cp(f"{D}/{f}", f"{page}/docs/proof/{f}")
    T = f"{page}/provenance/tools/kicad-generator"
    for f in ("kgen.py", "measure.py", "materials.py", "render.py", "assets.sh", "update_page.py", "kicad-generator-A2_b.patch"): cp(f"{W}/{f}", f"{T}/{f}")
    cp(os.path.expanduser("~/project/kicad-gen/node3/lod.mjs"), f"{T}/lod.mjs")
    cp(f"{W}/{c['params']}", f"{T}/params.json"); cp(f"{D}/superlow.json", f"{T}/params-superlow.json")
    cp(f"{D}/overlay.json", f"{T}/overlay.json")
    # 2. thumbnails for every model the page lists
    M = json.load(open(f"{page}/models.json"))
    paths = [m["path"] for m in M["models"]]
    if f"provenance/previous/{slug}-cadquery-square-fold.step" not in paths:
        M["models"].append(dict(path=f"provenance/previous/{slug}-cadquery-square-fold.step", format="step",
            title=f"Previous plain model: hand-built square-fold leads (CadQuery, {slug})", status="previous", slot=None, variant="original",
            use=["archive"], unique="The square-fold model this page carried from 2026-10-19 to 2026-10-21; replaced by KiCad's package generator (see provenance/3d-model.json)",
            marking=None, role="datasheet-built", colours=True, derived_from=None, source="provenance/tools/build_*.py (CadQuery)",
            thumb=f"docs/models/{slug}-cadquery-square-fold-step-top.png", thumb_bottom=f"docs/models/{slug}-cadquery-square-fold-step-bottom.png"))
    for m in M["models"]:
        f = f"{page}/{m['path']}"
        if not os.path.exists(f): continue
        for k, view in (("thumb", "top"), ("thumb_bottom", "bottom")):
            if m.get(k):
                subprocess.run([PY, f"{W}/render.py", f, f"{page}/{m[k]}", "--view", view, "--size", "320",
                                "--glbtool", f"{page}/provenance/tools/glbtool.py"], capture_output=True)
        m["sha256"], m["git_blob"], m["bytes"] = sha(f), blob(f), os.path.getsize(f)
        if m["path"] in (f"{slug}.step", f"{slug}-etched.step"):
            m["role"] = "generated"; m["source"] = "KiCad package generator, see provenance/3d-model.json"
            m["unique"] = ("The board-CAD model: KiCad's package generator at the datasheet's values, with the manufacturer's real top marking in the Adom.LaserEtch group"
                           if "etched" in m["path"] else "Same package with no marking, for anyone who marks their own boards")
        if m["path"].endswith(".glb") and m["path"] != f"{slug}-hero.glb":
            m["source"] = "step2glb 0.13.2 + glTF Transform 3.10.1 (batching, quantization, meshopt simplification) + materials.py (matte tin leads)"
    M["updated"] = TODAY
    json.dump(M, open(f"{page}/models.json", "w"), indent=1)
    # 3. the LOD manifest
    steps = [json.loads(l) for l in open(f"{O}/lod-steps.jsonl") if l.strip()]
    man = json.load(open(f"{page}/lod/manifest.json"))
    man["source"] = dict(path=f"{slug}-etched.step", sha256=sha(f"{page}/{slug}-etched.step"))
    man["generator"] = "KiCad package generator -> step2glb 0.13.2 convert --compress none -> glTF Transform 3.10.1 (dedup, join per named part, prune; weld + meshopt simplify on low and super-low; KHR_mesh_quantization on medium, low, super-low) -> materials.py (tin leads, epoxy body); previews by provenance/tools/kicad-generator/render.py"
    deriv = {"high": "etched STEP, uncompressed; primitives batched per part (lossless): chip, tin leads, pin-1 dimple and the full real marking",
             "medium": "etched STEP, batched, quantized (KHR_mesh_quantization); marking kept, its 5 um lift checked after quantization",
             "low": "plain STEP, batched, welded and simplified (meshopt, ratio 0.5, error 0.2 % of the part), quantized; the marking is omitted (unreadable at this size), pin-1 dimple kept",
             "super-low": "the same generator run with an undrafted, unfilleted body (leads unchanged), simplified (ratio 0.25, error 0.4 %), quantized; terminals and pin-1 cue kept, marking omitted"}
    for lv in man["levels"]:
        l = lv["level"]; f = f"{page}/lod/{slug}-{l}.glb"; s = stats[f"{slug}-{l}.glb"]; png = f"{page}/lod/{slug}-{l}.png"
        lv.update(path=f"lod/{slug}-{l}.glb", sha256=sha(f), sizeBytes=os.path.getsize(f), decodedBufferBytes=s["decodedBytes"],
                  triangles=s["triangles"], primitives=s["primitives"], boundsMm=s["boundsMm"], markingIncluded=bool(s["mark"]), mark=s["mark"],
                  helperNodes=s["helperNodes"], extensions=s["extensions"], derivation=deriv[l],
                  review="candidate: inspected as cached images (depth-buffered render); not qualified in a streaming LOD viewer")
        from PIL import Image
        im = Image.open(png)
        lv["preview"] = dict(path=f"lod/{slug}-{l}.png", sha256=sha(png), sizeBytes=os.path.getsize(png), width=im.width, height=im.height,
                             view="iso (front-left, from above), same camera on every level", sourceLevelSha256=lv["sha256"])
    man["updated"] = TODAY
    json.dump(man, open(f"{page}/lod/manifest.json", "w"), indent=1)
    # 4. the provenance record
    P = json.load(open(f"{page}/provenance/3d-model.json"))
    md = P["model"]
    prev_sq_rec = dict(file=f"provenance/previous/{slug}-cadquery-square-fold.step", sha256=old_hash,
                       what=f"hand-built CadQuery model ({md.get('builder')}), square lead folds, 2026-10-19 to 2026-10-21",
                       why="square 90 degree lead folds and flat grey leads looked fake; it had replaced KiCad's model over a foot-length miss that KiCad's own generator fixes")
    P["model"] = dict(file=f"{slug}.step", sha256=sha(f"{page}/{slug}.step"),
        builder=GEN, generator_key=c["key"], params="provenance/tools/kicad-generator/params.json",
        how="provenance/tools/kicad-generator/kgen.py runs KiCad's make_gw on the datasheet values; the generator's flat-foot length is tuned until the measured gauge-plane foot equals the datasheet value",
        values=c["values"], cad_kernel="CadQuery 2.8.0 (OpenCascade)",
        frame=md.get("frame"), dimensions_mm=[dict(item=a, datasheet_min_typ_max=b, model=v) for a, b, v in c["rows"]],
        kicad_library_check=dict(model=c["kicad_lib"], file=c["prev_kicad"], measured=c["check"]["what"], datasheet=c["check"]["ds"],
                                 kicad_library=c["check"]["lib"], regenerated=c["check"]["new"], method=METHOD, verdict=c["check"]["verdict"]),
        pin1_mark=c["pin1"], leads="KiCad's radiused gull-wing leads; GLB material 'Adom.Leads (matte tin)': metallic 1.0, roughness 0.35",
        overlay_proof=f"docs/proof/{c['overlay']}", before_after=f"docs/proof/{slug}-leads-before-after.png",
        previous=[md.get("previous"), prev_sq_rec] if isinstance(md.get("previous"), dict) else [prev_sq_rec])
    P["etched"] = dict(file=f"{slug}-etched.step", sha256=sha(f"{page}/{slug}-etched.step"), note=c["etch_note"],
                       check_image=f"docs/proof/{slug}-etched.check.png", plan=f"docs/proof/{slug}-etched.mark-plan.json")
    P["glb"] = {f: sha(f"{page}/{f}") for f in (f"{slug}.glb", f"{slug}-etched.glb", f"{slug}-hero.glb")}
    P["date"] = TODAY
    P["classification"] = ("Generated: KiCad's own package generator at the datasheet's values, with the datasheet's real marking, produced by Claude (AI) for John Lauer; "
                           "not manufacturer CAD and not a manufacturer certification")
    P.setdefault("tools", {}).update({"kicad-packages3D-generator": "e272fcc (archived repo; Adom A2_b patch)", "gltf-transform": "3.10.1", "meshoptimizer": "0.20.0",
                                      "adom-aiflow-etch": "adom/adom-aiflow (installed)", "cadquery": "2.8.0"})
    P["outline"] = dict(P.get("outline", {}), note="kept from 2026-10-19: the symbol's chip outline is a top view, and the top view is unchanged apart from the pin-1 dimple's size and place")
    P["license"] = "Model generated with KiCad's package generator (GPL-2.0 scripts; KiCad 3D models CC-BY-SA 4.0 with the KiCad libraries' exception); Adom marking and artwork CC-BY-4.0."
    json.dump(P, open(f"{page}/provenance/3d-model.json", "w"), indent=1)
    print("OK", slug, "files written; models", len(M["models"]))

if __name__ == "__main__":
    main(sys.argv[1], sys.argv[2])