master
John Lauer Laser mark by the planner (adom-aiflow 0.1.49): re-etched, outlines regenerated, GLB / hero / outline symbol rebuilt; l… 2226d33 16d ago
"""WS2812B-2020 (Worldsemi) 3D model, rebuilt from the datasheet drawings (AI-generated, 2026-10-03, v2).

Worldsemi publishes no 3D model and KiCad's library has none for this package, so every feature here is
taken from the Worldsemi WS2812B-2020 datasheet (V1.0 release; rev. V1.3 PDF), p.1-2 "Mechanical
Dimensions". Dimensioned values are used as printed; undimensioned ones were measured off the drawings at
600 dpi, with the printed 2.20 and 2.00 mm edges as the scale (350 px/mm on both axes):

  printed:  package 2.20 (X) x 2.00 (Y); total height 0.84; substrate 0.28; clear moulding 1.45 (X), full
            2.00 (Y), centred, straight sides; terminal pitch 1.00 (Y); gap between bottom pads 1.13.
  measured: bottom pads 0.535 (from the 1.13 gap) x 0.78, centred on the 1.00 pitch (y 0.11..0.89 from
            centre); castellation radius 0.20 at each terminal centre, on the package edge; top-side terminal
            metal fills the 0.375 strip outside the moulding over y 0.18..0.88 with rounded inner corners;
            polarity stripe on the underside 0.525 wide, 0.05 from the pin 1/2 pads, full length;
            IC outline 0.67 x 0.52 and R/G/B dies 0.19 x 0.21 from the top-view sketch (positions illustrative).
  photos:   features the drawings leave out, taken from product photos (v3, 2026-10-04):
            - lead-frame stubs: Worldsemi's WS2812B family photo (world-semi.com) shows four metal stubs with
              rounded ends on the long (2.20) faces, one near each end. Measured with the 2.20 edge as scale
              (72 px/mm): 0.41 long, 0.11..0.52 in from each end, standing 0.06 proud of the face. A top-view
              photo cannot show their height; they are placed at the top of the 0.28 base, 0.10 thick, beside
              the top-side terminal metal (estimated).
            - polarity stripe colour: LCSC's underside photo of WS2812B-2020 (C965555, 2025-08-12) shows the
              stripe mint green, sRGB (123, 242, 203), carrying a Worldsemi logo (not modelled), not dark.
            - top surface: both photos show the base white outside the 1.45 moulding; the top-side terminal metal
              the Top View draws is not visible, so a white mask covers it (the metal stays modelled beneath).
            - IC die: 0.53 x 0.29, centred (0.11, -0.25) in the model frame, from Worldsemi's photo at 144.5 px/mm.
            - LED dies, located by their bond wires in Worldsemi's photo (the photo is in the datasheet Top View's
              orientation: IC above, dies below; the model turns it 180 deg): three emitters in a row about 0.37
              below the IC. The centre one is round with a single wire (a vertical die): red. The two beside it are
              rectangular with two wires each; the V6 Top View orders the dies G R B, so green and blue sit either
              side. Model frame: G 0.09 x 0.13 at (0.27, 0.37), R 0.12 round at (-0.03, 0.38), B 0.09 x 0.15 at
              (-0.38, 0.36).
            - Worldsemi logo on the stripe (LCSC underside photo, perspective-corrected to 400 px/mm): the ring-and-WS
              mark, 0.875 across, centred (0.04, 0.09), on a white disc knocked out of the green stripe, reading
              upright with its top toward -Y. Its outline is traced from Worldsemi's own logo file
              (worldsemi_logo.json, trace_logo.py). The photo shows no pin cue besides the stripe, so which end of
              the stripe the logo favours (0.09 off centre) and which way up it reads are from the photo's framing,
              not tied to a pin; the stripe itself is the polarity mark and sits beside pins 1/2 as every drawing shows.

Frame: mm, Z up, seated on z = 0, origin at the package centre, oriented for the page footprint (pad 1 at
(-X,+Y) in 3D = the datasheet top view turned 180 deg): pin 1 DO (-X,+Y), 2 GND (-X,-Y), 3 DI (+X,-Y),
4 VDD (+X,+Y). Usage: build_ws2812b_2020_v2.py OUT.step REPORT.json [--name MPN] [--plain]
  --plain: the uncoloured model: same package, terminals, window and polarity mark, no IC or die patches."""
