← Commit history

Publish 2.7.0

Drew Owens ·80f61b0c02 ·2mo ago ·parent 3148417
4 files changed +218−2
SKILL.md+22
@@ -48,9 +48,28 @@ JSON are never user-supplied: they come out of the OCCT conversion below. | board shape | `.kicad_pro` + any `.kicad_sym`/`.pretty` used | APM render sources | | generated | GLB | OCCT conversion output — never user-supplied, always published | | generated | Metadata | OCCT footprint/symbol JSONs + the authored `package.json` |+| board shape | Bundled 3D — `3d/<basename>` for every CUSTOM `(model ...)` | see below (#16); recipients render blank without it | | optional | Silk PNGs (top/bottom) | white artwork on transparent | | optional | Extras | `.f3d`/`.usdz`/gerbers/BOM/CPL — carry what the EDA produced | +**Bundle custom 3D models so the board renders on ANY machine (#16).** The+whole-board GLB drives the wiki 3D viewer, so a board looks fine on the page+even when its per-component models are NOT bundled — the gap only appears when+a recipient pulls the project (APM/Nucleus "Add Project") and the machine-local+models render blank. Rule: stock `${KICADn_3DMODEL_DIR}/...` refs resolve+everywhere (leave them). EVERY other `(model ...)` ref — absolute paths+(`C:/Users/...`), personal env vars (`${KICAD_USER_PROJECTS_LIBRARY}`,+`${KISYS3DMOD}`, …) — must travel in the page's `3d/` folder with its path+rewritten to `${KIPRJMOD}/3d/<basename>`. Gate it:+```bash+python3 scripts/bundle_3d.py check <board>.kicad_pcb --page <owner>/<slug>+# and produce the format from your local model dirs:+python3 scripts/bundle_3d.py bundle <board>.kicad_pcb --models <dir>... --write+```+`check` fails with a per-file list of what's missing; anything it can't find+must be exported off the design machine (`kicad-cli pcb export glb+--subst-models` names each unresolved ref).+ **Rule: never accept an EDA-native GLB.** KiCad, Fusion, and service-kicad can all emit GLBs — those bypass anchoring, gold materials, meter normalization, optimization, and footprint emit. The ONLY GLB in a molecule package comes@@ -343,6 +362,9 @@ landing in APM; nothing changes on this side when it does). - [ ] `render/board.glb` mesh count == the page `<slug>.glb` (adom/wiki#91) —       `scripts/render_parity.py render/board.glb <slug>.glb`; a degraded       render is what the 3D tab serves, so refuse to ship it+- [ ] every CUSTOM `(model ...)` in the board is bundled under `3d/` (#16) —+      `scripts/bundle_3d.py check <board> --page <owner>/<slug>`; unbundled+      custom models render blank when a recipient pulls the project - [ ] molecule shape only: provenance stamp + footprint/symbol artifacts - [ ] **web-grade GLB** — the conversion stats' `web_grade.overBudget` is       `false` (< 8 MB / < 4M verts). Over budget, the component-page 3D
package.json+1−1
@@ -1,6 +1,6 @@ {   "slug": "molecule-publish",-  "version": "2.6.0",+  "version": "2.7.0",   "type": "skill",   "description": "Publish molecules AND generic boards to the Adom wiki - one publisher, two shapes: OCCT conversion (molecule mode with stats gates, or plain for boards), component packaging, adom-wiki publish, APM render assets + component dependencies. Companion to adom-molecule-import.",   "tags": [
page.json+1−1
@@ -1,6 +1,6 @@ {   "slug": "molecule-publish",-  "version": "2.6.0",+  "version": "2.7.0",   "type": "skill",   "description": "Publish molecules AND generic boards to the Adom wiki - one publisher, two shapes: OCCT conversion (molecule mode with stats gates, or plain for boards), component packaging, adom-wiki publish, APM render assets + component dependencies. Companion to adom-molecule-import.",   "tags": [
scripts/bundle_3d.pyadded+194
@@ -0,0 +1,194 @@+#!/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()