master
John Lauer Fix WS2812B-2020: correct 2020 package model, pinout and land pattern 85e9e9e 20d ago
#!/usr/bin/env python3
"""WS2812B-2020 (Worldsemi) 3D model built from the datasheet dimensions (AI-generated, 2026-09-30).

No redistributable 3D model exists for this package: Worldsemi publishes datasheets only, KiCad's
LED_WS2812B-2020_PLCC4_2.0x2.0mm footprint names a STEP that is not in kicad-packages3D, and the
LCSC/EasyEDA model is licensed for use inside JLCEDA/EasyEDA (and is 2.0 x 2.0 mm, not 2.20 x 2.00 mm).
So this is built with OpenCascade (OCP) from WS2812B-2020 datasheet V1.0 (2018-07-25):

  p.1 Mechanical Dimensions (top + back view): body 2.20 (X, across the pad columns) x 2.00 (Y);
      pad pitch 1.00 (Y); gap between the bottom pads 1.13 (so bottom pad width (2.20-1.13)/2 = 0.535);
      clear moulding 1.45 wide (X), full 2.00 length (Y); castellated half-round terminals on both
      X edges; polarity mark = dark stripe on the underside, beside the pin 1/2 column;
      top view: control IC above a row of three emitter dies labelled R G B (undimensioned sketch).
  p.2 Side View: total height 0.84, substrate 0.28; pin table 1 DO, 2 GND, 3 DI, 4 VDD.

Frame: mm, Z up, seated on z = 0, origin at the package centre, oriented for the page footprint
(KiCad convention, pin 1 top-left in the PCB view = the datasheet top view turned 180 deg):
pin 1 DO (-X,+Y), pin 2 GND (-X,-Y), pin 3 DI (+X,-Y), pin 4 VDD (+X,+Y). After the 180 deg turn the
die row sits on the pin 1/4 side of centre (+Y) and reads B G R in +X; the IC on the -Y side.
Die positions/sizes are ILLUSTRATIVE (read off the undimensioned p.1 sketch), not measured.

Usage: build_ws2812b_2020.py OUT.step REPORT.json [--plain]
  --plain: the optional uncoloured model: same body, window, terminals and polarity mark in neutral
           library colours, no die patches (no RGB indication)."""
import sys, json, math
sys.path.insert(0, '/home/adom/project/page-work/led-pass/tools')
import wl2srgb as w
from OCP.STEPCAFControl import STEPCAFControl_Writer
from OCP.STEPControl import STEPControl_AsIs
from OCP.TDocStd import TDocStd_Document
from OCP.TCollection import TCollection_ExtendedString
from OCP.XCAFDoc import XCAFDoc_DocumentTool, XCAFDoc_ColorType
from OCP.TDataStd import TDataStd_Name
from OCP.Quantity import Quantity_Color, Quantity_TOC_sRGB, Quantity_ColorRGBA
from OCP.BRepPrimAPI import BRepPrimAPI_MakeBox, BRepPrimAPI_MakeCylinder
from OCP.BRepAlgoAPI import BRepAlgoAPI_Cut, BRepAlgoAPI_Fuse, BRepAlgoAPI_Common
from OCP.BRepFilletAPI import BRepFilletAPI_MakeChamfer
from OCP.TopExp import TopExp_Explorer
from OCP.TopAbs import TopAbs_EDGE
from OCP.TopoDS import TopoDS
from OCP.gp import gp_Pnt, gp_Ax2, gp_Dir
from OCP.TopLoc import TopLoc_Location
from OCP.Interface import Interface_Static

out, rp = sys.argv[1:3]; PLAIN = '--plain' in sys.argv
# ---- datasheet dimensions (mm) ----
BX, BY = 2.20, 2.00          # p.1
H, HS = 0.84, 0.28           # p.2 side view: total, substrate
WX = 1.45                    # p.1 top view: clear moulding width
PITCH = 1.00                 # p.1
GAP = 1.13                   # p.1 back view: gap between bottom pads
PW = (BX - GAP) / 2          # 0.535 bottom pad width (X)
PL = 0.75                    # bottom pad length (Y), read off the p.1 back view (undimensioned)
TP = 0.02                    # terminal plating thickness (bottom pads stand proud by this)
RN = 0.15                    # castellation notch radius (p.1 drawing, undimensioned)
RR = 0.30                    # top-side terminal ring outer radius (p.1 top view, undimensioned)
STRIPE = (-0.53, 0.0)        # polarity stripe X span (p.1 back view, pin 1/2 side), undimensioned

