master
Drew Owens Publish 2.7.0 80f61b0 2mo ago
#!/usr/bin/env python3
"""bundle_3d.py - require + produce the bundled-3D ("Ray") format (molecule-publish #16).

A board's `.kicad_pcb` references per-component 3D models by path. Stock
`${KICADn_3DMODEL_DIR}/...` models resolve on any machine (KiCad ships the dir;
`--subst-models` finds each .step). EVERY OTHER ref - absolute machine paths
(`C:/Users/noah/...`), personal env vars (`${KICAD_USER_PROJECTS_LIBRARY}`,
`${ADOM_LIBRARY_DIR}`, `${KISYS3DMOD}`) - only exists on the design machine, so
when a recipient pulls the project (APM/Nucleus "Add Project") those parts
render BLANK. The wiki 3D viewer hides the gap because it runs off the single
whole-board GLB, so the uploader never sees it.

The portable format: each custom model travels in the page's `3d/<basename>`
and its `(model ...)` path is rewritten to `${KIPRJMOD}/3d/<basename>`.

  check  <board.kicad_pcb> [--have <f>...] [--page <owner>/<slug>]
      GATE. Lists every custom model ref and whether its file is present
      (in --have, or fetched from --page's file list). Non-zero exit if any
      custom model is not bundled.

  bundle <board.kicad_pcb> --models <dir>... [--out 3d] [--write]
      Produce the format: find each custom model file under --models dirs by
      basename, copy into <out>/, and (with --write) rewrite the .kicad_pcb
      paths to `${KIPRJMOD}/3d/<basename>`. Reports what it could not find
      (those must come off the design machine).

Stdlib only. --page shells to `adom-wiki`.
"""
import argparse
import json
import os
import re
import shutil
import subprocess
import sys

STOCK_RE = re.compile(r'^\$\{KICAD\d+_3DMODEL_DIR\}/', re.I)
PRJMOD_3D_RE = re.compile(r'^\$\{KIPRJMOD\}/3d/(.+)$', re.I)
MODEL_RE = re.compile(r'\(model\s+"([^"]+)"')


def model_refs(pcb_path):
    txt = open(pcb_path, encoding="utf-8", errors="replace").read()
    return [m.group(1) for m in MODEL_RE.finditer(txt)]


def classify(ref):
    """('stock'|'bundled'|'custom', basename). stock = no action; bundled = the
    portable ${KIPRJMOD}/3d/ form (file must exist); custom = must be bundled."""
    if STOCK_RE.match(ref):
        return "stock", os.path.basename(ref)
    m = PRJMOD_3D_RE.match(ref)
    if m:
        return "bundled", os.path.basename(m.group(1))
    return "custom", os.path.basename(ref)


def _list(page_ref, path=None):
    url = f"/api/pages/{page_ref}/files" + (f"?path={path}" if path else "")
    r = subprocess.run(["adom-wiki", "api", url], capture_output=True, text=True, timeout=60)
    if r.returncode != 0:
        return []
    try:
        return [f.get("name", "") for f in json.loads(r.stdout).get("files", [])]
    except Exception:
        return []


def page_files(page_ref):
    """Top-level names PLUS the contents of the bundled-model folders (3d/,
    *.3dshapes/) - the files API returns a folder as one entry, not its files,
    so the bundled models are invisible without descending into them."""
    top = _list(page_ref)
    names = list(top)
    for entry in top:
        if entry == "3d" or entry.endswith(".3dshapes"):
            names += [f"{entry}/{n}" for n in _list(page_ref, entry)]
    return names


def have_set(have, page):
    names = set(os.path.basename(h) for h in (have or []))
    # also accept explicit 3d/<name> entries from a page listing
    if page:
        pf = page_files(page) or []
        for f in pf:
            names.add(os.path.basename(f))
    return names


