main
adonis LW A6SG-V2BA-FK0PN0 SIDELED: datasheet-derived symbol, footprint, 3D model, viewers and generators 86e4c6a 1mo ago
#!/usr/bin/env python3
"""
Emit an AP214 STEP model of the ams OSRAM SIDELED(R) package (LW A6SG).

Every dimension below is cited to "LW A6SG binning FK0PN0", version 1.1,
2020-06-01 -- page numbers in the comments. Anything the datasheet does not
dimension is marked REPRESENTATIVE and is called out in the README too.

Frame: X right, Y front(-)/back(+), Z up. Board top plane at Z = 0, so the
model drops straight onto the footprint origin.
"""
import math

# ---------------------------------------------------------------- dimensions
BODY_W       = 4.00    # p.14 top view, 4 +/-0.2
BODY_H       = 3.97    # p.14 front view, 3.97 +0.23/-0.17
BODY_D       = 2.85    # p.14 side view, (2.85)
ENVELOPE_D   = 3.63    # p.14 top view, 3.63 +/-0.2  (body + lens dome)
STANDOFF     = 0.80    # p.14 side view, (0.8) -- body underside above board
LENS_DIA     = 2.40    # p.14 front view, (2.4)
OPTIC_Z_REL  = 2.52    # p.14 front view, 2.52 +0.28/-0.12 above body underside
LEAD_W       = 1.00    # p.14 front view, 1 +/-0.1
LEAD_PITCH   = 2.54    # p.14 front view, "2.54 spacing"
PAD_PITCH    = 2.50    # p.15 recommended solder pad: 3.7 outer span - 1.2 pad
PAD_LEN      = 3.00    # p.15 recommended solder pad, 3.0 (0.118)

LEAD_T       = 0.20    # REPRESENTATIVE: lead frame stock thickness
CHAMFER      = 0.50    # REPRESENTATIVE: size of the cathode-marking chamfer
FOOT_FRONT   = -1.00   # REPRESENTATIVE: foot stops short of the lens face
FOOT_BACK    =  1.50   # foot back edge, flush with the 3.0 mm land

Y_FRONT   = -ENVELOPE_D / 2.0          # -1.815, lens tip
Y_FACE    = Y_FRONT + (ENVELOPE_D - BODY_D)   # -1.035, body front face
Y_BACK    = Y_FRONT + ENVELOPE_D       # +1.815, body back face
Z_BOT     = STANDOFF                   # 0.80
Z_TOP     = STANDOFF + BODY_H          # 4.77
OPTIC_Z   = STANDOFF + OPTIC_Z_REL     # 3.32
DOME_H    = ENVELOPE_D - BODY_D        # 0.78 protrusion
DOME_R    = ((LENS_DIA / 2.0) ** 2 + DOME_H ** 2) / (2.0 * DOME_H)  # fitted cap

COL_BODY = (0.930, 0.930, 0.915)   # white SMT housing (p.1 "white SMT package")
COL_LEAD = (0.752, 0.762, 0.780)   # tin-plated lead frame
COL_LENS = (0.980, 0.958, 0.862)   # "colored diffused silicone resin" (p.1)


# ------------------------------------------------------------------- meshing
def tri_normal(a, b, c):
    u = (b[0] - a[0], b[1] - a[1], b[2] - a[2])
    v = (c[0] - a[0], c[1] - a[1], c[2] - a[2])
    return (u[1] * v[2] - u[2] * v[1],
            u[2] * v[0] - u[0] * v[2],
            u[0] * v[1] - u[1] * v[0])


def quad(a, b, c, d):
    """Two triangles, winding preserved."""
    return [(a, b, c), (a, c, d)]


def ear_clip(poly):
    """Triangulate a simple CCW polygon of 2D points; yields index triples."""
    idx = list(range(len(poly)))

    def area2(i, j, k):
        (ax, ay), (bx, by), (cx, cy) = poly[i], poly[j], poly[k]
        return (bx - ax) * (cy - ay) - (by - ay) * (cx - ax)

    def inside(i, j, k, p):
        d1, d2, d3 = area2(i, j, p), area2(j, k, p), area2(k, i, p)
        neg = d1 < 0 or d2 < 0 or d3 < 0
        pos = d1 > 0 or d2 > 0 or d3 > 0
        return not (neg and pos)

    out = []
    guard = 0
    while len(idx) > 3 and guard < 5000:
        guard += 1
        for n in range(len(idx)):
            i, j, k = idx[n - 1], idx[n], idx[(n + 1) % len(idx)]
            if area2(i, j, k) <= 1e-12:
                continue
            if any(inside(i, j, k, m) for m in idx if m not in (i, j, k)):
                continue
            out.append((i, j, k))
            idx.pop(n)
            break
        else:
            break
    if len(idx) == 3:
        out.append(tuple(idx))
    return out