import sys, json
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
from OCP.BRepFilletAPI import BRepFilletAPI_MakeFillet
from OCP.TopExp import TopExp_Explorer
from OCP.TopAbs import TopAbs_EDGE
from OCP.TopoDS import TopoDS
from OCP.BRepAdaptor import BRepAdaptor_Curve
from OCP.gp import gp_Pnt, gp_Ax2, gp_Dir, gp_Vec
from OCP.BRepBuilderAPI import BRepBuilderAPI_MakePolygon, BRepBuilderAPI_MakeFace
from OCP.BRepPrimAPI import BRepPrimAPI_MakePrism
import os
from OCP.TopLoc import TopLoc_Location
from OCP.Interface import Interface_Static

out, rp = sys.argv[1:3]
PLAIN = '--plain' in sys.argv
NAME = sys.argv[sys.argv.index('--name') + 1] if '--name' in sys.argv else 'WS2812B-2020'
BX, BY, H, HS, WX = 2.20, 2.00, 0.84, 0.28, 1.45      # printed
PITCH, GAP = 1.00, 1.13                                # printed
PW = (BX - GAP) / 2                                    # 0.535 bottom pad width
PY0, PY1 = 0.11, 0.89                                  # bottom pad y extent from centre (measured)
RN = 0.20                                              # castellation radius (measured)
TY0, TY1, TR = 0.18, 0.88, 0.15                        # top-side terminal y extent, inner corner radius (measured)
PT = 0.02                                              # plating thickness (illustrative)
STRIPE_W, STRIPE_GAP = 0.525, 0.05                     # measured
TAB_IN0, TAB_IN1, TAB_OUT, TAB_T, TAB_R = 0.11, 0.52, 0.06, 0.10, 0.03   # lead-frame stubs (Worldsemi photo)
WHITE, METAL, CLEAR, ICC = (0.95, 0.95, 0.93), (0.80, 0.81, 0.83), (0.90, 0.92, 0.94), (0.32, 0.33, 0.35)
STRIPE = (123 / 255, 242 / 255, 203 / 255)            # LCSC underside photo
DIES = {'R': (0.7998, 0.3324, 0.2771), 'G': (0.3411, 0.7353, 0.5413), 'B': (0.2094, 0.3959, 0.7376)}


