main
John Lauer LODs, models.json, verification log, provenance and tools, README, license c543194 17h ago
#!/usr/bin/env python3
"""Depth-buffered orthographic render of a chip GLB (or STEP), for LOD previews and close-ups.
Reads geometry with the page's glbtool loader (quantized accessors handled), shades with a key + fill
light, Lambert plus a Blinn specular that follows each material's metallic/roughness (so matte tin
reads as tin and epoxy as epoxy). Orthographic, 3x supersampled.
usage: render.py model.glb|model.step out.png [--view iso|top|front|side|corner] [--size 400]
                 [--box x0,x1,y0,y1,z0,z1 (mm: frame only this region)] [--bg 36,40,46] [--alpha]
views: top, bottom, iso = from front-left-up (pin-1 side); corner = low 3/4 view onto a lead corner;
front = along +Y; side = along -X."""
import sys, math, argparse
import numpy as np
from PIL import Image
sys.path.insert(0, sys.argv[0].rsplit("/", 1)[0])

def load(path):
    if path.lower().endswith((".step", ".stp")):
        import cadquery as cq
        s = cq.importers.importStep(path).val()
        v, t = s.tessellate(0.002, 0.1)
        P = np.array([[p.x, p.y, p.z] for p in v]) / 1000.0
        return [dict(path="step", P=P, I=np.array(t), col=[0.6, 0.6, 0.6, 1], metal=0.0, rough=0.6)]
    from pygltflib import GLTF2
    import importlib.util
    spec = importlib.util.spec_from_file_location("glbtool", ARGS.glbtool)
    gt = importlib.util.module_from_spec(spec); spec.loader.exec_module(gt)
    g, res = gt.tris(path)
    # attach metallic/roughness per primitive (glbtool keeps only the colour)
    i = 0
    for n, M, p in gt.walk(g):
        for pr in g.meshes[n.mesh].primitives:
            m = g.materials[pr.material] if pr.material is not None else None
            pbr = m.pbrMetallicRoughness if m else None
            res[i]["metal"] = (pbr.metallicFactor if pbr and pbr.metallicFactor is not None else 1.0)
            res[i]["rough"] = (pbr.roughnessFactor if pbr and pbr.roughnessFactor is not None else 1.0)
            i += 1
    return res

def view_matrix(name):
    def rz(a): a = math.radians(a); return np.array([[math.cos(a), -math.sin(a), 0], [math.sin(a), math.cos(a), 0], [0, 0, 1]])
    def rx(a): a = math.radians(a); return np.array([[1, 0, 0], [0, math.cos(a), -math.sin(a)], [0, math.sin(a), math.cos(a)]])
    if name == "top": return np.eye(3)
    if name == "bottom": return rx(180)            # from below, X still to the right
    if name == "front": return rx(-90)                 # look along +Y: X right, Z up
    if name == "side": return rx(-90) @ rz(-90)        # look along -X: Y right, Z up
    if name == "corner": return rx(-68) @ rz(-38)      # low 3/4 onto the -X/-Y lead corner
    return rx(-55) @ rz(-35)                           # iso

ap = argparse.ArgumentParser()
ap.add_argument("model"); ap.add_argument("out")
ap.add_argument("--view", default="iso"); ap.add_argument("--size", type=int, default=400)
ap.add_argument("--box"); ap.add_argument("--bg", default="36,40,46")
ap.add_argument("--turn", type=float, default=0.0, help="extra turn about Z before the view (deg)")
ap.add_argument("--alpha", action="store_true", help="transparent background (RGBA PNG)")
ap.add_argument("--glbtool", default="/tmp/claude-1001/-home-adom-project/43add640-14a6-413a-b15b-ffdadeb7cf16/scratchpad/stm32/provenance/tools/glbtool.py")
ARGS = ap.parse_args()
parts = load(ARGS.model)
R = view_matrix(ARGS.view)
if ARGS.turn:
    _a = math.radians(ARGS.turn)
    R = R @ np.array([[math.cos(_a), -math.sin(_a), 0], [math.sin(_a), math.cos(_a), 0], [0, 0, 1]])
allP = np.vstack([p["P"] for p in parts]) * 1000.0
if ARGS.box:
    b = [float(v) for v in ARGS.box.split(",")]
    corners = np.array([[x, y, z] for x in b[0:2] for y in b[2:4] for z in b[4:6]])
else:
    corners = allP
