master
John Lauer Add adom-verified, adom-models and adom-footprint-variants (moved from john/ pages) with their tools 7acd92d 16d ago
#!/usr/bin/env python3
"""Write or refresh a component page's models.json (schema adom/models@1): every 3D file on the page, what makes
each one different, and how they relate, for people (the ::adom-models widget) and for AIs (read this, not file names).

Run it inside a clone of the component page:

  models_json.py scan                 # add every .step/.stp/.glb not yet listed (roles guessed from page.json + names),
                                      # refresh sha256 / bytes of all, drop records whose file is gone
  models_json.py set PATH key=value [key=value ...]
                                      # describe one file: title, role, variant, status, unique, derived_from, use,
                                      # marking.text, marking.surface, marking.colour, colours, source, slot
  models_json.py thumbs               # render previews: each STEP converted on the step2glb service and shot TOP (iso,
                                      # framed on the wall the part number faces: models.json "front", a STEP-frame
                                      # vector, default [0,-1,0]) and BOTTOM (pads, polarity mark); each GLB one view
  models_json.py show

Record fields:
  path, format (step|glb), title, status (current|previous), variant (etched|plain|hero|showcase|insertion|lod|original),
  role (manufacturer|datasheet-built|photo-matched|derived), slot (the page.json component.parts key that points at it,
  if any), marking {text, surface, colour} or null, colours (true when dies/materials are coloured), unique (one line:
  what this file has that its siblings do not), use (list: board-cad|render|wiki-hero|board-insertion|archive),
  derived_from (path), source (where it came from), sha256, git_blob (the git blob SHA-1 the wiki's file listing reports,
so a viewer can check freshness without downloading), bytes, thumb (top preview PNG), thumb_bottom (STEP only: underside preview PNG).
Top level: schema, part, updated, front (STEP-frame unit vector of the face the part number is on, for framing)."""
import glob, hashlib, json, os, re, subprocess, sys, tempfile, datetime

PATH = "models.json"
HERE = os.path.dirname(os.path.abspath(__file__))


def load():
    if os.path.exists(PATH): return json.load(open(PATH))
    pj = json.load(open("page.json")) if os.path.exists("page.json") else {}
    return {"schema": "adom/models@1", "part": (pj.get("component") or {}).get("mpn") or pj.get("title") or "", "models": []}


def save(d):
    d["updated"] = datetime.date.today().isoformat()
    json.dump(d, open(PATH, "w"), indent=1, ensure_ascii=False); open(PATH, "a").write("\n")


def sha(p): return hashlib.sha256(open(p, "rb").read()).hexdigest()


def git_blob(p):
    b = open(p, "rb").read(); return hashlib.sha1(b"blob %d\0" % len(b) + b).hexdigest()   # what the wiki file listing reports


def guess(path, slots):
    n = os.path.basename(path).lower(); fmt = "glb" if n.endswith(".glb") else "step"
    r = dict(path=path, format=fmt, title=os.path.basename(path), status="previous" if path.startswith("provenance/") else "current")
    r["slot"] = next((k for k, v in slots.items() if v == path), None)
    if path.startswith("provenance/"): r.update(variant="original", use=["archive"])
    elif "hero" in n: r.update(variant="hero", use=["wiki-hero"])
    elif "insertion" in n: r.update(variant="insertion", use=["board-insertion"])
    elif "/lod/" in "/" + path: r.update(variant="lod", use=["render"])
    elif re.search(r"(uncoloured|plain|bare|-part\.glb)", n): r.update(variant="plain", use=["board-cad"] if fmt == "step" else ["render"])
    elif "etched" in n or r["slot"] in ("model_3d_step", "step"): r.update(variant="etched", use=["board-cad"] if fmt == "step" else ["render"])
    else: r.update(variant="showcase" if fmt == "glb" else "original", use=["render"] if fmt == "glb" else ["board-cad"])
    r.setdefault("unique", ""); r.setdefault("marking", None)
    return r


def scan(d):
    slots = ((json.load(open("page.json")).get("component") or {}).get("parts") or {}) if os.path.exists("page.json") else {}
    have = {m["path"]: m for m in d["models"]}
    files = sorted(p for p in glob.glob("**/*", recursive=True) if re.search(r"\.(step|stp|glb)$", p, re.I) and not p.startswith(("node_modules/", ".")))
    out = []
    for p in files:
        m = have.get(p) or guess(p, slots)
        m["sha256"] = sha(p); m["git_blob"] = git_blob(p); m["bytes"] = os.path.getsize(p)
        m["slot"] = next((k for k, v in slots.items() if v == p), m.get("slot"))
        out.append(m)
    gone = sorted(set(have) - set(files))
    d["models"] = out
    return dict(models=len(out), added=sorted(set(files) - set(have)), dropped=gone)


def setkv(d, path, kvs):
    m = next((x for x in d["models"] if x["path"] == path), None)
    if not m: sys.exit("not listed (run scan first): " + path)
    for kv in kvs:
        k, v = kv.split("=", 1)
        if k == "use": v = [x.strip() for x in v.split(",") if x.strip()]
        elif k == "colours": v = v.lower() in ("1", "true", "yes")
        elif v in ("null", "none", ""): v = None
        if k.startswith("marking."):
            m["marking"] = m.get("marking") or {}; m["marking"][k.split(".", 1)[1]] = v
        else: m[k] = v
    return m