def box(x0, y0, z0, x1, y1, z1): return BRepPrimAPI_MakeBox(gp_Pnt(min(x0, x1), min(y0, y1), min(z0, z1)), abs(x1 - x0), abs(y1 - y0), abs(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 fillet_vertical_edges(shape, r, pick):
    mk = BRepFilletAPI_MakeFillet(shape); n = 0
    e = TopExp_Explorer(shape, TopAbs_EDGE)
    while e.More():
        ed = TopoDS.Edge(e.Current()); c = BRepAdaptor_Curve(ed)
        a, b = c.Value(c.FirstParameter()), c.Value(c.LastParameter())
        if abs(a.X() - b.X()) < 1e-6 and abs(a.Y() - b.Y()) < 1e-6 and pick(a.X(), a.Y()):
            mk.Add(r, ed); n += 1
        e.Next()
    return mk.Shape() if n else shape


TRACE = json.load(open(os.path.join(os.path.dirname(os.path.abspath(__file__)), 'top_terminals.json')))
KEY = {(1, -1): 'left-upper', (1, 1): 'left-lower', (-1, -1): 'right-upper', (-1, 1): 'right-lower'}   # datasheet top view turned 180 deg


def traced(pts, sx):
    # the traced outline (model frame, mm), x snapped onto [moulding edge, package edge]
    xs = [abs(x) for x, y in pts]; a, b = min(xs), max(xs); lo, hi = WX / 2, BX / 2
    q = [(sx * (lo + (abs(x) - a) / (b - a) * (hi - lo)), y) for x, y in pts]
    clean = [q[0]]
    for p in q[1:]:
        if abs(p[0] - clean[-1][0]) + abs(p[1] - clean[-1][1]) > 2e-3: clean.append(p)
    poly = BRepBuilderAPI_MakePolygon()
    for x, y in clean: poly.Add(gp_Pnt(x, y, HS - PT))
    poly.Close()
    face = BRepBuilderAPI_MakeFace(poly.Wire()).Face()
    return BRepPrimAPI_MakePrism(face, gp_Vec(0, 0, PT + 0.001)).Shape()


notches = [(sx * BX / 2, sy * PITCH / 2) for sx in (-1, 1) for sy in (-1, 1)]
# substrate (white), notched at every terminal
body = box(-BX / 2, -BY / 2, PT, BX / 2, BY / 2, HS)
for x, y in notches: body = cut(body, cyl(x, y, 0, RN + PT, HS + 0.01))   # out to the plating's outer radius: the metal ring owns 0.20-0.22, no shared surface (z-fighting)
parts = [('body', body, WHITE, None)]
pins = {(-1, 1): 'pin1_DO', (-1, -1): 'pin2_GND', (1, -1): 'pin3_DI', (1, 1): 'pin4_VDD'}
for (sx, sy), name in pins.items():
    xe = sx * BX / 2
    bottom = box(xe, sy * PY0, 0, xe - sx * PW, sy * PY1, PT)
    xi = xe - sx * (BX - WX) / 2                                     # moulding edge
    top = traced(TRACE[KEY[(sx, sy)]], sx)
    body = cut(body, top)                                            # the metal owns its volume: no shared faces with the body
    ring = cut(cyl(xe, sy * PITCH / 2, 0, RN + PT, HS + 0.001), cyl(xe, sy * PITCH / 2, -0.01, RN, HS + 0.02))
    ring = cut(ring, box(xe, -BY, -0.1, xe + sx * 1.0, BY, 1.0))     # keep the half inside the package
    term = fuse(fuse(bottom, top), ring)
    term = cut(term, cyl(xe, sy * PITCH / 2, -0.01, RN, HS + 0.02))
    parts.append((name, term, METAL, None))
parts[0] = ('body', body, WHITE, None)
xs = -BX / 2 + PW + STRIPE_GAP                                       # pins 1/2 are at -X
LOGO_D, LOGO_X, LOGO_Y = 0.875, 0.04, 0.09                             # LCSC underside photo
stripe = cut(box(xs, -BY / 2, PT / 2, xs + STRIPE_W, BY / 2, PT), cyl(LOGO_X, LOGO_Y, 0, LOGO_D / 2, PT + 0.01))
parts.append(('polarity_mark', stripe, STRIPE, None))
LOGO = json.load(open(os.path.join(os.path.dirname(os.path.abspath(__file__)), 'worldsemi_logo.json')))


def logo_face(rings):
    # logo (u right, v up) seen from below, top toward -Y: model x = u, y = -v (a mirror, as seen from below)
    ws = []
    for r in rings:
        pg = BRepBuilderAPI_MakePolygon()
        for u, v in r: pg.Add(gp_Pnt(LOGO_X + u * LOGO_D, LOGO_Y - v * LOGO_D, PT / 2))
        pg.Close(); ws.append(pg.Wire())
    mf = BRepBuilderAPI_MakeFace(ws[0])
    for w in ws[1:]: mf.Add(w)
    return BRepPrimAPI_MakePrism(mf.Face(), gp_Vec(0, 0, PT / 2)).Shape()


logo = None
for pl in LOGO['polys']:
    sh = logo_face(pl); logo = sh if logo is None else fuse(logo, sh)
parts.append(('worldsemi_logo', logo, STRIPE, None))
for sx in (-1, 1):
    for sy in (-1, 1):
        x0, x1 = sx * (BX / 2 - TAB_IN1), sx * (BX / 2 - TAB_IN0)
        stub = box(x0, sy * BY / 2, HS - TAB_T, x1, sy * (BY / 2 + TAB_OUT), HS)   # starts at the wall: no buried overlap
        stub = fillet_vertical_edges(stub, TAB_R, lambda x, y, sy=sy: abs(y - sy * (BY / 2 + TAB_OUT)) < 1e-6)
        parts.append(('lead_frame_stub_%s%s' % ('-+'[sx > 0], '-+'[sy > 0]), stub, METAL, None))
parts.append(('window', box(-WX / 2, -BY / 2, HS, WX / 2, BY / 2, H), CLEAR, 0.45))
DT = 0.06
mask = None
for sx in (-1, 1):
    m = box(sx * WX / 2, -BY / 2, HS, sx * BX / 2, BY / 2, HS + 0.004)
    for x, y in notches: m = cut(m, cyl(x, y, HS - 0.01, RN + PT, 0.03))
    mask = m if mask is None else fuse(mask, m)
parts.append(('top_mask', mask, WHITE, None))                         # photos: white outside the moulding
IC_W, IC_H, IC_X, IC_Y = 0.53, 0.29, 0.11, -0.25                       # Worldsemi photo
if not PLAIN: parts.append(('ic_die', box(IC_X - IC_W / 2, IC_Y - IC_H / 2, HS, IC_X + IC_W / 2, IC_Y + IC_H / 2, HS + DT * 0.6), ICC, None))
if not PLAIN:                                                          # Worldsemi photo, bond wires
    parts.append(('die_G', box(0.27 - 0.045, 0.37 - 0.065, HS, 0.27 + 0.045, 0.37 + 0.065, HS + DT), DIES['G'], None))
    parts.append(('die_R', cyl(-0.03, 0.38, HS, 0.06, DT), DIES['R'], None))
    parts.append(('die_B', box(-0.38 - 0.045, 0.36 - 0.075, HS, -0.38 + 0.045, 0.36 + 0.075, HS + DT), DIES['B'], 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(NAME))
for name, shp, rgb, alpha in parts:
    lab = st.AddShape(shp, False); TDataStd_Name.Set_s(lab, TCollection_ExtendedString(name))
    q = Quantity_Color(rgb[0], rgb[1], rgb[2], Quantity_TOC_sRGB)
    if alpha is not None: q = Quantity_ColorRGBA(q, 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 = ("/* %s (Worldsemi WS2812B-2020) 3D model v3 - AI-generated (Claude, Adom) from the Worldsemi WS2812B-2020 datasheet\n"
       "   Mechanical Dimensions (printed values as printed; undimensioned features measured off the drawing at 350 px/mm; lead-frame stubs and stripe colour from product photos),\n"
       "   with OpenCascade (OCP). Not a manufacturer model: Worldsemi publishes none. Pin 1 DO (-X,+Y), 2 GND (-X,-Y),\n"
       "   3 DI (+X,-Y), 4 VDD (+X,+Y); mm, Z up, seated on z = 0. */" % NAME)
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_v2.py', name=NAME, plain=PLAIN, doc=__doc__,
               source=dict(doc="Worldsemi WS2812B-2020 datasheet (V1.0 release, rev. V1.3 PDF)", url="https://cdn-shop.adafruit.com/product-files/4684/4684_WS2812B-2020_V1.3_EN.pdf",
                           pages="1-2 Mechanical Dimensions: Back View, Top View, Side View, PCB Solder Pad"),
               dims_mm=dict(package=[BX, BY], height=H, substrate=HS, window_x=WX, pitch=PITCH, bottom_pad=[PW, PY1 - PY0], bottom_pad_y=[PY0, PY1],
                            castellation_r=RN, top_terminal_y=[TY0, TY1], top_terminal_corner_r=TR, plating=PT, stripe=[STRIPE_W, STRIPE_GAP], lead_frame_stub=dict(in_from_end=[TAB_IN0, TAB_IN1], proud=TAB_OUT, thick=TAB_T)),
               photos=dict(stubs='https://www.world-semi.com/assets/products/ws2812b-family/ws2812b-v6-v7-package-family.webp', underside='https://www.lcsc.com/product-detail/C965555.html'),
               parts=[dict(name=n, srgb=list(c), alpha=a) for n, _, c, a in parts]), open(rp, 'w'), indent=1)
print(json.dumps(dict(out=out, parts=len(parts))))