← Commit history

Fix pins-bigger-than-holes: real Fusion board GLB, transparent FR4, gentle 2.6mm insertion float (was 17mm), solder-paste balls per pad; verified via Draco decode + headless render; skill records the fix

John Lauer ·bff3a37006 ·3mo ago ·parent 8ec554c
6 files changed +186
AdomMachinePins-4pin-animated.glb
⋯ 1 unchanged line ⋯
AdomMachinePins-complete.zip
⋯ 1 unchanged line ⋯
SKILL.md+47
@@ -142,6 +142,53 @@ These are non-negotiable lessons from building this exact library. Every one was    and mis-hit the tiny rows. (Arrow keys sent to the canvas also zoom it out.) 7. **No em-dashes anywhere on the wiki page.** They read as AI-slop; use commas, colons, or hyphens. 8. **Only ship silkscreen board shots.** A 3D board with no silkscreen is not worth showing.+9. **Keep the insertion float TINY (2 to 3mm), never a launch.** A big lift (the first build used 17mm)+   floats the pins high above their holes, so the wide contact barrel reads as "the pin is way bigger+   than the hole" even though the geometry is a correct press-fit. Lift the pin just enough to clearly+   pop out of and reseat in its OWN hole. The barrel is meant to be wider than the drill (it seats on+   the pad); only the thin tail passes through, so the pin must stay visually attached to its hole.+10. **VERIFY THE GLB BY RENDERING IT before you push, every time.** Do not ship a GLB you have not+   looked at. See the verification recipe below.++## Verify the GLB offline (the step that was skipped, causing repeated rejects)++The real Fusion board GLB (via `step2glb`) is **Draco-compressed** (`KHR_draco_mesh_compression`).+`trimesh` cannot decode Draco, so it reads ALL vertices as coincident (area 0, bounds 0) and any+render is black. That is why offline checks silently "passed" while the board looked wrong to the user.+To actually SEE it:++```+pip install --break-system-packages DracoPy pyrender+# render headless with EGL:+import os; os.environ["PYOPENGL_PLATFORM"]="egl"+import DracoPy, pygltflib, numpy as np, pyrender, trimesh+g=pygltflib.GLTF2().load(glb); blob=g.binary_blob()+for mesh in g.meshes:            # decode each primitive+  for p in mesh.primitives:+    ext=p.extensions["KHR_draco_mesh_compression"]; bv=g.bufferViews[ext["bufferView"]]+    dm=DracoPy.decode(blob[(bv.byteOffset or 0):(bv.byteOffset or 0)+bv.byteLength])+    v=np.array(dm.points).reshape(-1,3); f=np.array(dm.faces).reshape(-1,3)   # apply node world transform+# pyrender: PerspectiveCamera(znear=size*0.02) (default 0.05m near-plane clips a 0.05m board -> black),+# render with RenderFlags.SKIP_CULL_FACES (CAD normals are often inverted).+```++Render at rest AND at the animation peak (lift the PIN_* nodes) and LOOK at both. Only push once the+pins visibly seat in their own holes and the solder balls ring each pad.++## The reliable lead-GLB recipe that shipped (use the real Fusion board, do not hand-build the slab)++The user explicitly wanted the REAL Fusion board, not a hand-modeled slab. So: export the Fusion 3D+PCB to STEP, `step2glb` it (real holes, pads, pins, colors), then Draco-DECODE it and rebuild an+uncompressed GLB with `trimesh` so you can add what STEP drops and can render-verify it:+  - board/soldermask meshes -> `alphaMode=BLEND` alpha ~0.45 (transparent FR4, see the tails through it);+  - merge each pin's many sub-meshes (nodes named `MpinContact*` / `=>[0:1:1:*]`) into ONE node per pin+    (`PIN_0..PIN_3`) so it animates as a unit;+  - 300um solder-paste balls: a ring of small metallic spheres just outside each barrel base, on the+    board top surface (auto-size the ring from each pin's detected barrel radius: ~9 balls medium, ~16 large);+  - add a gentle 2 to 3mm insertion animation on the PIN_* nodes (see requirement 9);+  - keep balls STATIC (paste stays on the pad; only the pin moves).+Scripts that shipped this: `/tmp/build_board_glb.py` (decode + rebuild + balls) and+`/tmp/add_anim_board.py` (4-pin animation).  ## Recreate the lead GLB animation (all 4 pins, real holes, silk, solder balls) 
screenshots/board-3d-render.png
⋯ 1 unchanged line ⋯
scripts/add_anim_board.pyadded+35
@@ -0,0 +1,35 @@+#!/usr/bin/env python3+"""Add a gentle looping insertion animation to the 4 PIN_* nodes of the board GLB."""+import sys, numpy as np, pygltflib+from pygltflib import GLTF2, Animation, AnimationSampler, AnimationChannel, AnimationChannelTarget, Accessor, BufferView+IN, OUT = sys.argv[1], sys.argv[2]+LIFT = float(sys.argv[3])/1000.0   # mm -> meters (scene is meters)++g = GLTF2().load(IN)+pin_nodes = [i for i,n in enumerate(g.nodes) if n.name and n.name.startswith("PIN_")]+print("animating nodes:", [g.nodes[i].name for i in pin_nodes])++times = np.array([0.0,1.4,3.0,4.4,6.0], dtype=np.float32)+trans = np.array([[0,0,0],[0,0,0],[0,0,LIFT],[0,0,LIFT],[0,0,0]], dtype=np.float32)++blob = bytearray(g.binary_blob())+while len(blob)%4: blob.append(0)+def add_view(b):+    while len(blob)%4: blob.append(0)+    off=len(blob); blob.extend(b)+    g.bufferViews.append(BufferView(buffer=0, byteOffset=off, byteLength=len(b)))+    return len(g.bufferViews)-1+tv=add_view(times.tobytes())+g.accessors.append(Accessor(bufferView=tv,componentType=pygltflib.FLOAT,count=len(times),type="SCALAR",+                            min=[float(times.min())],max=[float(times.max())])); in_idx=len(g.accessors)-1+ov=add_view(trans.tobytes())+g.accessors.append(Accessor(bufferView=ov,componentType=pygltflib.FLOAT,count=len(trans),type="VEC3")); out_idx=len(g.accessors)-1++samplers=[]; channels=[]+for pn in pin_nodes:+    samplers.append(AnimationSampler(input=in_idx, output=out_idx, interpolation="LINEAR"))+    channels.append(AnimationChannel(sampler=len(samplers)-1, target=AnimationChannelTarget(node=pn, path="translation")))+g.animations=[Animation(name="insert", samplers=samplers, channels=channels)]+g.buffers[0].byteLength=len(blob); g.set_binary_blob(bytes(blob))+g.save(OUT)+print("added insertion animation, lift",LIFT*1000,"mm ->",OUT)
scripts/build_board_glb.pyadded+104
@@ -0,0 +1,104 @@+#!/usr/bin/env python3+"""Rebuild the Adom pins board lead GLB from the REAL Fusion board (Draco-decoded),+with: transparent FR4, real pins grouped per-node, 300um solder-paste balls on each pad,+and a SUBTLE insertion animation (small lift so pins clearly seat in their own holes).+Fully renderable/verifiable offline (uncompressed)."""+import DracoPy, pygltflib, numpy as np, trimesh, sys++SRC = "/tmp/AdomBoardFusion.glb"          # raw real Fusion board (Draco)+OUT = sys.argv[1] if len(sys.argv)>1 else "/tmp/AdomBoard-lead2.glb"+LIFT_MM = float(sys.argv[2]) if len(sys.argv)>2 else 2.6   # gentle press-in, not a launch++g = pygltflib.GLTF2().load(SRC); blob = g.binary_blob()+mats = g.materials or []+def matcol(idx):+    if idx is None or idx>=len(mats): return [0.7,0.7,0.7,1.0]+    pbr = mats[idx].pbrMetallicRoughness+    return list(pbr.baseColorFactor) if pbr and pbr.baseColorFactor else [0.7,0.7,0.7,1.0]++# world transforms per mesh index (+ node name)+def world_mats(g):+    W={}+    def rec(ni,M):+        n=g.nodes[ni]; L=np.eye(4)+        if n.matrix: L=np.array(n.matrix,float).reshape(4,4).T+        elif n.translation: L[:3,3]=n.translation+        Mn=M@L+        if n.mesh is not None: W.setdefault(n.mesh,[]).append((Mn,n.name or ""))+        for c in (n.children or []): rec(c,Mn)+    for r in g.scenes[g.scene or 0].nodes: rec(r,np.eye(4))+    return W+W = world_mats(g)++# decode + split into board verts and per-pin clusters (meters)+pin_pts=[]; board_meshes=[]+PIN_CENTERS=np.array([[0.010,0.010],[0.040,0.010],[0.010,0.040],[0.040,0.040]])+pin_clusters={i:[] for i in range(4)}+for mi,mesh in enumerate(g.meshes):+    for p in mesh.primitives:+        ext=p.extensions["KHR_draco_mesh_compression"]; bv=g.bufferViews[ext["bufferView"]]+        off=bv.byteOffset or 0; dm=DracoPy.decode(blob[off:off+bv.byteLength])+        v=np.array(dm.points).reshape(-1,3); f=np.array(dm.faces).reshape(-1,3)+        col=matcol(p.material)+        for (M,nm) in W.get(mi,[(np.eye(4),"")]):+            vv=(M[:3,:3]@v.T).T+M[:3,3]+            is_pin = ("MpinContact" in nm) or nm.startswith("=>")+            if is_pin:+                c=vv[:,:2].mean(0); k=int(np.argmin(((PIN_CENTERS-c)**2).sum(1)))+                pin_clusters[k].append((vv,f,col))+            else:+                board_meshes.append((vv,f,col))++# board top surface z (max z of board/pad geometry)+board_top = max(vv[:,2].max() for vv,_,_ in board_meshes)+print("board top z (mm):", round(board_top*1000,3))++scene = trimesh.Scene()++def add_mesh(vv,f,rgba,name,metal=False):+    m=trimesh.Trimesh(vertices=vv,faces=f,process=False)+    r,gr,b,a=rgba+    m.visual=trimesh.visual.TextureVisuals(material=trimesh.visual.material.PBRMaterial(+        baseColorFactor=[int(r*255),int(gr*255),int(b*255),int(a*255)],+        metallicFactor=0.9 if metal else 0.2, roughnessFactor=0.35 if metal else 0.55,+        alphaMode="BLEND" if a<0.99 else "OPAQUE", doubleSided=True))+    scene.add_geometry(m,node_name=name,geom_name=name)++# board: FR4 body + soldermask transparent (~0.5), pads/copper opaque gold+for i,(vv,f,col) in enumerate(board_meshes):+    is_green = col[1]>=col[0] and col[1]>0.12+    is_gold  = col[0]>0.6 and col[2]<0.5+    if is_gold:+        add_mesh(vv,f,[0.85,0.62,0.20,1.0],f"pad_{i}",metal=True)+    else:+        base=[0.18,0.16,0.06] if not is_green else [0.05,0.42,0.12]+        add_mesh(vv,f,base+[0.45],f"board_{i}")++# pins: merge each cluster into ONE node so it animates as a unit; detect barrel radius+pin_info={}+for k in range(4):+    parts=pin_clusters[k]+    if not parts: continue+    V=[]; F=[]; off=0+    for vv,f,col in parts:+        V.append(vv); F.append(f+off); off+=len(vv)+    V=np.vstack(V); F=np.vstack(F)+    c=V[:,:2].mean(0)+    barrel_r=float(np.hypot(V[:,0]-c[0], V[:,1]-c[1]).max())   # widest = socket barrel+    pin_info[k]=(c, barrel_r)+    add_mesh(V,F,[0.90,0.66,0.30,1.0],f"PIN_{k}",metal=True)+    print(f"PIN_{k} center=({c[0]*1000:.1f},{c[1]*1000:.1f})mm barrel OD={barrel_r*2000:.2f}mm")++# 300um solder-paste jet balls: ring just outside each barrel base, on the board top surface+BALL_R=0.00035  # 0.35mm radius (~0.7mm blob; jet paste reads better a touch above 300um)+for k,(c,barrel_r) in pin_info.items():+    ring_r=barrel_r*1.18+    n = 16 if barrel_r>0.0015 else 9+    for j in range(n):+        th=2*np.pi*j/n+        s=trimesh.creation.icosphere(subdivisions=2,radius=BALL_R)+        s.apply_translation([c[0]+ring_r*np.cos(th), c[1]+ring_r*np.sin(th), board_top])+        add_mesh(s.vertices,s.faces,[0.80,0.80,0.84,1.0],f"ball_{k}_{j}",metal=True)++scene.export(OUT)+print("exported",OUT)