Q = corners @ R.T
lo, hi = Q.min(0), Q.max(0); ctr = (lo + hi) / 2; ext = max(hi[0] - lo[0], hi[1] - lo[1]) * 1.08
SS = 3; N = ARGS.size * SS; s = N / ext
img = np.zeros((N, N, 3)); zb = np.full((N, N), -1e9)
L1 = np.array([0.35, 0.45, 0.82]); L1 /= np.linalg.norm(L1)     # key, in view space
L2 = np.array([-0.6, -0.2, 0.5]); L2 /= np.linalg.norm(L2)      # fill
V = np.array([0, 0, 1.0])
for p in parts:
    X = (p["P"] * 1000.0) @ R.T
    sx = (X[:, 0] - ctr[0]) * s + N / 2; sy = N / 2 - (X[:, 1] - ctr[1]) * s; z = X[:, 2]
    col = np.array(p["col"][:3]); metal, rough = p.get("metal", 0.0), max(p.get("rough", 0.6), 0.08)
    shin = 2.0 / (rough ** 4) - 2.0
    for t in p["I"]:
        a, b2, c = t
        v0, v1, v2 = X[a], X[b2], X[c]
        nrm = np.cross(v1 - v0, v2 - v0); ln = np.linalg.norm(nrm)
        if ln == 0: continue
        nrm /= ln
        if nrm[2] < 0: nrm = -nrm
        diff = 0.18 + 0.62 * max(0, nrm @ L1) + 0.2 * max(0, nrm @ L2)
        H = (L1 + V); H /= np.linalg.norm(H)
        spec = max(0, nrm @ H) ** min(shin, 400) * (0.9 if metal > 0.5 else 0.25) * (1 - rough * 0.6)
        base = col * (0.55 + 0.45 * (1 - metal)) if metal > 0.5 else col
        shade = np.clip(base * diff + spec * (col if metal > 0.5 else np.ones(3)), 0, 1)
        shade = shade ** (1 / 2.2)
        xs = np.array([sx[a], sx[b2], sx[c]]); ys = np.array([sy[a], sy[b2], sy[c]]); zs = np.array([z[a], z[b2], z[c]])
        x0, x1 = int(max(0, np.floor(xs.min()))), int(min(N - 1, np.ceil(xs.max())))
        y0, y1 = int(max(0, np.floor(ys.min()))), int(min(N - 1, np.ceil(ys.max())))
        if x0 > x1 or y0 > y1: continue
        gx, gy = np.meshgrid(np.arange(x0, x1 + 1) + 0.5, np.arange(y0, y1 + 1) + 0.5)
        d = (ys[1] - ys[2]) * (xs[0] - xs[2]) + (xs[2] - xs[1]) * (ys[0] - ys[2])
        if abs(d) < 1e-12: continue
        w0 = ((ys[1] - ys[2]) * (gx - xs[2]) + (xs[2] - xs[1]) * (gy - ys[2])) / d
        w1 = ((ys[2] - ys[0]) * (gx - xs[2]) + (xs[0] - xs[2]) * (gy - ys[2])) / d
        w2 = 1 - w0 - w1
        m = (w0 >= -1e-6) & (w1 >= -1e-6) & (w2 >= -1e-6)
        if not m.any(): continue
        zz = w0 * zs[0] + w1 * zs[1] + w2 * zs[2]
        sub = zb[y0:y1 + 1, x0:x1 + 1]
        upd = m & (zz > sub)
        sub[upd] = zz[upd]
        img[y0:y1 + 1, x0:x1 + 1][upd] = shade
bg = np.array([int(v) for v in ARGS.bg.split(",")]) / 255.0
img[zb < -1e8] = bg
if ARGS.alpha:
    a = (zb > -1e8).astype(np.float64)[..., None]
    rgba = np.concatenate([img * a, a], 2)      # premultiplied so the LANCZOS edge stays clean
    out = np.asarray(Image.fromarray((rgba * 255).astype(np.uint8), "RGBA").resize((ARGS.size, ARGS.size), Image.LANCZOS)).astype(np.float64)
    al = out[..., 3:] / 255.0
    out[..., :3] = np.where(al > 0, out[..., :3] / np.maximum(al, 1e-6), 0)
    Image.fromarray(np.clip(out, 0, 255).astype(np.uint8), "RGBA").save(ARGS.out)
else:
    Image.fromarray((img * 255).astype(np.uint8)).resize((ARGS.size, ARGS.size), Image.LANCZOS).save(ARGS.out)
print("wrote", ARGS.out)