main
John Lauer Remove the AI-added MPN-etched model (the part has no marking per its datasheet; John's rule: real marking only). Add d… 9bdf1dc 18h ago
"""Board-use LOD GLBs (high, medium, low, super-low) for a small two-terminal chip.

Input: the page's board-use STEP (Z-up, mm, seated on z=0, centred).
Output (in <outdir>/lod/): high.glb medium.glb low.glb super-low.glb, a 400x300
PNG preview of each rendered from that GLB's own triangles, manifest.json
(schema adom/component-lod@1).

Every level: Z-up, metres, the same origin and pose as the STEP (no recentring,
no rotation), primitives batched by material (one per material), the STEP's own
colours.  No footprint, pad outline, label or silkscreen helpers exist in the
source, so nothing is removed.  high/medium/low are OCCT tessellations of the
exact B-rep at decreasing deflection; super-low replaces each material group by
its bounding box per side of the part (terminals, body, overcoat), which keeps
the silhouette, the terminal bands and the seating plane.

usage: lod.py part.step MPN outdir [source_label]
"""
import sys, os, json, struct, hashlib
import numpy as np
from PIL import Image, ImageDraw
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
import ortho
from OCP.BRepMesh import BRepMesh_IncrementalMesh
from OCP.BRepTools import BRepTools

step, mpn, outdir = sys.argv[1], sys.argv[2], sys.argv[3]
src_label = sys.argv[4] if len(sys.argv) > 4 else os.path.basename(step)
lod_dir = os.path.join(outdir, "lod"); os.makedirs(lod_dir, exist_ok=True)

LEVELS = [("high", 0.0005, 0.1), ("medium", 0.004, 0.35), ("low", 0.02, 0.9)]

def tris_at(lin, ang):
    # ortho.load_tris meshes with fixed params; clean and remesh by patching BRepMesh call
    orig = ortho.BRepMesh_IncrementalMesh
    def mesh(shp, _l, rel, _a, par):
        BRepTools.Clean_s(shp)
        return orig(shp, lin, False, ang, True)
    ortho.BRepMesh_IncrementalMesh = mesh
    try:
        return ortho.load_tris(step)
    finally:
        ortho.BRepMesh_IncrementalMesh = orig

def boxes_from(tris):
    groups = {}
    for p, c in tris:
        groups.setdefault(c, []).append(p)
    allp = np.concatenate([p for p, _ in tris]); xmax = allp[:, 0].max()
    endc = {}
    for p, c in tris:
        if np.allclose(p[:, 0], xmax, atol=1e-4): endc[c] = endc.get(c, 0) + 1
    tcol = max(endc, key=endc.get)
    boxes = []
    for c, ps in groups.items():
        T = np.stack(ps)
        crosses = ((T[:, :, 0].min(1) < -1e-6) & (T[:, :, 0].max(1) > 1e-6)).any()
        P = T.reshape(-1, 3)
        parts = [P] if crosses else [P[P[:, 0] < 0], P[P[:, 0] > 0]]
        for Q in parts:
            if len(Q):
                boxes.append([Q.min(0), Q.max(0), c])
    tin = min(min(abs(lo[0]), abs(hi[0])) for lo, hi, c in boxes if c == tcol)
    out = []
    for lo, hi, c in boxes:
        if c != tcol:
            lo = lo.copy(); hi = hi.copy(); lo[0] = max(lo[0], -tin); hi[0] = min(hi[0], tin)
        v = np.array([[x, y, z] for x in (lo[0], hi[0]) for y in (lo[1], hi[1]) for z in (lo[2], hi[2])])
        for a_, b_, cc, d in [(0, 1, 3, 2), (4, 6, 7, 5), (0, 4, 5, 1), (2, 3, 7, 6), (0, 2, 6, 4), (1, 5, 7, 3)]:
            out.append((v[[a_, b_, cc]], c)); out.append((v[[a_, cc, d]], c))
    return out

def write_glb(tris, path, name):
    mats = []; groups = {}
    for p, c in tris:
        if c not in groups:
            groups[c] = []; mats.append(c)
        groups[c].append(p)
    bin_ = b""; views = []; accs = []; prims = []
    def add_view(data, target):
        nonlocal bin_
        off = len(bin_); bin_ += data
        while len(bin_) % 4: bin_ += b"\0"
        views.append({"buffer": 0, "byteOffset": off, "byteLength": len(data), "target": target})
        return len(views) - 1
    decoded = 0; ntri = 0
    for mi, c in enumerate(mats):
        P = np.concatenate(groups[c]).reshape(-1, 3, 3)
        n = np.cross(P[:, 1] - P[:, 0], P[:, 2] - P[:, 0]); n /= np.linalg.norm(n, axis=1, keepdims=True) + 1e-30
        pos = (P.reshape(-1, 3) / 1000.0).astype(np.float32)          # mm -> metres
        nor = np.repeat(n, 3, axis=0).astype(np.float32)
        # weld identical (position, normal) vertices
        key = np.concatenate([np.round(pos, 9), np.round(nor, 5)], 1)
        uniq, inv = np.unique(key, axis=0, return_inverse=True)
        upos = uniq[:, :3].astype(np.float32); unor = uniq[:, 3:].astype(np.float32)
        idx = inv.astype(np.uint16 if len(uniq) < 65535 else np.uint32).ravel()
        vp = add_view(upos.tobytes(), 34962); vn = add_view(unor.tobytes(), 34962); vi = add_view(idx.tobytes(), 34963)
        accs.append({"bufferView": vp, "componentType": 5126, "count": len(upos), "type": "VEC3",
                     "min": upos.min(0).tolist(), "max": upos.max(0).tolist()})
        accs.append({"bufferView": vn, "componentType": 5126, "count": len(unor), "type": "VEC3"})
        accs.append({"bufferView": vi, "componentType": 5123 if idx.dtype == np.uint16 else 5125, "count": len(idx), "type": "SCALAR"})
        prims.append({"attributes": {"POSITION": len(accs) - 3, "NORMAL": len(accs) - 2}, "indices": len(accs) - 1, "material": mi})
        decoded += upos.nbytes + unor.nbytes + idx.nbytes; ntri += len(idx) // 3
    gl = {"asset": {"version": "2.0", "generator": "capencoder passives lod.py (OCCT tessellation)"},
          "scene": 0, "scenes": [{"nodes": [0]}],
          "nodes": [{"name": name, "mesh": 0, "extras": {"adomUpAxis": "Z", "units": "metres"}}],
          "meshes": [{"name": name, "primitives": prims}],
          "materials": [{"name": f"mat_{i}", "pbrMetallicRoughness": {"baseColorFactor": [*c, 1.0], "metallicFactor": 0.0, "roughnessFactor": 0.6}} for i, c in enumerate(mats)],
          "accessors": accs, "bufferViews": views, "buffers": [{"byteLength": len(bin_)}]}
    js = json.dumps(gl, separators=(",", ":")).encode()
    while len(js) % 4: js += b" "
    glb = struct.pack("<III", 0x46546C67, 2, 12 + 8 + len(js) + 8 + len(bin_)) + struct.pack("<II", len(js), 0x4E4F534A) + js + struct.pack("<II", len(bin_), 0x004E4942) + bin_
    open(path, "wb").write(glb)
    return {"decodedBufferBytes": decoded, "triangles": ntri, "primitives": len(prims)}

def read_glb_tris(path):
    b = open(path, "rb").read(); n = struct.unpack("<I", b[12:16])[0]
    j = json.loads(b[20:20 + n]); binc = b[20 + n + 8:]
    def acc(i, dt, w):
        a = j["accessors"][i]; v = j["bufferViews"][a["bufferView"]]
        return np.frombuffer(binc, dt, a["count"] * w, v["byteOffset"]).reshape(-1, w) if w > 1 else np.frombuffer(binc, dt, a["count"], v["byteOffset"])
    out = []
    for p in j["meshes"][0]["primitives"]:
        pos = acc(p["attributes"]["POSITION"], np.float32, 3) * 1000.0
        ia = j["accessors"][p["indices"]]
        idx = acc(p["indices"], np.uint16 if ia["componentType"] == 5123 else np.uint32, 1)
        col = tuple(j["materials"][p["material"]]["pbrMetallicRoughness"]["baseColorFactor"][:3])
        for t in idx.reshape(-1, 3):
            out.append((pos[t].astype(float), col))
    return out

ortho.VIEWS["iso"] = (tuple(np.array([0.7071, -0.7071, 0])), tuple(np.array([-0.4082, -0.4082, 0.8165])), tuple(np.array([0.5774, 0.5774, 0.5774])))
def preview(path, png):
    tris = read_glb_tris(path)
    allp = np.concatenate([t[0] for t in tris]); ext = np.abs(allp).max()
    img = ortho.render(tris, "iso", 300 / (3.3 * ext), size=(400, 300))
    bg = Image.new("RGBA", img.size, (48, 57, 68, 255)); bg.alpha_composite(img); bg.convert("RGB").save(png)

sha = lambda f: hashlib.sha256(open(f, "rb").read()).hexdigest()
levels = []
src_tris = None
for name, lin, ang in LEVELS + [("super-low", None, None)]:
    if name == "super-low":
        tris = boxes_from(src_tris)
    else:
        tris = tris_at(lin, ang)
        if name == "high":
            src_tris = tris
    path = os.path.join(lod_dir, f"{name}.glb")
    st = write_glb(tris, path, mpn)
    back = read_glb_tris(path); P = np.concatenate([t[0] for t in back])
    png = os.path.join(lod_dir, f"{name}.png"); preview(path, png)
    rec = {"level": name, "path": f"lod/{name}.glb", "sha256": sha(path), "sizeBytes": os.path.getsize(path), **st,
           "bounds": {"min": (P.min(0) / 1000).round(7).tolist(), "max": (P.max(0) / 1000).round(7).tolist()},
           "approximation": ({"method": "OCCT BRepMesh_IncrementalMesh", "linearDeflectionMm": lin, "angularDeflectionRad": ang}
                             if lin else {"method": "per-material bounding boxes split at the part centre (terminals, body, overcoat)"}),
           "detailVisibility": {"virtualMpn": False, "note": "no marking: the datasheet specifies none"},
           "review": "Candidate: rendered preview checked by AI (silhouette, terminals, seat z=0); not human-reviewed",
           "preview": {"path": f"lod/{name}.png", "sha256": sha(png), "sizeBytes": os.path.getsize(png), "width": 400, "height": 300,
                       "view": "iso", "sourceLevelSha256": sha(path)}}
    levels.append(rec)
    print(name, st, rec["bounds"])
manifest = {"schema": "adom/component-lod@1", "mpn": mpn, "upAxis": "Z", "units": "metres",
            "status": "candidate (AI-checked, not human-reviewed)",
            "purpose": "clean board-use component models: no footprint pads, outlines, labels or silkscreen; no marking (none per datasheet)",
            "source": {"path": src_label, "sha256": sha(step)},
            "sourceToDisplay": "uniform scale 0.001 (mm to m); no rotation, no translation; origin = footprint origin, seat z = 0",
            "generator": "lod.py (capencoder passives, OCCT via cadquery-ocp)",
            "removedHelpers": [], "levels": levels,
            "hints": ["All four levels share pose and units; super-low is boxes per material group, chosen for board overviews."]}
json.dump(manifest, open(os.path.join(lod_dir, "manifest.json"), "w"), indent=1)