main
John Lauer Component record for rc0402fr-0733kl: files batch 1 dea64e1 21h 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)
# optional: the unmarked STEP; colours present only in the marked source are the marking,
# kept in high and medium, dropped in low and super-low (too small to read there)
plain_step = sys.argv[5] if len(sys.argv) > 5 else None
MARK_COLS = set()
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 = c != tcol and ((T[:, :, 0].min(1) < -1e-6) & (T[:, :, 0].max(1) > 1e-6)).any()  # terminals: always two boxes
        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)
    # the body box reaches the same top as the overcoat box: coplanar tops z-fight, so stop the body at the overcoat's underside
    dark = min({c for _, _, c in boxes}, key=lambda c: sum(c))
    dk = [(lo, hi) for lo, hi, c in boxes if c == dark]
    if dark != tcol and dk:
        dlo = min(lo[2] for lo, hi in dk); dhi = max(hi[2] for lo, hi in dk)
        for b in boxes:
            if b[2] not in (dark, tcol) and b[1][2] >= dhi - 1e-6:
                b[1] = b[1].copy(); b[1][2] = dlo
    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))
    # painter's sorting by triangle centroid can bury faces in the top plane (and the marking 5 um above it);
    # faces in those planes cannot be hidden in a view from above, so draw them again, plane by plane, bottom first
    near = lambda c: any(max(abs(a - b) for a, b in zip(c, m)) < 2e-3 for m in MARK_COLS)
    zt = allp[:, 2].max()
    marked = [t for t in tris if MARK_COLS and near(t[1])]
    top = zt - 0.005 if marked else zt
    plane = [t for t in tris if not (MARK_COLS and near(t[1])) and np.abs(t[0][:, 2] - top).max() < 1e-4]
    for layer in (plane, [t for t in marked if t[0][:, 2].min() > top + 1e-4]):
        if layer:
            img.alpha_composite(ortho.render(layer, "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
if plain_step:
    MARK_COLS = {c for _, c in ortho.load_tris(step)} - {c for _, c in ortho.load_tris(plain_step)}
    print("marking colours", MARK_COLS)
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 = [t for t in tris if t[1] not in MARK_COLS]
        if name == "low" and MARK_COLS:
            tris = [t for t in tris if t[1] not in MARK_COLS]
    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, "manufacturerMarking": name in ("high", "medium"),
                                 "note": "datasheet marking code shown on the top coat" if name in ("high", "medium") else "datasheet marking omitted: unreadable at this level"}
                                if MARK_COLS else {"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)