skill
molecule-publish
Public Made by Adomby adom
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.
← Commit history
Publish 2.7.0
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()