← Commit history
README.md+31
@@ -45,6 +45,37 @@ Same part, different packaging or reel option. - 3D model: TSSOP-14 (KiCad packages3D, STEP) - 3D model files: `as5047p-atsm-etched.step` / `as5047p-atsm-etched.glb` carry the MPN laser-etched on the die in a toggleable `Adom.LaserEtch` group (Fusion-ready); `as5047p-atsm.step` / `as5047p-atsm.glb` are the bare package body. +## 3D model check++KiCad's library model is kept. KiCad's library model is kept: every printed dimension is inside ams' limits (foot 0.529 mm against 0.45 to 0.75, A2 0.90 against 0.80 to 1.05) and its leads have KiCad's radiused bends. ams' drawing is not to scale (the top view is drawn with a break line), so the proof is this measured table, not an overlay.++| Dimension (mm) | Datasheet min / typ / max | Model |+|---|---|---|+| D body length | 4.90 / 5.00 / 5.10 | 5.000 |+| E width over pins | 6.40 BSC | 6.400 |+| E1 body width | 4.30 / 4.40 / 4.50 | 4.400 |+| A overall height | max 1.20 | 1.000 |+| A1 standoff | 0.05 / - / 0.15 | 0.100 |+| A2 body thickness | 0.80 / 1.00 / 1.05 | 0.900 |+| e pitch | 0.65 BSC | 0.650 |+| L foot (to the 0.25 mm gauge plane) | 0.45 / 0.60 / 0.75 | 0.529 (measured) |+| R, R1 bend radii | min 0.09 | 0.10, 0.10 (KiCad) |++Measured section through one lead's centre line, cut at the 0.25 mm gauge plane; L = lead tip to where the lead's lower outer surface crosses that plane (JEDEC), provenance/tools/kicad-generator/measure.py. Record: [provenance/3d-model.json](provenance/3d-model.json).++## Detail levels for board and digital-twin viewers++::adom-component-lod{src="/adom/as5047p-atsm/render/lod/manifest.json"}++| Level | File | Triangles | Draw calls | Size | Marking |+|---|---|---|---|---|---|+| high | [as5047p-atsm-high.glb](lod/as5047p-atsm-high.glb) | 2310 | 4 | 83 KiB | yes |+| medium | [as5047p-atsm-medium.glb](lod/as5047p-atsm-medium.glb) | 2310 | 4 | 61 KiB | yes |+| low | [as5047p-atsm-low.glb](lod/as5047p-atsm-low.glb) | 1094 | 3 | 30 KiB | no |+| super-low | [as5047p-atsm-super-low.glb](lod/as5047p-atsm-super-low.glb) | 740 | 3 | 23 KiB | no |++All four levels share the Z-up frame, units and seating of the library model, carry no footprint or silkscreen artwork, and keep the real terminals and the pin-1 cue; the leads are matte tin. Low and super-low omit the marking (unreadable at those sizes). Status: candidate; the cached previews were inspected, a streaming LOD viewer has not qualified them.+ ## Provenance  Data extracted from the manufacturer datasheet, not a distributor listing:
as5047p-atsm-etched.glb
⋯ 1 unchanged line ⋯
as5047p-atsm.glb
⋯ 1 unchanged line ⋯
lod/as5047p-atsm-high.glbadded
⋯ 1 unchanged line ⋯
lod/as5047p-atsm-high.pngadded
⋯ 1 unchanged line ⋯
lod/as5047p-atsm-low.glbadded
⋯ 1 unchanged line ⋯
lod/as5047p-atsm-low.pngadded
⋯ 1 unchanged line ⋯
lod/as5047p-atsm-medium.glbadded
⋯ 1 unchanged line ⋯
lod/as5047p-atsm-medium.pngadded
⋯ 1 unchanged line ⋯
lod/as5047p-atsm-super-low.glbadded
⋯ 1 unchanged line ⋯
lod/as5047p-atsm-super-low.pngadded
⋯ 1 unchanged line ⋯
lod/manifest.jsonadded+162
@@ -0,0 +1,162 @@+{+ "schema": "adom/component-lod@1",+ "mpn": "AS5047P-ATSM",+ "units": "metres (glTF)",+ "upAxis": "Z (Adom/KiCad frame; seated on z = 0, centred on the footprint origin)",+ "status": "candidate",+ "updated": "2026-10-21",+ "source": {+  "path": "as5047p-atsm-etched.step",+  "sha256": "f03f698c0c6b04890f8d06f780f3fa888883ea936256c1431d0cc7568fd6796d"+ },+ "generator": "step2glb 0.13.2 -> glTF Transform 3.10.1 (dedup, join per part, prune; weld + meshopt simplify on low/super-low; quantize) -> materials.py (tin leads); previews by render.py",+ "hints": [+  "Board-use files: no footprint pad outlines, pad labels, silkscreen or board artwork (helperNodes is empty on every level)."+ ],+ "levels": [+  {+   "level": "high",+   "path": "lod/as5047p-atsm-high.glb",+   "sha256": "c4e14a505b2e590de003feb98b2eeb21f817830475ff9faf8745cee364a7f861",+   "sizeBytes": 84976,+   "decodedBufferBytes": 81012,+   "triangles": 2310,+   "primitives": 4,+   "boundsMm": [+    -3.2,+    -2.5,+    -0.0,+    3.2,+    2.5,+    1.005+   ],+   "markingIncluded": true,+   "mark": {+    "mark_z_min_mm": 1.005,+    "mark_z_max_mm": 1.005,+    "chip_top_mm": 1.0,+    "lift_um": 5.0+   },+   "helperNodes": [],+   "extensions": [],+   "derivation": "etched STEP, uncompressed; primitives batched per part (lossless)",+   "review": "candidate: inspected as cached images (depth-buffered render); not qualified in a streaming LOD viewer",+   "preview": {+    "path": "lod/as5047p-atsm-high.png",+    "sha256": "966ad61308cb263130a771ca3704fc3556ed5c01445cfe67c4b9665b2acaf119",+    "sizeBytes": 31100,+    "width": 400,+    "height": 400,+    "view": "iso (front-left, from above), same camera on every level",+    "sourceLevelSha256": "c4e14a505b2e590de003feb98b2eeb21f817830475ff9faf8745cee364a7f861"+   }+  },+  {+   "level": "medium",+   "path": "lod/as5047p-atsm-medium.glb",+   "sha256": "5beb2d9155878393c62f1072742679c3904ec16d9904c96f40cb995a2a359c74",+   "sizeBytes": 62568,+   "decodedBufferBytes": 47436,+   "triangles": 2310,+   "primitives": 4,+   "boundsMm": [+    -3.2,+    -2.5,+    -0.0,+    3.2,+    2.5,+    1.005+   ],+   "markingIncluded": true,+   "mark": {+    "mark_z_min_mm": 1.005,+    "mark_z_max_mm": 1.005,+    "chip_top_mm": 1.0,+    "lift_um": 4.98+   },+   "helperNodes": [],+   "extensions": [+    "KHR_mesh_quantization"+   ],+   "derivation": "etched STEP, batched, quantized (KHR_mesh_quantization); marking kept",+   "review": "candidate: inspected as cached images (depth-buffered render); not qualified in a streaming LOD viewer",+   "preview": {+    "path": "lod/as5047p-atsm-medium.png",+    "sha256": "7e244afb8906462c3c52104ec7a3db5c90094c4cbc71c0a1aa0f33bdabc1b791",+    "sizeBytes": 31041,+    "width": 400,+    "height": 400,+    "view": "iso (front-left, from above), same camera on every level",+    "sourceLevelSha256": "5beb2d9155878393c62f1072742679c3904ec16d9904c96f40cb995a2a359c74"+   }+  },+  {+   "level": "low",+   "path": "lod/as5047p-atsm-low.glb",+   "sha256": "ca7e57c411c5baef3546b0e7ad6b6f2a75ffd3bbf6fb6793df08d190da7926a8",+   "sizeBytes": 30888,+   "decodedBufferBytes": 22668,+   "triangles": 1094,+   "primitives": 3,+   "boundsMm": [+    -3.2,+    -2.5,+    -0.0,+    3.2,+    2.5,+    1.0+   ],+   "markingIncluded": false,+   "mark": null,+   "helperNodes": [],+   "extensions": [+    "KHR_mesh_quantization"+   ],+   "derivation": "plain STEP, welded and simplified (meshopt, ratio 0.5, error 0.2 %), quantized; marking omitted",+   "review": "candidate: inspected as cached images (depth-buffered render); not qualified in a streaming LOD viewer",+   "preview": {+    "path": "lod/as5047p-atsm-low.png",+    "sha256": "fa6e2291ddc7a6ccdcd78a143c62fcff7fc081b332768e93537f0419e7366a7f",+    "sizeBytes": 25668,+    "width": 400,+    "height": 400,+    "view": "iso (front-left, from above), same camera on every level",+    "sourceLevelSha256": "ca7e57c411c5baef3546b0e7ad6b6f2a75ffd3bbf6fb6793df08d190da7926a8"+   }+  },+  {+   "level": "super-low",+   "path": "lod/as5047p-atsm-super-low.glb",+   "sha256": "0fd2e5f45262b03335db973360a8b689a71acf43128eacdf0e0a71be5574419c",+   "sizeBytes": 23096,+   "decodedBufferBytes": 16296,+   "triangles": 740,+   "primitives": 3,+   "boundsMm": [+    -3.2,+    -2.5,+    -0.0,+    3.2,+    2.5,+    1.0+   ],+   "markingIncluded": false,+   "mark": null,+   "helperNodes": [],+   "extensions": [+    "KHR_mesh_quantization"+   ],+   "derivation": "plain STEP, simplified (ratio 0.25, error 0.8 %), quantized; terminals and pin-1 cue kept, marking omitted",+   "review": "candidate: inspected as cached images (depth-buffered render); not qualified in a streaming LOD viewer",+   "preview": {+    "path": "lod/as5047p-atsm-super-low.png",+    "sha256": "07e8e266f99737135d810cf5281d1d9e710f47ffd963ad951cd26056bc9e527e",+    "sizeBytes": 25578,+    "width": 400,+    "height": 400,+    "view": "iso (front-left, from above), same camera on every level",+    "sourceLevelSha256": "0fd2e5f45262b03335db973360a8b689a71acf43128eacdf0e0a71be5574419c"+   }+  }+ ]+}\ No newline at end of file
provenance/3d-model.jsonadded+78
@@ -0,0 +1,78 @@+{+ "schema": "adom/3d-model-provenance@1",+ "mpn": "AS5047P-ATSM",+ "date": "2026-10-21",+ "model": {+  "file": "as5047p-atsm.step",+  "sha256": "3ee5786aae56190997aa793ba3cfc003fd616782770e29719b4562f012982245",+  "builder": "KiCad packages3D library model, unchanged",+  "values": "unchanged KiCad library model",+  "dimensions_mm": [+   {+    "item": "D body length",+    "datasheet_min_typ_max": "4.90 / 5.00 / 5.10",+    "model": "5.000"+   },+   {+    "item": "E width over pins",+    "datasheet_min_typ_max": "6.40 BSC",+    "model": "6.400"+   },+   {+    "item": "E1 body width",+    "datasheet_min_typ_max": "4.30 / 4.40 / 4.50",+    "model": "4.400"+   },+   {+    "item": "A overall height",+    "datasheet_min_typ_max": "max 1.20",+    "model": "1.000"+   },+   {+    "item": "A1 standoff",+    "datasheet_min_typ_max": "0.05 / - / 0.15",+    "model": "0.100"+   },+   {+    "item": "A2 body thickness",+    "datasheet_min_typ_max": "0.80 / 1.00 / 1.05",+    "model": "0.900"+   },+   {+    "item": "e pitch",+    "datasheet_min_typ_max": "0.65 BSC",+    "model": "0.650"+   },+   {+    "item": "L foot (to the 0.25 mm gauge plane)",+    "datasheet_min_typ_max": "0.45 / 0.60 / 0.75",+    "model": "0.529 (measured)"+   },+   {+    "item": "R, R1 bend radii",+    "datasheet_min_typ_max": "min 0.09",+    "model": "0.10, 0.10 (KiCad)"+   }+  ],+  "library_check": {+   "model": "KiCad packages3D TSSOP-14_4.4x5mm_P0.65mm.step (CC-BY-SA 4.0)",+   "measured": "every printed dimension, foot at the gauge plane",+   "datasheet": "ams AS5047P datasheet v2-00 p.31 Figure 40",+   "previous": "-",+   "regenerated": "not needed",+   "verdict": "KiCad's library model is kept: every printed dimension is inside ams' limits (foot 0.529 mm against 0.45 to 0.75, A2 0.90 against 0.80 to 1.05) and its leads have KiCad's radiused bends. ams' drawing is not to scale (the top view is drawn with a break line), so the proof is this measured table, not an overlay.",+   "method": "section through one lead's centre line, cut at the 0.25 mm gauge plane; L = lead tip to where the lead's lower outer surface crosses that plane (JEDEC), provenance/tools/kicad-generator/measure.py"+  },+  "leads": "radiused gull-wing leads; GLB material 'Adom.Leads (matte tin)': metallic 1.0, roughness 0.35"+ },+ "etched": {+  "file": "as5047p-atsm-etched.step",+  "sha256": "f03f698c0c6b04890f8d06f780f3fa888883ea936256c1431d0cc7568fd6796d",+  "note": "the page's existing MPN etch, unchanged"+ },+ "glb": {+  "as5047p-atsm.glb": "bfa2199f8606f1ac5977584e19eb6541231e5eb748b88a21896a220e9d96b329",+  "as5047p-atsm-etched.glb": "c4e14a505b2e590de003feb98b2eeb21f817830475ff9faf8745cee364a7f861"+ },+ "lod": "lod/manifest.json"+}\ No newline at end of file
provenance/tools/dims.pyadded+42
@@ -0,0 +1,42 @@+#!/usr/bin/env python3+"""Measure a gull-wing package STEP: overall extents, body size above the leads, foot widths/pitch.+usage: dims.py part.step z_body z_foot   (prints JSON)"""+import sys, json, cadquery as cq+from OCP.BRepAlgoAPI import BRepAlgoAPI_Section+from OCP.gp import gp_Pln, gp_Pnt, gp_Dir+s = cq.importers.importStep(sys.argv[1]).val()+zb, zf = float(sys.argv[2]), float(sys.argv[3])+def sect(z):+    sec = BRepAlgoAPI_Section(s.wrapped, gp_Pln(gp_Pnt(0, 0, z), gp_Dir(0, 0, 1))); sec.Build()+    return cq.Shape.cast(sec.Shape())+bb = s.BoundingBox()+b = sect(zb).BoundingBox()+edges = sect(zf).Edges()+# cluster foot edges into feet by connectivity of bboxes+boxes = [e.BoundingBox() for e in edges]+feet = []+for e in boxes:+    for f in feet:+        if e.xmin <= f[2] + 1e-3 and e.xmax >= f[0] - 1e-3 and e.ymin <= f[3] + 1e-3 and e.ymax >= f[1] - 1e-3:+            f[0], f[1], f[2], f[3] = min(f[0], e.xmin), min(f[1], e.ymin), max(f[2], e.xmax), max(f[3], e.ymax); break+    else:+        feet.append([e.xmin, e.ymin, e.xmax, e.ymax])+# merge passes+changed = True+while changed:+    changed = False+    for i in range(len(feet)):+        for j in range(i + 1, len(feet)):+            a, c = feet[i], feet[j]+            if a[0] <= c[2] + 1e-3 and a[2] >= c[0] - 1e-3 and a[1] <= c[3] + 1e-3 and a[3] >= c[1] - 1e-3:+                feet[i] = [min(a[0], c[0]), min(a[1], c[1]), max(a[2], c[2]), max(a[3], c[3])]; del feet[j]; changed = True; break+        if changed: break+# left row (min x) feet+left = sorted([f for f in feet if f[0] < bb.xmin + 0.05], key=lambda f: f[1])+widths = [round(f[3] - f[1], 3) for f in left]+cent = [(f[1] + f[3]) / 2 for f in left]+pitch = [round(cent[i + 1] - cent[i], 3) for i in range(len(cent) - 1)]+foot_len = [round(f[2] - f[0], 3) for f in left]+print(json.dumps(dict(overall=dict(x=round(bb.xlen, 3), y=round(bb.ylen, 3), z=round(bb.zmax - bb.zmin, 3), zmin=round(bb.zmin, 3)),+    body_at_z=zb, body=dict(x=round(b.xlen, 3), y=round(b.ylen, 3)), feet_total=len(feet), left_row=len(left),+    lead_width=sorted(set(widths)), pitch=sorted(set(pitch)), foot_len_at_z=sorted(set(foot_len))), indent=None))
provenance/tools/glbtool.pyadded+108
@@ -0,0 +1,108 @@+#!/usr/bin/env python3+"""GLB stats + preview render (numpy/PIL, flat shading), reading quantized accessors correctly.+usage: glbtool.py stats a.glb [...]      -> JSON per file+       glbtool.py render a.glb out.png [view=iso|top] [size=400]"""+import sys, json, hashlib, math, struct+import numpy as np+from pygltflib import GLTF2+from PIL import Image, ImageDraw+CT = {5120: np.int8, 5121: np.uint8, 5122: np.int16, 5123: np.uint16, 5125: np.uint32, 5126: np.float32}+NC = {"SCALAR": 1, "VEC2": 2, "VEC3": 3, "VEC4": 4, "MAT4": 16}+def acc(g, blob, i):+    a = g.accessors[i]; bv = g.bufferViews[a.bufferView]; dt = np.dtype(CT[a.componentType]); n = NC[a.type]+    off = (bv.byteOffset or 0) + (a.byteOffset or 0); stride = bv.byteStride or dt.itemsize * n+    raw = np.frombuffer(blob, dtype=np.uint8, count=stride * (a.count - 1) + dt.itemsize * n, offset=off)+    arr = np.lib.stride_tricks.as_strided(raw, shape=(a.count, n * dt.itemsize), strides=(stride, 1)).copy().view(dt).reshape(a.count, n).astype(np.float64)+    if a.normalized:+        arr = {np.int8: lambda x: np.maximum(x / 127, -1), np.uint8: lambda x: x / 255, np.int16: lambda x: np.maximum(x / 32767, -1), np.uint16: lambda x: x / 65535}[dt.type](arr)+    return arr+def node_mat(n):+    if n.matrix: return np.array(n.matrix).reshape(4, 4).T+    M = np.eye(4)+    if n.scale: M = np.diag(list(n.scale) + [1]) @ M+    if n.rotation:+        x, y, z, w = n.rotation+        R = np.array([[1-2*(y*y+z*z), 2*(x*y-z*w), 2*(x*z+y*w)], [2*(x*y+z*w), 1-2*(x*x+z*z), 2*(y*z-x*w)], [2*(x*z-y*w), 2*(y*z+x*w), 1-2*(x*x+y*y)]])+        R4 = np.eye(4); R4[:3, :3] = R; M = R4 @ M+    if n.translation:+        T = np.eye(4); T[:3, 3] = n.translation; M = T @ M+    return M+def walk(g):+    out = []+    def rec(i, P, path):+        n = g.nodes[i]; M = P @ node_mat(n); p = path + [n.name or str(i)]+        if n.mesh is not None: out.append((n, M, p))+        for c in n.children or []: rec(c, M, p)+    for r in g.scenes[g.scene or 0].nodes: rec(r, np.eye(4), [])+    return out+def load(f):+    g = GLTF2().load(f); return g, g.binary_blob()+def tris(f):+    g, blob = load(f); res = []+    for n, M, path in walk(g):+        for pr in g.meshes[n.mesh].primitives:+            P = acc(g, blob, pr.attributes.POSITION); P = (M @ np.c_[P, np.ones(len(P))].T).T[:, :3]+            I = acc(g, blob, pr.indices).astype(int).reshape(-1, 3) if pr.indices is not None else np.arange(len(P)).reshape(-1, 3)+            mat = g.materials[pr.material] if pr.material is not None else None+            col = mat.pbrMetallicRoughness.baseColorFactor if mat and mat.pbrMetallicRoughness else [0.6, 0.6, 0.6, 1]+            res.append(dict(path="/".join(path), P=P, I=I, col=col))+    return g, res+def stats(f):+    g, res = tris(f); data = open(f, "rb").read()+    allP = np.vstack([r["P"] for r in res])+    dec = 0+    for a in g.accessors: dec += a.count * NC[a.type] * np.dtype(CT[a.componentType]).itemsize+    names = [n.name or "" for n in g.nodes]+    helpers = [n for n in names if any(k in n.lower() for k in ("pad outline", "silkscreen", "signal name", "reference artwork", "footprint pads", "courtyard"))]+    mark = [r for r in res if "LaserEtch" in r["path"]]+    chip = [r for r in res if "LaserEtch" not in r["path"]]+    mz = None+    if mark:+        mp = np.vstack([r["P"] for r in mark]); top = max(r["P"][:, 2].max() for r in chip)+        mz = dict(mark_z_min_mm=round(mp[:, 2].min() * 1000, 4), mark_z_max_mm=round(mp[:, 2].max() * 1000, 4), chip_top_mm=round(top * 1000, 4),+                  lift_um=round((mp[:, 2].min() - top) * 1e6, 2))+    return dict(file=f.split("/")[-1], sha256=hashlib.sha256(data).hexdigest(), bytes=len(data), triangles=int(sum(len(r["I"]) for r in res)),+                primitives=len(res), decodedBytes=int(dec), boundsMm=[round(v * 1000, 3) for v in (*allP.min(0), *allP.max(0))],+                helperNodes=helpers, mark=mz, extensions=g.extensionsUsed or [])+def render(f, out, view="iso", size=400):+    g, res = tris(f)+    allP = np.vstack([r["P"] for r in res]); c = (allP.min(0) + allP.max(0)) / 2+    if view == "top":+        R = np.eye(3)+    elif view.startswith("hero"):+        # pose: reading direction (-Y in the board frame) turned to +X, then a 3/4 view from the front, above+        P90 = np.array([[0, -1, 0], [1, 0, 0], [0, 0, 1]])+        a = math.radians(28); b = math.radians(-48)+        Rz = np.array([[math.cos(a), -math.sin(a), 0], [math.sin(a), math.cos(a), 0], [0, 0, 1]])+        Rx = np.array([[1, 0, 0], [0, math.cos(b), -math.sin(b)], [0, math.sin(b), math.cos(b)]])+        R = Rx @ Rz @ P90+    else:   # iso: rotate -45 about z then tilt 55 about x (camera from front-left-up, pin-1 side)+        a = math.radians(-35); b = math.radians(-55)+        Rz = np.array([[math.cos(a), -math.sin(a), 0], [math.sin(a), math.cos(a), 0], [0, 0, 1]])+        Rx = np.array([[1, 0, 0], [0, math.cos(b), -math.sin(b)], [0, math.sin(b), math.cos(b)]])+        R = Rx @ Rz+    light = np.array([0.3, -0.5, 0.8]); light /= np.linalg.norm(light)+    polys = []+    for r in res:+        Q = (r["P"] - c) @ R.T+        for t in r["I"]:+            v = Q[t]; nrm = np.cross(v[1] - v[0], v[2] - v[0]); ln = np.linalg.norm(nrm)+            if ln == 0: continue+            nrm /= ln+            if nrm[2] < 0: nrm = -nrm+            sh = 0.35 + 0.65 * max(0, float(nrm @ light))+            col = tuple(int(255 * min(1, (x ** (1 / 2.2)) * sh)) for x in r["col"][:3])+            polys.append(((1 if "LaserEtch" in r["path"] else 0), v[:, 2].mean(), v[:, :2], col))+    polys.sort(key=lambda p: (p[0], p[1]))+    polys = [(p[1], p[2], p[3]) for p in polys]+    Q = np.vstack([p[1] for p in polys]); ext = (Q.max(0) - Q.min(0)).max()+    s = size * 0.86 / ext; S = 3+    im = Image.new("RGBA", (size * S, size * S), (0, 0, 0, 0) if view.startswith("hero") else (36, 40, 46, 255)); d = ImageDraw.Draw(im)+    for _, v, col in polys:+        d.polygon([(size * S / 2 + x * s * S, size * S / 2 - y * s * S) for x, y in v], fill=col)+    im.resize((size, size), Image.LANCZOS).save(out)+if __name__ == "__main__":+    if sys.argv[1] == "stats":+        print(json.dumps([stats(f) for f in sys.argv[2:]], indent=1))+    else:+        render(sys.argv[2], sys.argv[3], *(sys.argv[4:5] or ["iso"]), *(map(int, sys.argv[5:6])))
provenance/tools/kicad-generator/lod.mjsadded+34
@@ -0,0 +1,34 @@+// LOD derivation for a chip GLB (glTF Transform 4.1 + meshoptimizer), per wiki-component-lod:+//   batch:    join primitives per material inside each named part (body, pins, mark, Adom.LaserEtch stay+//             separate nodes), dedup, prune; geometry unchanged (lossless batching)+//   simplify: weld then error-bounded meshopt simplification to a target ratio (lower levels only)+//   quantize: KHR_mesh_quantization+// usage: node lod.mjs in.glb out.glb [--simplify ratio:error] [--quantize]+import { NodeIO } from '@gltf-transform/core';+import { ALL_EXTENSIONS } from '@gltf-transform/extensions';+import { dedup, join, prune, weld, simplify, quantize } from '@gltf-transform/functions';+import { MeshoptSimplifier } from 'meshoptimizer';++const [, , src, out, ...opts] = process.argv;+const io = new NodeIO().registerExtensions(ALL_EXTENSIONS);+const doc = await io.read(src);+const before = count(doc);+const steps = [dedup(), join({ keepNamed: true }), prune()];+const si = opts.indexOf('--simplify');+if (si >= 0) {+  const [ratio, error] = opts[si + 1].split(':').map(Number);+  await MeshoptSimplifier.ready;+  steps.push(weld(), simplify({ simplifier: MeshoptSimplifier, ratio, error, lockBorder: false }), prune());+}+if (opts.includes('--quantize')) steps.push(quantize());+await doc.transform(...steps);+await io.write(out, doc);+console.log(JSON.stringify({ out, before, after: count(doc), nodes: doc.getRoot().listNodes().filter(n => n.getMesh()).map(n => n.getName()) }));++function count(d) {+  let prims = 0, tris = 0;+  for (const m of d.getRoot().listMeshes()) for (const p of m.listPrimitives()) {+    prims++; const i = p.getIndices(); tris += (i ? i.getCount() : p.getAttribute('POSITION').getCount()) / 3;+  }+  return { prims, tris };+}
provenance/tools/kicad-generator/materials.pyadded+69
@@ -0,0 +1,69 @@+#!/usr/bin/env python3+"""Give a chip GLB physical materials, by the part each material is used on.+step2glb writes base colours only, so glTF's defaults (metallic 1, roughness 1) make every part a flat+rough metal. This sets, per part (node name from the STEP assembly):+  pins            -> "Adom.Leads (matte tin)": metallic 1.0, roughness 0.35, tin colour+  body            -> "Adom.Body (epoxy)": metallic 0.0, roughness 0.55, colour kept+  mark            -> "Adom.Pin1Mark": metallic 0.0, roughness 0.6, colour kept+  Adom.LaserEtch  -> metallic 0.0, roughness 0.7, colour kept (the reviewed mark)+Geometry, node transforms and the etch colour are untouched. Each material is used by one part only.+usage: materials.py in.glb [out.glb]   (in place when out is omitted)"""+import sys+from pygltflib import GLTF2, Material, PbrMetallicRoughness++TIN = [0.72, 0.72, 0.70, 1.0]          # linear; about sRGB 0.86 / 0.86 / 0.85, bright matte tin+SPEC = {+    "pins": ("Adom.Leads (matte tin)", 1.0, 0.35, TIN),+    "body": ("Adom.Body (epoxy)", 0.0, 0.55, None),+    "mark": ("Adom.Pin1Mark", 0.0, 0.6, None),+    "Adom.LaserEtch": (None, 0.0, 0.7, None),+}++# KiCad packages3D standard colours (linear, as step2glb writes them): black body, metal grey pins, light brown mark+KICAD = {"body": (0.019, 0.018, 0.018), "pins": (0.645, 0.638, 0.572), "mark": (0.435, 0.398, 0.316)}+def by_colour(m):+    c = (m.pbrMetallicRoughness.baseColorFactor or [0, 0, 0])[:3]+    for k, ref in KICAD.items():+        if all(abs(a - b) < 0.02 for a, b in zip(c, ref)):+            return k+    return None++def main(src, out):+    g = GLTF2().load(src)+    used = {}+    for n in g.nodes:+        if n.mesh is None:+            continue+        key0 = next((k for k in SPEC if (n.name or "").startswith(k)), None)+        for p in g.meshes[n.mesh].primitives:+            if p.material is None:+                continue+            # a KiCad library STEP is one part with three colours: tell body, pins and mark by KiCad's colours+            key = key0 or by_colour(g.materials[p.material])+            used.setdefault(p.material, set()).add(key)+            if key is None:+                continue+            # give this part its own material when another part shares it+            if len(used[p.material]) > 1:+                m = g.materials[p.material]+                g.materials.append(Material(name=m.name, pbrMetallicRoughness=PbrMetallicRoughness(+                    baseColorFactor=list(m.pbrMetallicRoughness.baseColorFactor)), doubleSided=m.doubleSided))+                p.material = len(g.materials) - 1+                used[p.material] = {key}+            name, metal, rough, col = SPEC[key]+            m = g.materials[p.material]+            pbr = m.pbrMetallicRoughness or PbrMetallicRoughness()+            pbr.metallicFactor, pbr.roughnessFactor = metal, rough+            if col:+                pbr.baseColorFactor = col+            m.pbrMetallicRoughness = pbr+            if name:+                m.name = name+    missing = [k for k in ("pins", "body") if not any(k in s for s in used.values())]+    if missing:+        sys.exit(f"ERROR: {src}: no {missing} part found (node names must come from the STEP assembly)")+    g.save(out)+    print(f"OK: {out}: " + ", ".join(f"{m.name} m{m.pbrMetallicRoughness.metallicFactor} r{m.pbrMetallicRoughness.roughnessFactor}" for m in g.materials))++if __name__ == "__main__":+    main(sys.argv[1], sys.argv[2] if len(sys.argv) > 2 else sys.argv[1])
provenance/tools/kicad-generator/measure.pyadded+12
@@ -0,0 +1,12 @@+#!/usr/bin/env python3+"""JEDEC gauge-plane foot length of any gull-wing STEP: section through the lead centred at y (on the+-x row), then the lead's crossings of z = 0.25; L = first crossing - tip. usage: measure.py part.step y"""+import sys, cadquery as cq+from OCP.BRepAlgoAPI import BRepAlgoAPI_Section+from OCP.gp import gp_Pln, gp_Pnt, gp_Dir+s = cq.importers.importStep(sys.argv[1]).val(); y = float(sys.argv[2])+sec = BRepAlgoAPI_Section(s.wrapped, gp_Pln(gp_Pnt(0, y, 0), gp_Dir(0, 1, 0))); sec.Build(); prof = cq.Shape.cast(sec.Shape())+pb = prof.BoundingBox()+sec2 = BRepAlgoAPI_Section(prof.wrapped, gp_Pln(gp_Pnt(0, 0, 0.25), gp_Dir(0, 0, 1))); sec2.Build()+xs = sorted(v.X for v in cq.Shape.cast(sec2.Shape()).Vertices() if v.X < 0)+print(f"{sys.argv[1].split('/')[-1]} y={y}: tip {pb.xmin:.4f}, crossings {[round(x,4) for x in xs]}, L = {xs[0]-pb.xmin:.4f}")
provenance/tools/kicad-generator/page_new.pyadded+149
@@ -0,0 +1,149 @@+#!/usr/bin/env python3+"""Add a 3D model record, LODs and model sections to a component page that had none (TMC2226-SA-T,+AS5047P-ATSM). usage: page_new.py <slug> <page dir>+Writes: provenance/3d-model.json, lod/ (4 GLB, 4 previews, manifest), docs/proof/*, README sections;+for a regenerated model also the STEP/GLB/hero files and provenance/previous + tools."""+import sys, os, json, shutil, hashlib, re+from PIL import Image+W = os.path.expanduser("~/project/kicad-gen/work")+slug, page = sys.argv[1], sys.argv[2]+D, O = f"{W}/{slug}", f"{W}/{slug}/out"+def sha(p): return hashlib.sha256(open(p, "rb").read()).hexdigest()+def cp(a, b): os.makedirs(os.path.dirname(b) or ".", exist_ok=True); shutil.copy2(a, b)+METHOD = ("section through one lead's centre line, cut at the 0.25 mm gauge plane; L = lead tip to where the lead's lower outer surface "+          "crosses that plane (JEDEC), provenance/tools/kicad-generator/measure.py")++if slug == "tmc2226-sa-t":+    regenerated = True+    rows = [("D body size X", "9.60 / 9.70 / 9.80", "9.700"), ("E width over pins", "6.20 / 6.40 / 6.60", "6.400"), ("E1 body size Y", "4.30 / 4.40 / 4.50", "4.400"),+            ("A total thickness", "max 1.20", "1.100"), ("A1 stand off", "0.05 / - / 0.15", "0.100"), ("A2 mold thickness", "0.90 / 1.00 / 1.10", "1.000"),+            ("b lead width", "0.20 / - / 0.29", "0.245"), ("c lead frame thickness", "0.13 / - / 0.18", "0.155"), ("e lead pitch", "0.55 / 0.65 / 0.75", "0.650"),+            ("L lead (to the 0.25 mm gauge plane, L2)", "0.45 / 0.60 / 0.75", "0.600 (measured)"), ("R, R1 bend radii", "min 0.09", "0.09, 0.09"),+            ("D1 x E2 exposed die pad", "6.20 x 2.75 REF", "6.200 x 2.750 (measured)"), ("theta 1..3 body draft", "10 / 12 / 14 deg", "12 deg")]+    check = dict(model="the page's previous model (labelled KiCad packages3D HTSSOP-28)", measured="width over pins and lead shape",+                 datasheet="E 6.20 / 6.40 / 6.60; gull-wing leads", previous="7.20 mm over pins, straight unbent leads, exposed pad 2.85 x 6.70",+                 regenerated="6.400 mm over pins, radiused gull-wing leads, foot 0.600 mm, exposed pad 6.20 x 2.75",+                 verdict="The page's previous model was a plain block with straight, unbent leads 0.6 mm over Trinamic's maximum width over pins, with the footprint's 2.85 x 6.7 mm land as its exposed pad. It was regenerated with KiCad's own package generator from the datasheet's p.81 table, with the true 6.20 x 2.75 mm exposed die pad.")+    values = ("TMC2226 datasheet Rev. 1.06 p.81 nominal values: D 9.70, E 6.40, E1 4.40, A2 1.00, e 0.65, L 0.60 (gauge plane L2 = 0.25), R = R1 = 0.09, draft 12 deg; "+              "midpoints where only limits are printed: A1 0.10, b 0.245, c 0.155; exposed die pad D1 6.20 x E2 2.75 (REF).")+    mark = dict(lines=["TMC2226-SA"], source="TMC2226 datasheet p.81, 22.2 Package Codes: Code & marking TMC2226-SA",+                note="etched with --over-vendor-mark (the generated body has no printed marking; its faces read as glyphs to the detector) and the real one-line marking; gate WARN size only (one line cannot fill the band as two lines would), every other check PASS")+else:+    regenerated = False+    rows = [("D body length", "4.90 / 5.00 / 5.10", "5.000"), ("E width over pins", "6.40 BSC", "6.400"), ("E1 body width", "4.30 / 4.40 / 4.50", "4.400"),+            ("A overall height", "max 1.20", "1.000"), ("A1 standoff", "0.05 / - / 0.15", "0.100"), ("A2 body thickness", "0.80 / 1.00 / 1.05", "0.900"),+            ("e pitch", "0.65 BSC", "0.650"), ("L foot (to the 0.25 mm gauge plane)", "0.45 / 0.60 / 0.75", "0.529 (measured)"), ("R, R1 bend radii", "min 0.09", "0.10, 0.10 (KiCad)")]+    check = dict(model="KiCad packages3D TSSOP-14_4.4x5mm_P0.65mm.step (CC-BY-SA 4.0)", measured="every printed dimension, foot at the gauge plane",+                 datasheet="ams AS5047P datasheet v2-00 p.31 Figure 40", previous="-", regenerated="not needed",+                 verdict="KiCad's library model is kept: every printed dimension is inside ams' limits (foot 0.529 mm against 0.45 to 0.75, A2 0.90 against 0.80 to 1.05) and its leads have KiCad's radiused bends. ams' drawing is not to scale (the top view is drawn with a break line), so the proof is this measured table, not an overlay.")+    values = "unchanged KiCad library model"+    mark = None++# files+stats = {s["file"]: s for s in json.load(open(f"{O}/stats.json"))}+for l in ("high", "medium", "low", "super-low"):+    for ext in ("glb", "png"): cp(f"{O}/lod/{slug}-{l}.{ext}", f"{page}/lod/{slug}-{l}.{ext}")+for f in (f"{slug}.glb", f"{slug}-etched.glb"): cp(f"{O}/{f}", f"{page}/{f}")+T = f"{page}/provenance/tools/kicad-generator"+for f in ("materials.py", "render.py", "measure.py"): cp(f"{W}/{f}", f"{T}/{f}")+cp(os.path.expanduser("~/project/kicad-gen/node3/lod.mjs"), f"{T}/lod.mjs"); cp(f"{W}/page_new.py", f"{T}/page_new.py")+if regenerated:+    prev = f"{page}/provenance/previous/{slug}-previous.step"+    if not os.path.exists(prev): cp(f"{page}/{slug}.step", prev)+    prev_hash = sha(prev)+    for f in (f"{slug}.step", f"{slug}-etched.step"): cp(f"{D}/{f}", f"{page}/{f}")+    for f in (f"{slug}-hero.glb", f"{slug}-hero.overlay.json"): cp(f"{O}/{f}", f"{page}/{f}")+    for f in ("kgen.py", "assets.sh", "kicad-generator-A2_b.patch"): cp(f"{W}/{f}", f"{T}/{f}")+    cp(f"{W}/tmc.json", f"{T}/params.json"); cp(f"{D}/superlow.json", f"{T}/params-superlow.json"); cp(f"{D}/overlay.json", f"{T}/overlay.json")+    for f in (f"{slug}-overlay-tmc2226-ds-p80.png", f"{slug}-leads-before-after.png", f"{slug}-hero-preview.png"): cp(f"{O}/{f}", f"{page}/docs/proof/{f}")+    for f in (f"{slug}-etched.check.png", f"{slug}-etched.check.json", f"{slug}-etched.mark-plan.json"): cp(f"{D}/{f}", f"{page}/docs/proof/{f}")+# LOD manifest (schema as on the other pages)+deriv = {"high": "etched STEP, uncompressed; primitives batched per part (lossless)",+         "medium": "etched STEP, batched, quantized (KHR_mesh_quantization); marking kept",+         "low": "plain STEP, welded and simplified (meshopt, ratio 0.5, error 0.2 %), quantized; marking omitted",+         "super-low": ("undrafted, unfilleted generator run, simplified (ratio 0.25, error 0.4 %), quantized; marking omitted" if regenerated+                       else "plain STEP, simplified (ratio 0.25, error 0.8 %), quantized; terminals and pin-1 cue kept, marking omitted")}+levels = []+for l in ("high", "medium", "low", "super-low"):+    f = f"{page}/lod/{slug}-{l}.glb"; s = stats[f"{slug}-{l}.glb"]; png = f"{page}/lod/{slug}-{l}.png"; im = Image.open(png)+    levels.append(dict(level=l, path=f"lod/{slug}-{l}.glb", sha256=sha(f), sizeBytes=os.path.getsize(f), decodedBufferBytes=s["decodedBytes"],+                       triangles=s["triangles"], primitives=s["primitives"], boundsMm=s["boundsMm"], markingIncluded=bool(s["mark"]), mark=s["mark"],+                       helperNodes=s["helperNodes"], extensions=s["extensions"], derivation=deriv[l],+                       review="candidate: inspected as cached images (depth-buffered render); not qualified in a streaming LOD viewer",+                       preview=dict(path=f"lod/{slug}-{l}.png", sha256=sha(png), sizeBytes=os.path.getsize(png), width=im.width, height=im.height,+                                    view="iso (front-left, from above), same camera on every level", sourceLevelSha256=sha(f))))+mpn = json.load(open(f"{page}/page.json"))["component"]["mpn"]+json.dump(dict(schema="adom/component-lod@1", mpn=mpn, units="metres (glTF)", upAxis="Z (Adom/KiCad frame; seated on z = 0, centred on the footprint origin)",+               status="candidate", updated="2026-10-21", source=dict(path=f"{slug}-etched.step", sha256=sha(f"{page}/{slug}-etched.step")),+               generator="step2glb 0.13.2 -> glTF Transform 3.10.1 (dedup, join per part, prune; weld + meshopt simplify on low/super-low; quantize) -> materials.py (tin leads); previews by render.py",+               hints=["Board-use files: no footprint pad outlines, pad labels, silkscreen or board artwork (helperNodes is empty on every level)."],+               levels=levels), open(f"{page}/lod/manifest.json", "w"), indent=1)+# provenance record+rec = dict(schema="adom/3d-model-provenance@1", mpn=mpn, date="2026-10-21",+           model=dict(file=f"{slug}.step", sha256=sha(f"{page}/{slug}.step"),+                      builder=("kicad-packages3D-generator (commit e272fcc, GW_QFP_SOIC_SSOP_TSSOP_SOT/make_gw, CadQuery 2.8.0) via provenance/tools/kicad-generator/kgen.py" if regenerated else "KiCad packages3D library model, unchanged"),+                      values=values, dimensions_mm=[dict(item=a, datasheet_min_typ_max=b, model=v) for a, b, v in rows],+                      library_check=dict(check, method=METHOD),+                      leads="radiused gull-wing leads; GLB material 'Adom.Leads (matte tin)': metallic 1.0, roughness 0.35",+                      **({"previous": dict(file=f"provenance/previous/{slug}-previous.step", sha256=prev_hash, why="block body with straight, unbent leads, 7.2 mm over pins")} if regenerated else {})),+           etched=dict(file=f"{slug}-etched.step", sha256=sha(f"{page}/{slug}-etched.step"), **({"marking": mark} if mark else {"note": "the page's existing MPN etch, unchanged"})),+           glb={f: sha(f"{page}/{f}") for f in (f"{slug}.glb", f"{slug}-etched.glb") + ((f"{slug}-hero.glb",) if regenerated else ())},+           lod="lod/manifest.json")+os.makedirs(f"{page}/provenance", exist_ok=True)+json.dump(rec, open(f"{page}/provenance/3d-model.json", "w"), indent=1)+# README+R = open(f"{page}/README.md").read()+lodrows = "\n".join(f"| {l['level']} | [{slug}-{l['level']}.glb](lod/{slug}-{l['level']}.glb) | {l['triangles']} | {l['primitives']} | {round(l['sizeBytes']/1024)} KiB | {'yes' if l['markingIncluded'] else 'no'} |" for l in levels)+dims = "\n".join(f"| {a} | {b} | {v} |" for a, b, v in rows)+if regenerated:+    R = R.replace("- 3D model: HTSSOP-28 (KiCad packages3D, STEP)", "- 3D model: HTSSOP-28 made with KiCad's own package generator at the datasheet's values, with the true exposed die pad (below)", 1)+    R = R.replace("carry the MPN laser-etched on the die", "carry the real marking (TMC2226-SA) laser-etched on the die", 1)+    R = R.replace("## CAD (KiCad official)", "## CAD (KiCad footprint and symbol; 3D model from KiCad's package generator)", 1)+    block = f"""## 3D model++The model is made with **KiCad's own package generator** (kicad-packages3D-generator, the CadQuery scripts behind KiCad's 3D library), so the leads have real radiused gull-wing bends, run with the datasheet's values. {values} The annotated hero is [{slug}-hero.glb]({slug}-hero.glb) (adom-hero 1.4, display only); the board-use model is [{slug}-etched.glb]({slug}-etched.glb).++**Why it was regenerated:** {check['verdict']}++## Leads: before and after++![Lead corner, before and after](docs/proof/{slug}-leads-before-after.png)++## Model proof: overlay on the datasheet drawing++![TMC2226 overlay](docs/proof/{slug}-overlay-tmc2226-ds-p80.png)++| Dimension (mm) | Datasheet min / typ / max | Model |+|---|---|---|+{dims}++Marking: {mark['note']}. Record: [provenance/3d-model.json](provenance/3d-model.json); the previous model is kept in [provenance/previous/](provenance/previous/{slug}-previous.step).++"""+else:+    block = f"""## 3D model check++KiCad's library model is kept. {check['verdict']}++| Dimension (mm) | Datasheet min / typ / max | Model |+|---|---|---|+{dims}++Measured {METHOD}. Record: [provenance/3d-model.json](provenance/3d-model.json).++"""+block += f"""## Detail levels for board and digital-twin viewers++::adom-component-lod{{src="/adom/{slug}/render/lod/manifest.json"}}++| Level | File | Triangles | Draw calls | Size | Marking |+|---|---|---|---|---|---|+{lodrows}++All four levels share the Z-up frame, units and seating of the library model, carry no footprint or silkscreen artwork, and keep the real terminals and the pin-1 cue; the leads are matte tin. Low and super-low omit the marking (unreadable at those sizes). Status: candidate; the cached previews were inspected, a streaming LOD viewer has not qualified them.++"""+if "## Detail levels for board" not in R:+    R = R.replace("## Provenance", block + "## Provenance", 1)+open(f"{page}/README.md", "w").write(R)+print("OK", slug)
provenance/tools/kicad-generator/render.pyadded+107
@@ -0,0 +1,107 @@+#!/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]+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("--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)+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+Image.fromarray((img * 255).astype(np.uint8)).resize((ARGS.size, ARGS.size), Image.LANCZOS).save(ARGS.out)+print("wrote", ARGS.out)
provenance/tools/overlay.pyadded+77
@@ -0,0 +1,77 @@+#!/usr/bin/env python3+"""Overlay proof: an orthographic render of a STEP blended over a datasheet drawing view.+usage: overlay.py spec.json+spec: {"step": "...", "out": "...png", "body": {"x": half, "y": half, "zmin": z},  # body box for the inner outline+       "views": [{"page": "png", "crop": [x0,y0,x1,y1], "view": "top"|"front"|"side", "rot": deg (top only),+                  "flip": false, "centre": [px,py] (page px of model origin: top=body centre, front/side=(centre, seating plane)),+                  "scale": px_per_mm, "title": "..."}],+       "caption": ["line", ...]}+front = looking along +Y (shows X horizontally, Z up); side = looking along -X (shows Y, Z)."""+import sys, json, math+import numpy as np+import cadquery as cq+from PIL import Image, ImageDraw, ImageFont, ImageFilter, ImageChops+spec = json.load(open(sys.argv[1]))+shape = cq.importers.importStep(spec["step"]).val()+verts, tris = shape.tessellate(0.005, 0.2)+V = np.array([[v.x, v.y, v.z] for v in verts]); T = np.array(tris)+bx = spec.get("body")+def proj(view, rot=0):+    if view == "top":+        a = math.radians(rot); c, s = math.cos(a), math.sin(a)+        return np.stack([c * V[:, 0] - s * V[:, 1], s * V[:, 0] + c * V[:, 1]], 1)+    if view == "front":+        return np.stack([V[:, 0], V[:, 2]], 1)+    if view == "side":+        return np.stack([V[:, 1], V[:, 2]], 1)+def body_tris():+    if not bx: return np.zeros(len(T), bool)+    c = V[T].mean(1)+    return (np.abs(c[:, 0]) <= bx["x"] + 1e-3) & (np.abs(c[:, 1]) <= bx["y"] + 1e-3) & (c[:, 2] >= bx["zmin"] - 1e-3)+BT = body_tris()+SS = 4+panels = []+try:+    font = ImageFont.truetype("/usr/share/fonts/truetype/dejavu/DejaVuSans.ttf", 22)+    small = ImageFont.truetype("/usr/share/fonts/truetype/dejavu/DejaVuSans.ttf", 18)+except Exception:+    font = small = ImageFont.load_default()+for vw in spec["views"]:+    page = Image.open(vw["page"]).convert("L")+    x0, y0, x1, y1 = vw["crop"]+    W, H = x1 - x0, y1 - y0+    P = proj(vw["view"], vw.get("rot", 0))+    if vw.get("flip"): P[:, 0] = -P[:, 0]+    s = vw["scale"]; cx, cy = vw["centre"]+    px = (cx - x0 + P[:, 0] * s) * SS; py = (cy - y0 - P[:, 1] * s) * SS+    def mask(sel):+        m = Image.new("L", (W * SS, H * SS), 0); d = ImageDraw.Draw(m)+        for t in T[sel]:+            d.polygon([(px[i], py[i]) for i in t], fill=255)+        return m.resize((W, H), Image.LANCZOS).point(lambda v: 255 if v > 127 else 0)+    m_all = mask(np.ones(len(T), bool))+    m_body = mask(BT) if BT.any() and vw["view"] == "top" else None+    def edge(m):+        return ImageChops.subtract(m, m.filter(ImageFilter.MinFilter(3)))+    base = page.crop((x0, y0, x1, y1)).convert("RGB")+    tint = Image.new("RGB", (W, H), (0, 170, 220))+    out = Image.composite(Image.blend(base, ImageChops.multiply(base, tint), 0.55), base, m_all)+    red = Image.new("RGB", (W, H), (230, 30, 30))+    out = Image.composite(red, out, edge(m_all))+    if m_body is not None:+        out = Image.composite(Image.new("RGB", (W, H), (240, 120, 0)), out, edge(m_body))+    hdr = 34+    pan = Image.new("RGB", (W, H + hdr), "white"); pan.paste(out, (0, hdr))+    ImageDraw.Draw(pan).text((6, 6), vw.get("title", vw["view"]), fill="black", font=font)+    panels.append(pan)+cap = spec.get("caption", [])+Wt = max(sum(p.width for p in panels) + 20 * (len(panels) + 1), 40 + max([int(small.getlength(c)) for c in cap] + [0]))+Ht = max(p.height for p in panels) + 40 + 26 * len(cap)+sheet = Image.new("RGB", (Wt, Ht), "white"); x = 20+for p in panels:+    sheet.paste(p, (x, 20)); x += p.width + 20+d = ImageDraw.Draw(sheet); y = max(p.height for p in panels) + 30+for line in cap:+    d.text((20, y), line, fill="black", font=small); y += 26+sheet.save(spec["out"])+print("wrote", spec["out"], sheet.size)