GLB_HTML = """<!doctype html><html><body style="margin:0;background:#0d1117">
<script type="importmap">{"imports":{"three":"https://cdn.jsdelivr.net/npm/[email protected]/build/three.module.js","three/addons/":"https://cdn.jsdelivr.net/npm/[email protected]/examples/jsm/"}}</script>
<script type="module">
import * as THREE from 'three'; import {GLTFLoader} from 'three/addons/loaders/GLTFLoader.js'; import {RoomEnvironment} from 'three/addons/environments/RoomEnvironment.js';
const V=VIEWSPEC, W=640,H=480,r=new THREE.WebGLRenderer({antialias:true,preserveDrawingBuffer:true});r.setSize(W,H);r.setClearColor(0x0d1117);
r.outputColorSpace=THREE.SRGBColorSpace;document.body.appendChild(r.domElement);
const s=new THREE.Scene();s.environment=new THREE.PMREMGenerator(r).fromScene(new RoomEnvironment(),0.04).texture;s.add(new THREE.HemisphereLight(0xffffff,0x667788,1.2));const dl=new THREE.DirectionalLight(0xffffff,2.0);s.add(dl);
new GLTFLoader().load('GLB_URL',g=>{const o=g.scene;s.add(o);o.traverse(n=>{if(/LED_lit|glow/i.test(n.name||''))n.visible=false;});
const b=new THREE.Box3().setFromObject(o),c=b.getCenter(new THREE.Vector3()),sz=b.getSize(new THREE.Vector3()),z=sz.length();
// up = the model's thinnest axis (a chip); glTF from a Z-up STEP is Y-up, so map the STEP-frame 'front' (x,y,z) -> (x,z,-y)
const zup=(sz.z<=sz.y&&sz.z<=sz.x), up=zup?new THREE.Vector3(0,0,1):new THREE.Vector3(0,1,0);
const f=V.front, front=zup?new THREE.Vector3(f[0],f[1],f[2]):new THREE.Vector3(f[0],f[2],-f[1]);
const side=new THREE.Vector3().crossVectors(up,front).normalize();
let dir, camUp=up.clone();
if(V.view==='bottom'){dir=up.clone().multiplyScalar(-1).add(front.clone().multiplyScalar(0.18));camUp=front.clone();const bl=new THREE.DirectionalLight(0xffffff,1.6);bl.position.copy(up).multiplyScalar(-10);s.add(bl);}
else{dir=front.clone().multiplyScalar(0.85).add(up.clone().multiplyScalar(0.62)).add(side.clone().multiplyScalar(-0.42));}
dir.normalize();dl.position.copy(dir).multiplyScalar(z*3).add(up.clone().multiplyScalar(z));
const cam=new THREE.PerspectiveCamera(28,W/H,z/100,z*100);cam.up.copy(camUp);cam.position.copy(c).add(dir.multiplyScalar(z*(V.view==='bottom'?1.6:1.5)));cam.lookAt(c);r.render(s,cam);document.title='done';},undefined,e=>{document.title='error '+e});
</script></body></html>"""
CHROME = os.path.expanduser("~/.cache/ms-playwright/chromium_headless_shell-1243/chrome-headless-shell-linux64/chrome-headless-shell")


def render_glb(glb_path, outp, view, front):
    import base64, shutil
    tmp = tempfile.mkdtemp(prefix="mthumb-"); html = os.path.join(tmp, "t.html")    # file:// cannot fetch, so inline the GLB
    url = "data:model/gltf-binary;base64," + base64.b64encode(open(glb_path, "rb").read()).decode()
    open(html, "w").write(GLB_HTML.replace("GLB_URL", url).replace("VIEWSPEC", json.dumps(dict(view=view, front=front))))
    subprocess.run([CHROME, "--headless", "--no-sandbox", "--use-angle=swiftshader", "--enable-unsafe-swiftshader", "--allow-file-access-from-files",
                    "--virtual-time-budget=15000", "--window-size=640,480", "--screenshot=" + os.path.abspath(outp), "file://" + html], capture_output=True, text=True)
    shutil.rmtree(tmp, ignore_errors=True)
    return os.path.exists(outp) and os.path.getsize(outp) > 4000


def thumbs_for(m, stem, front):
    """STEP: convert on the step2glb service, then a TOP view framed on the marked wall and a BOTTOM view (pads, polarity
    mark). GLB: one view of the file as published."""
    if m["format"] == "step":
        tmp = tempfile.mkdtemp(prefix="mstep-"); g = os.path.join(tmp, "m.glb")
        r = subprocess.run(["step2glb", "convert", m["path"], "-o", g], capture_output=True, text=True)
        if r.returncode or not os.path.exists(g): return {}
        for post in sorted(glob.glob("provenance/tools/glb_post_*.py")):   # the page's own GLB finishing (e.g. metal), as published GLBs get it
            subprocess.run(["python3", post, g], capture_output=True)
        out = {}
        for view, key in (("top", "thumb"), ("bottom", "thumb_bottom")):
            p = stem + "." + view + ".png"
            if render_glb(g, p, view, front): out[key] = p
        return out
    p = stem + ".png"
    return {"thumb": p} if render_glb(m["path"], p, "top", front) else {}


def main():
    a = sys.argv[1:] or ["show"]; d = load()
    if a[0] == "scan": print(json.dumps(scan(d))); save(d)
    elif a[0] == "set": print(json.dumps(setkv(d, a[1], a[2:]))); save(d)
    elif a[0] == "thumbs":
        os.makedirs("docs/models", exist_ok=True); res = {}
        for f in glob.glob("docs/models/*.png"): os.remove(f)
        front = d.get("front") or [0, -1, 0]
        for m in d["models"]:
            stem = "docs/models/" + re.sub(r"[^A-Za-z0-9._-]+", "_", m["path"])
            m.pop("thumb", None); m.pop("thumb_bottom", None)
            got = thumbs_for(m, stem, front); m.update(got); res[m["path"]] = sorted(got)
        save(d); print(json.dumps(res, indent=1))
    else: print(json.dumps(d, indent=1))


if __name__ == "__main__":
    main()