def box(x0, y0, z0, x1, y1, z1): return BRepPrimAPI_MakeBox(gp_Pnt(x0, y0, z0), x1 - x0, y1 - y0, z1 - z0).Shape()
def cyl(x, y, z0, r, h): return BRepPrimAPI_MakeCylinder(gp_Ax2(gp_Pnt(x, y, z0), gp_Dir(0, 0, 1)), r, h).Shape()
def cut(a, b): return BRepAlgoAPI_Cut(a, b).Shape()
def fuse(a, b): return BRepAlgoAPI_Fuse(a, b).Shape()
def common(a, b): return BRepAlgoAPI_Common(a, b).Shape()

PINS = [('1', 'DO', -1, +1), ('2', 'GND', -1, -1), ('3', 'DI', +1, -1), ('4', 'VDD', +1, +1)]
env = box(-BX / 2, -BY / 2, 0, BX / 2, BY / 2, H)
# substrate, raised by the plating thickness so the bottom pads seat the part on z = 0
body = box(-BX / 2, -BY / 2, TP, BX / 2, BY / 2, HS)
for _, _, sx, sy in PINS: body = cut(body, cyl(sx * BX / 2, sy * PITCH / 2, 0, RN + TP, HS + 0.01))
leads = []
for num, name, sx, sy in PINS:
    cx, cy = sx * BX / 2, sy * PITCH / 2
    pad = box(min(sx * (GAP / 2), sx * BX / 2), cy - PL / 2, 0, max(sx * (GAP / 2), sx * BX / 2), cy + PL / 2, TP)
    barrel = cut(cyl(cx, cy, 0, RN + TP, HS), cyl(cx, cy, -0.01, RN, HS + 0.02))
    ring = cut(cyl(cx, cy, HS, RR, 0.012), cyl(cx, cy, HS - 0.01, RN, 0.04))
    t = fuse(fuse(cut(pad, cyl(cx, cy, -0.01, RN, 0.1)), barrel), ring)
    leads.append((f'pin{num}_{name}', common(t, env)))
win = box(-WX / 2, -BY / 2, HS, WX / 2, BY / 2, H)
ch = BRepFilletAPI_MakeChamfer(win); ex = TopExp_Explorer(win, TopAbs_EDGE)
while ex.More():                              # small chamfer on the four top edges of the moulding
    e = TopoDS.Edge(ex.Current()); ex.Next()
    from OCP.BRep import BRep_Tool
    from OCP.TopExp import TopExp
    v1, v2 = TopExp.FirstVertex_s(e), TopExp.LastVertex_s(e)
    if abs(BRep_Tool.Pnt_s(v1).Z() - H) < 1e-6 and abs(BRep_Tool.Pnt_s(v2).Z() - H) < 1e-6: ch.Add(0.03, e)
win = ch.Shape()
stripe = box(STRIPE[0], -BY / 2 + 0.05, TP - 0.005, STRIPE[1], BY / 2 - 0.05, TP)   # printed mark on the underside
parts = [('body', body, (0.95, 0.95, 0.93), None)]
if PLAIN: parts.append(('window', win, (0.90, 0.90, 0.88), None))
else: parts.append(('window', win, (0.82, 0.84, 0.86), 0.55))
parts += [(n, s, (0.78, 0.78, 0.76), None) for n, s in leads]
parts.append(('polarity_mark', stripe, (0.12, 0.12, 0.12), None))

# ---- dies (coloured model only) ----
SAT = 0.60
def ok2lin(L, C, h): return w.ok2lin([L, C * math.cos(math.radians(h)), C * math.sin(math.radians(h))])
def inside(l): return all(-1e-7 <= c <= 1 + 1e-7 for c in l)
def max_c(L, h):
    lo, hi = 0.0, 0.5
    for _ in range(50):
        m = (lo + hi) / 2
        if inside(ok2lin(L, m, h)): lo = m
        else: hi = m
    return lo
def colour(lam, L, hue=None):
    dec = lambda c: c / 12.92 if c <= 0.04045 else ((c + 0.055) / 1.055) ** 2.4
    e = w.along_white(w.xy2lin(*w.locus(lam))); _, a, b = w.lin2ok([dec(c) for c in e])
    h = hue if hue is not None else math.degrees(math.atan2(b, a)) % 360
    cusp = max(max_c(0.2 + 0.78 * i / 299, h) for i in range(300)); C = min(SAT * cusp, max_c(L, h))
    s = [round(w.enc(min(1, max(0, c))), 4) for c in ok2lin(L, C, h)]
    return s, [round(L, 3), round(C, 4), round(h, 1)], round(C / cusp, 3)