def cmd_check(a):
    refs = model_refs(a.board)
    names = have_set(a.have, a.page)
    rows, missing = [], []
    seen = set()
    for ref in refs:
        kind, base = classify(ref)
        key = (kind, base)
        if key in seen:
            continue
        seen.add(key)
        if kind == "stock":
            continue
        present = base in names or f"3d/{base}" in names
        rows.append((kind, base, present))
        if not present:
            missing.append(base)

    for kind, base, present in sorted(rows, key=lambda r: (r[2], r[1])):
        print(f"  {'OK ' if present else 'MISSING'}  {kind:8}  {base}")
    n_custom = sum(1 for _, _, _ in rows)
    if missing:
        print(f"\nFAIL: {len(missing)} custom/manufacturer 3D model(s) not bundled - "
              "these render BLANK when a recipient pulls the project (molecule-pipeline "
              "#16). Bundle each into the page's 3d/ folder and rewrite its (model ...) "
              "path to ${KIPRJMOD}/3d/<basename>:\n  " + "\n  ".join(missing) +
              "\n\n`bundle_3d.py bundle <board> --models <your 3d model dirs> --write` "
              "does steps 2-3 for files it can find; anything still missing must be "
              "exported from the design machine.")
        return 1
    print(f"\nOK: all {n_custom} custom 3D model ref(s) are bundled (or none present).")
    return 0


def cmd_bundle(a):
    refs = model_refs(a.board)
    # index candidate files by basename across --models dirs
    index = {}
    for d in a.models or []:
        for root, _, files in os.walk(d):
            for f in files:
                if f.lower().endswith((".step", ".stp", ".wrl", ".glb")):
                    index.setdefault(f, os.path.join(root, f))
    os.makedirs(a.out, exist_ok=True)
    txt = open(a.board, encoding="utf-8", errors="replace").read()
    copied, missing, rewritten = [], [], 0
    seen = set()
    for ref in refs:
        kind, base = classify(ref)
        if kind == "stock" or base in seen:
            continue
        seen.add(base)
        src = index.get(base)
        # try a .step twin of a .wrl ref (kicad refs .wrl, ships .step)
        if not src and base.lower().endswith(".wrl"):
            src = index.get(base[:-4] + ".step") or index.get(base[:-4] + ".stp")
            if src:
                base = os.path.basename(src)
        if src:
            dst = os.path.join(a.out, base)
            if not os.path.exists(dst):
                shutil.copy2(src, dst)
            copied.append(base)
            # rewrite this ref (and its .wrl original) to the portable form
            portable = f"${{KIPRJMOD}}/{a.out}/{base}"
            txt2 = re.sub(r'\(model\s+"[^"]*' + re.escape(os.path.splitext(base)[0]) +
                          r'[^"]*"', f'(model "{portable}"', txt)
            if txt2 != txt:
                rewritten += 1
                txt = txt2
        else:
            missing.append(base)
    if a.write and copied:
        open(a.board, "w", encoding="utf-8").write(txt)
    print(f"bundled {len(copied)} model(s) into {a.out}/ ; rewrote {rewritten} path(s)"
          + (" (written)" if a.write else " (dry-run; pass --write)"))
    if missing:
        print("NOT FOUND under --models (export these from the design machine):\n  "
              + "\n  ".join(sorted(set(missing))))
        return 1
    return 0


def main():
    ap = argparse.ArgumentParser(description=__doc__,
                                 formatter_class=argparse.RawDescriptionHelpFormatter)
    sub = ap.add_subparsers(dest="cmd", required=True)
    c = sub.add_parser("check", help="GATE: are all custom 3D models bundled?")
    c.add_argument("board")
    c.add_argument("--have", nargs="*", help="files present on the page (basenames ok)")
    c.add_argument("--page", help="<owner>/<slug> to fetch the page's file list")
    c.set_defaults(fn=cmd_check)
    b = sub.add_parser("bundle", help="produce the format from local model dirs")
    b.add_argument("board")
    b.add_argument("--models", nargs="*", help="dirs to search for model files")
    b.add_argument("--out", default="3d")
    b.add_argument("--write", action="store_true", help="rewrite the .kicad_pcb in place")
    b.set_defaults(fn=cmd_bundle)
    a = ap.parse_args()
    sys.exit(a.fn(a))


if __name__ == "__main__":
    main()