def extrude(profile, lo, hi, axis):
    """Extrude a CCW 2D profile between two planes normal to `axis`."""
    def place(p, t):
        if axis == 'x':
            return (t, p[0], p[1])
        if axis == 'y':
            return (p[0], t, p[1])
        return (p[0], p[1], t)

    tris, n = [], len(profile)
    for a, b, c in ear_clip(profile):
        tris.append((place(profile[c], hi), place(profile[b], hi), place(profile[a], hi)))
        tris.append((place(profile[a], lo), place(profile[b], lo), place(profile[c], lo)))
    for i in range(n):
        p, q = profile[i], profile[(i + 1) % n]
        tris += quad(place(p, lo), place(q, lo), place(q, hi), place(p, hi))
    # Extruding a CCW profile along -axis flips every face; fix in one place.
    probe = sum(tri_normal(*t)[{'x': 0, 'y': 1, 'z': 2}[axis]] for t in tris[:2])
    if probe < 0:
        tris = [(t[0], t[2], t[1]) for t in tris]
    return tris


def box(x0, x1, y0, y1, z0, z1):
    return extrude([(x0, y0), (x1, y0), (x1, y1), (x0, y1)], z0, z1, 'z')


def dome(cx, cz, y_base, radius, height, segs=32, rings=8):
    """Spherical cap opening toward -Y, closed with a flat base disc."""
    base_r = math.sqrt(max(radius * radius - (radius - height) ** 2, 0.0))
    y_c = y_base + radius - height          # sphere centre, behind the face
    # polar angle 0 = tip (-Y), grows toward the rim
    a_rim = math.acos(max(-1.0, min(1.0, (y_c - y_base) / radius)))

    def surf(ring, seg):
        a = a_rim * ring / rings
        phi = 2.0 * math.pi * seg / segs
        r = radius * math.sin(a)
        return (cx + r * math.cos(phi), y_c - radius * math.cos(a), cz + r * math.sin(phi))

    tris = []
    tip = (cx, y_c - radius, cz)
    for s in range(segs):
        tris.append((tip, surf(1, s + 1), surf(1, s)))
    for ring in range(1, rings):
        for s in range(segs):
            tris += quad(surf(ring, s), surf(ring, s + 1),
                         surf(ring + 1, s + 1), surf(ring + 1, s))
    centre = (cx, y_base, cz)
    for s in range(segs):
        p = (cx + base_r * math.cos(2 * math.pi * s / segs), y_base,
             cz + base_r * math.sin(2 * math.pi * s / segs))
        q = (cx + base_r * math.cos(2 * math.pi * (s + 1) / segs), y_base,
             cz + base_r * math.sin(2 * math.pi * (s + 1) / segs))
        tris.append((centre, p, q))
    return tris


# ------------------------------------------------------------------ the part
def build():
    # Housing: rectangular prism with one vertical edge chamfered on the
    # cathode (-X) side -- the datasheet's "Cathode marking" callout (p.14).
    housing = [
        (-BODY_W / 2, Y_FACE + CHAMFER),
        (-BODY_W / 2 + CHAMFER, Y_FACE),
        (BODY_W / 2, Y_FACE),
        (BODY_W / 2, Y_BACK),
        (-BODY_W / 2, Y_BACK),
    ]
    solids = [('housing', COL_BODY, extrude(housing, Z_BOT, Z_TOP, 'z'))]

    # Gull-wing leads: foot on the land pattern, riser up into the housing.
    lead = [
        (FOOT_FRONT, 0.0), (FOOT_BACK, 0.0), (FOOT_BACK, Z_BOT + 0.2),
        (FOOT_BACK - LEAD_T, Z_BOT + 0.2), (FOOT_BACK - LEAD_T, LEAD_T),
        (FOOT_FRONT, LEAD_T),
    ]
    for sign, name in ((-1, 'lead_cathode'), (1, 'lead_anode')):
        cx = sign * PAD_PITCH / 2.0
        solids.append((name, COL_LEAD,
                       extrude(lead, cx - LEAD_W / 2, cx + LEAD_W / 2, 'x')))

    solids.append(('lens', COL_LENS,
                   dome(0.0, OPTIC_Z, Y_FACE, DOME_R, DOME_H)))

    # KiCad inverts Y between footprint space and 3D-model space, so mirror the
    # part in Y here: the lens then lands on the footprint's -Y edge, where the
    # .kicad_mod draws the emission arrow. Mirroring flips handedness, so the
    # winding of every triangle is reversed to keep normals pointing outward.
    return [(name, colour, [(mirror(t[0]), mirror(t[2]), mirror(t[1]))
                            for t in tris])
            for name, colour, tris in solids]