DS, DP, DY, DT = 0.20, 0.28, +0.30, 0.012        # die size, pitch, row offset (+Y = pin 1/4 side), thickness
rep = []
if not PLAIN:
    # datasheet top view (pin 1 bottom-right) reads R G B left to right; turned 180 deg -> R at +X
    for name, lam, L, hue, x in [('die_R', 625, 0.60, 29.2, +DP), ('die_G', 520, 0.72, None, 0.0), ('die_B', 470, 0.52, None, -DP)]:
        s, ok, frac = colour(lam, L, hue)
        parts.append((name, box(x - DS / 2, DY - DS / 2, HS, x + DS / 2, DY + DS / 2, HS + DT), tuple(s), None))
        rep.append(dict(part=name, dominant_wavelength_nm=lam, datasheet='p.3 LED Characteristics, lambda_d range centre',
                        oklch=ok, chroma_fraction_of_cusp=frac, srgb=s, hex='#%02X%02X%02X' % tuple(round(c * 255) for c in s),
                        box_mm=dict(x=[round(x - DS / 2, 3), round(x + DS / 2, 3)], y=[round(DY - DS / 2, 3), round(DY + DS / 2, 3)], z=[HS, HS + DT])))
    parts.append(('ic_die', box(-0.20, -0.45, HS, 0.20, -0.11, HS + DT), (0.35, 0.36, 0.38), None))

doc = TDocStd_Document(TCollection_ExtendedString("d"))
st = XCAFDoc_DocumentTool.ShapeTool_s(doc.Main()); ct = XCAFDoc_DocumentTool.ColorTool_s(doc.Main())
asm = st.NewShape(); TDataStd_Name.Set_s(asm, TCollection_ExtendedString('WS2812B-2020'))
for name, shp, rgb, alpha in parts:
    lab = st.AddShape(shp, False); TDataStd_Name.Set_s(lab, TCollection_ExtendedString(name))
    if alpha is None:
        q = Quantity_Color(rgb[0], rgb[1], rgb[2], Quantity_TOC_sRGB)
        ct.SetColor(lab, q, XCAFDoc_ColorType.XCAFDoc_ColorGen); ct.SetColor(lab, q, XCAFDoc_ColorType.XCAFDoc_ColorSurf)
    else:
        q = Quantity_ColorRGBA(Quantity_Color(rgb[0], rgb[1], rgb[2], Quantity_TOC_sRGB), alpha)
        ct.SetColor(lab, q, XCAFDoc_ColorType.XCAFDoc_ColorGen); ct.SetColor(lab, q, XCAFDoc_ColorType.XCAFDoc_ColorSurf)
    comp = st.AddComponent(asm, lab, TopLoc_Location())
    TDataStd_Name.Set_s(comp, TCollection_ExtendedString(name))
st.UpdateAssemblies()
Interface_Static.SetCVal_s("write.step.schema", "AP214IS")
Interface_Static.SetCVal_s("write.step.unit", "MM")
wr = STEPCAFControl_Writer(); wr.SetColorMode(True); wr.SetNameMode(True); wr.SetLayerMode(True)
wr.Transfer(doc, STEPControl_AsIs); assert wr.Write(out) == 1
hdr = ("/* WS2812B-2020 (Worldsemi) 3D model - AI-generated (Claude, Adom) from the WS2812B-2020 datasheet V1.0\n"
       "   (2018-07-25) p.1 Mechanical Dimensions and p.2 Side View / PIN Configuration, with OpenCascade (OCP).\n"
       "   Not a manufacturer model. Die patch positions are illustrative (datasheet sketch is undimensioned).\n"
       "   Pin 1 DO (-X,+Y), 2 GND (-X,-Y), 3 DI (+X,-Y), 4 VDD (+X,+Y); mm, Z up, seated on z = 0. */")
t = open(out, encoding='latin-1').read().replace('HEADER;', 'HEADER;\n' + hdr, 1); open(out, 'w', encoding='latin-1').write(t)
json.dump(dict(tool='build_ws2812b_2020.py', plain=PLAIN, doc=__doc__,
               dims_mm=dict(body=[BX, BY], height=H, substrate=HS, window_width=WX, pad_pitch=PITCH, bottom_pad=[PW, PL], notch_r=RN,
                            stripe_x=STRIPE),
               parts=[dict(name=n, srgb=list(c), alpha=a) for n, _, c, a in parts], dies=rep), open(rp, 'w'), indent=1)
print(json.dumps(dict(ok=True, out=out, parts=[p[0] for p in parts], dies=rep)))