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.
master
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194
#!/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()