def mirror(p):
    return (p[0], -p[1], p[2])


# --------------------------------------------------------------- STEP writer
class Step:
    def __init__(self):
        self.lines, self.n = [], 0

    def add(self, body):
        self.n += 1
        self.lines.append("#%d = %s;" % (self.n, body))
        return self.n

    def pt(self, p):
        return self.add("CARTESIAN_POINT('',(%s))" % ",".join("%.6f" % v for v in p))

    def dr(self, d):
        return self.add("DIRECTION('',(%s))" % ",".join("%.6f" % v for v in d))

    def ax2(self, origin, axis, ref):
        return self.add("AXIS2_PLACEMENT_3D('',#%d,#%d,#%d)"
                        % (self.pt(origin), self.dr(axis), self.dr(ref)))


def norm(v):
    m = math.sqrt(sum(c * c for c in v))
    return tuple(c / m for c in v) if m > 1e-12 else (0.0, 0.0, 1.0)


def solid_brep(s, tris, label):
    """Triangles -> MANIFOLD_SOLID_BREP with shared vertices and edges."""
    vmap, verts = {}, []
    for t in tris:
        for p in t:
            key = tuple(round(c, 6) for c in p)
            if key not in vmap:
                vmap[key] = len(verts)
                verts.append(key)

    vpoint = [s.add("VERTEX_POINT('',#%d)" % s.pt(v)) for v in verts]

    edges = {}

    def edge(i, j):
        key = (i, j) if i < j else (j, i)
        if key not in edges:
            a, b = verts[key[0]], verts[key[1]]
            d = norm((b[0] - a[0], b[1] - a[1], b[2] - a[2]))
            vec = s.add("VECTOR('',#%d,1.000000)" % s.dr(d))
            line = s.add("LINE('',#%d,#%d)" % (s.pt(a), vec))
            edges[key] = s.add("EDGE_CURVE('',#%d,#%d,#%d,.T.)"
                               % (vpoint[key[0]], vpoint[key[1]], line))
        return edges[key], (i < j)

    faces = []
    for t in tris:
        idx = [vmap[tuple(round(c, 6) for c in p)] for p in t]
        if len(set(idx)) < 3:
            continue
        n = tri_normal(*[verts[i] for i in idx])
        if math.sqrt(sum(c * c for c in n)) < 1e-12:
            continue
        n = norm(n)
        a, b = verts[idx[0]], verts[idx[1]]
        ref = norm((b[0] - a[0], b[1] - a[1], b[2] - a[2]))
        # Gram-Schmidt so the reference direction is exactly in-plane.
        dot = sum(ref[i] * n[i] for i in range(3))
        ref = norm(tuple(ref[i] - dot * n[i] for i in range(3)))
        plane = s.add("PLANE('',#%d)" % s.ax2(a, n, ref))

        oriented = []
        for k in range(3):
            ec, fwd = edge(idx[k], idx[(k + 1) % 3])
            oriented.append(s.add("ORIENTED_EDGE('',*,*,#%d,%s)"
                                  % (ec, ".T." if fwd else ".F.")))
        loop = s.add("EDGE_LOOP('',(%s))" % ",".join("#%d" % o for o in oriented))
        bound = s.add("FACE_OUTER_BOUND('',#%d,.T.)" % loop)
        faces.append(s.add("ADVANCED_FACE('',(#%d),#%d,.T.)" % (bound, plane)))

    shell = s.add("CLOSED_SHELL('',(%s))" % ",".join("#%d" % f for f in faces))
    return s.add("MANIFOLD_SOLID_BREP('%s',#%d)" % (label, shell))


def write(path, solids):
    s = Step()
    ctx_axis = s.ax2((0.0, 0.0, 0.0), (0.0, 0.0, 1.0), (1.0, 0.0, 0.0))

    length = s.add("( LENGTH_UNIT() NAMED_UNIT(*) SI_UNIT(.MILLI.,.METRE.) )")
    angle = s.add("( NAMED_UNIT(*) PLANE_ANGLE_UNIT() SI_UNIT($,.RADIAN.) )")
    solid_a = s.add("( NAMED_UNIT(*) SI_UNIT($,.STERADIAN.) SOLID_ANGLE_UNIT() )")
    tol = s.add("UNCERTAINTY_MEASURE_WITH_UNIT(LENGTH_MEASURE(1.E-07),#%d,"
                "'distance_accuracy_value','confusion accuracy')" % length)
    ctx = s.add("( GEOMETRIC_REPRESENTATION_CONTEXT(3) "
                "GLOBAL_UNCERTAINTY_ASSIGNED_CONTEXT((#%d)) "
                "GLOBAL_UNIT_ASSIGNED_CONTEXT((#%d,#%d,#%d)) "
                "REPRESENTATION_CONTEXT('','3D') )" % (tol, length, angle, solid_a))

    breps, styled = [], []
    for label, colour, tris in solids:
        brep = solid_brep(s, tris, label)
        breps.append(brep)
        rgb = s.add("COLOUR_RGB('',%.6f,%.6f,%.6f)" % colour)
        fill = s.add("FILL_AREA_STYLE_COLOUR('',#%d)" % rgb)
        area = s.add("FILL_AREA_STYLE('',(#%d))" % fill)
        sfill = s.add("SURFACE_STYLE_FILL_AREA(#%d)" % area)
        side = s.add("SURFACE_SIDE_STYLE('',(#%d))" % sfill)
        usage = s.add("SURFACE_STYLE_USAGE(.BOTH.,#%d)" % side)
        psa = s.add("PRESENTATION_STYLE_ASSIGNMENT((#%d))" % usage)
        styled.append(s.add("STYLED_ITEM('colour',(#%d),#%d)" % (psa, brep)))

    shape = s.add("ADVANCED_BREP_SHAPE_REPRESENTATION('%s',(#%d,%s),#%d)"
                  % ('LW_A6SG', ctx_axis,
                     ",".join("#%d" % b for b in breps), ctx))
    s.add("MECHANICAL_DESIGN_GEOMETRIC_PRESENTATION_REPRESENTATION('',(%s),#%d)"
          % (",".join("#%d" % x for x in styled), ctx))

    app = s.add("APPLICATION_CONTEXT('core data for automotive mechanical "
                "design processes')")
    s.add("APPLICATION_PROTOCOL_DEFINITION('international standard',"
          "'automotive_design',2000,#%d)" % app)
    pctx = s.add("PRODUCT_CONTEXT('',#%d,'mechanical')" % app)
    prod = s.add("PRODUCT('LW_A6SG-V2BA-FK0PN0','LW A6SG-V2BA-FK0PN0',"
                 "'ams OSRAM SIDELED white LED',(#%d))" % pctx)
    pdf = s.add("PRODUCT_DEFINITION_FORMATION('','',#%d)" % prod)
    pdctx = s.add("PRODUCT_DEFINITION_CONTEXT('part definition',#%d,'design')" % app)
    pdef = s.add("PRODUCT_DEFINITION('design','',#%d,#%d)" % (pdf, pdctx))
    pds = s.add("PRODUCT_DEFINITION_SHAPE('','',#%d)" % pdef)
    s.add("SHAPE_DEFINITION_REPRESENTATION(#%d,#%d)" % (pds, shape))

    header = (
        "ISO-10303-21;\n"
        "HEADER;\n"
        "FILE_DESCRIPTION(('ams OSRAM SIDELED LW A6SG-V2BA-FK0PN0'),'2;1');\n"
        "FILE_NAME('lw-a6sg-v2ba-fk0pn0.step','2026-08-27T00:00:00',"
        "('Adom'),('Adom'),'gen_step.py','Adom component pipeline','');\n"
        "FILE_SCHEMA(('AUTOMOTIVE_DESIGN { 1 0 10303 214 1 1 1 1 }'));\n"
        "ENDSEC;\n"
        "DATA;\n")
    with open(path, 'w') as fh:
        fh.write(header)
        fh.write("\n".join(s.lines))
        fh.write("\nENDSEC;\nEND-ISO-10303-21;\n")
    return s.n


if __name__ == '__main__':
    import sys
    out = sys.argv[1] if len(sys.argv) > 1 else 'lw-a6sg-v2ba-fk0pn0.step'
    solids = build()
    n = write(out, solids)
    print("OK: %s  (%d solids, %d triangles, %d entities)"
          % (out, len(solids), sum(len(t) for _, _, t in solids), n))
    print("    envelope X %.2f  Y %.2f  Z %.2f  dome R %.3f"
          % (BODY_W, ENVELOPE_D, Z_TOP, DOME_R))