#!/usr/bin/env python3
"""generate iPhone 6s charging dock/stand STLs for Tinkercad import.

The stand is a union of overlapping solids (boxes plus extruded rounded
polygons), some tilted by the recline angle. Tinkercad and slicers treat
overlapping closed shells as one solid. Units are millimetres. Edit TILTS
and re-run for other angles.

Two variants per angle (see SPECS): a snug iPhone 6s one, and a universal
one whose slot fits bare iPhones from the SE to the 16/17-era Max bodies.

Design (portrait, Lightning port down):
 - phone slides into a slot reclined TILT degrees from vertical
 - bottom edge rests on two seat columns with a 30 mm gap between them,
   the Lightning plug hangs through the gap into an open well
 - the well is open at the back (and through the front window) so the
   cable exits with a gentle bend
 - back support is two rails, center open for airflow (always-on server)
 - front lip is two tabs with a 28 mm window so the home button stays
   visible and reachable
 - side cheeks are open frames (38x52 mm windows) to save material; the
   tilted rails and tabs cross the windows as recessed braces
 - corners rounded where structure allows (base plan corners, cheek frame
   tops, window corners, lip/rail tips); seat surfaces, slot faces and
   bed-contact edges stay square on purpose
 - rear camera sits far above the rails, nothing blocks either camera
 - embossed NAS REBORN brand on the front apron
"""
import math, os, struct

OUT = os.path.dirname(os.path.abspath(__file__))
TILTS = [8, 15, 25]           # degrees of backwards recline

PH_W, PH_T, PH_H = 67.1, 7.1, 138.3    # iPhone 6s body

# stand variants: slot opening, slot gap and the tallest/thickest phone the
# variant must hold (drives base depth and the embed checks).
# universal covers SE (58.6 wide) through the 16/17-era Max bodies
# (78.1 x 163 x 8.8 envelope); bare phones or thin skins on smaller models.
SPECS = [
    dict(key="6s", open_w=70.0, gap=9.0, tallest=138.3, thickest=7.1),
    dict(key="universal", open_w=81.0, gap=10.5, tallest=163.0, thickest=8.8),
]

BRAND = "NAS REBORN"
BRAND_PX = 1.3            # pixel size of the 5x7 font, mm
BRAND_RAISE = 1.0         # emboss height above the base, mm

R_BASE, R_FRAME, R_WINDOW, R_TIP = 9.0, 8.0, 6.0, 5.0
ARC_SEG = 10              # segments per quarter arc

# 5x7 pixel font (HD44780 style), only the glyphs the brand needs
FONT = {
    "N": ("10001","11001","10101","10011","10001","10001","10001"),
    "A": ("01110","10001","10001","11111","10001","10001","10001"),
    "S": ("01111","10000","10000","01110","00001","00001","11110"),
    "R": ("11110","10001","10001","11110","10100","10010","10001"),
    "E": ("11111","10000","10000","11110","10000","10000","11111"),
    "B": ("11110","10001","10001","11110","10001","10001","11110"),
    "O": ("01110","10001","10001","10001","10001","10001","01110"),
    " ": ("00000",)*7,
}

def brand_boxes(text, px, y_top, z_base):
    """axis boxes for embossed text on the base top, read from the front.

    Horizontal pixel runs merge into one box each; boxes are inflated a
    hair so diagonally touching pixels fuse into one printed stroke.
    """
    eps = 0.08
    total = (6*len(text) - 1) * px
    x_left = -total / 2
    boxes = []
    for ci, ch in enumerate(text):
        for r, row in enumerate(FONT[ch]):
            y1 = y_top - r*px
            col = 0
            while col < 5:
                if row[col] == "1":
                    run = col
                    while run < 5 and row[run] == "1":
                        run += 1
                    x0 = x_left + (ci*6 + col) * px
                    boxes.append((x0 - eps, x0 + (run-col)*px + eps,
                                  y1 - px - eps, y1 + eps,
                                  z_base - 0.5, z_base + BRAND_RAISE))
                    col = run
                else:
                    col += 1
    return boxes

# --- rounded prism primitives ------------------------------------------------
# a prism extrudes a 2D polygon in the (A,B) plane along W, with A x B = W.
# two polygon kinds: 'rrect' (rectangle with per-corner radii, CCW order
# a0b0, a1b0, a1b1, a0b1) and 'fillet' (the material wedge that rounds a
# concave window corner: square at C minus a quarter disk).

def prism(kind, origin, A, B, W, w0, w1, **params):
    return dict(kind=kind, origin=origin, A=A, B=B, W=W, w0=w0, w1=w1, **params)

def rrect_poly(a0, a1, b0, b1, radii, seg=ARC_SEG):
    corners = [(a0, b0, 1, 1, 180), (a1, b0, -1, 1, 270),
               (a1, b1, -1, -1, 0), (a0, b1, 1, -1, 90)]
    pts = []
    for (ca, cb, da, db, ang), r in zip(corners, radii):
        if r <= 0:
            pts.append((ca, cb))
            continue
        cx, cy = ca + da*r, cb + db*r
        for k in range(seg + 1):
            t = math.radians(ang + 90*k/seg)
            pts.append((cx + r*math.cos(t), cy + r*math.sin(t)))
    return pts

def fillet_poly(C, da, db, r, seg=ARC_SEG):
    ca, cb = C
    cx, cy = ca + da*r, cb + db*r
    pts = [(ca, cb)]
    for k in range(seg + 1):
        t = math.radians(90*k/seg)
        pts.append((cx - da*r*math.sin(t), cy - db*r*math.cos(t)))
    return pts

def prism_poly(p):
    if p["kind"] == "rrect":
        poly = rrect_poly(p["a0"], p["a1"], p["b0"], p["b1"], p["radii"])
    else:
        poly = fillet_poly(p["C"], p["da"], p["db"], p["r"])
    area = sum(poly[i][0]*poly[(i+1) % len(poly)][1] -
               poly[(i+1) % len(poly)][0]*poly[i][1] for i in range(len(poly)))
    if area < 0:                       # keep first vertex first: fan anchor
        poly = [poly[0]] + poly[1:][::-1]
    return poly

def prism_contains(p, a, b):
    if p["kind"] == "rrect":
        if not (p["a0"] <= a <= p["a1"] and p["b0"] <= b <= p["b1"]):
            return False
        corners = [(p["a0"], p["b0"], 1, 1), (p["a1"], p["b0"], -1, 1),
                   (p["a1"], p["b1"], -1, -1), (p["a0"], p["b1"], 1, -1)]
        for (ca, cb, da, db), r in zip(corners, p["radii"]):
            if r > 0:
                cx, cy = ca + da*r, cb + db*r
                if (a-cx)*da < 0 and (b-cy)*db < 0 and (a-cx)**2 + (b-cy)**2 > r*r:
                    return False
        return True
    ca, cb = p["C"]
    da, db, r = p["da"], p["db"], p["r"]
    sa, sb = (a-ca)*da, (b-cb)*db
    if not (0 <= sa <= r and 0 <= sb <= r):
        return False
    return (sa-r)**2 + (sb-r)**2 >= r*r

def geometry(tilt_deg, spec):
    """returns dict with box lists, prism list and the tilt frame.

    channel boxes are (x0,x1, v0,v1, u0,u1) in the tilted slot frame,
    axis boxes are (x0,x1, y0,y1, z0,z1) in world coordinates.
    """
    T = math.radians(tilt_deg)
    s, c = math.sin(T), math.cos(T)
    X, Y, Z = (1,0,0), (0,1,0), (0,0,1)
    U, V = (0, s, c), (0, c, -s)
    O = (0, -6.0, 6.0)

    hw = spec["open_w"] / 2          # cheek inner face
    bx = hw + 10                     # cheek outer face and base half-width
    pl = hw + 6                      # plate ends, recessed 4 mm in the window
    g2 = spec["gap"] / 2             # slot half-gap; back rail face is datum

    # base grows backwards at steep angles so the centre of mass keeps margin
    com_y = -6.0 + (g2 - spec["thickest"]/2)*c + (34 + spec["tallest"]/2)*s
    backY = max(42.0, math.ceil(com_y + 15))

    channel = [
        # seat columns (split front/back so both stay embedded in the base),
        # 30 mm gap between left and right pair for the Lightning/USB-C plug
        (-(hw+3), -15, -g2-6,   2.0, -4, 34),
        (     15, hw+3, -g2-6,  2.0, -4, 34),
        (-(hw+3), -15,   1.0, g2+8,  0, 34),
        (     15, hw+3,   1.0, g2+8,  0, 34),
    ]
    axis = []
    prisms = [
        # base plate, plan corners rounded like the phone's
        prism("rrect", (0,0,0), X, Y, Z, 0, 6,
              a0=-bx, a1=bx, b0=-42.0, b1=backY, radii=(R_BASE,)*4),
        # front lip tabs and back rails, rounded tips, extruded through
        # their thickness so slot faces stay flat
        prism("rrect", O, U, X, V, -g2-6, -g2,
              a0=-6, a1=62, b0=14, b1=pl, radii=(0, R_TIP, R_TIP, 0)),
        prism("rrect", O, U, X, V, -g2-6, -g2,
              a0=-6, a1=62, b0=-pl, b1=-14, radii=(0, R_TIP, R_TIP, 0)),
        prism("rrect", O, U, X, V, g2, g2+8,
              a0=26, a1=115, b0=18, b1=pl, radii=(0, R_TIP, R_TIP, 0)),
        prism("rrect", O, U, X, V, g2, g2+8,
              a0=26, a1=115, b0=-pl, b1=-18, radii=(0, R_TIP, R_TIP, 0)),
    ]
    for x0, x1 in ((-bx, -hw), (hw, bx)):
        # each cheek is a frame around a 38x52 window (y -4..34, z 12..64):
        # two uprights with rounded outer top corners, boxed sill and
        # header, and fillets rounding the window corners
        prisms += [
            prism("rrect", (0,0,0), Y, Z, X, x0, x1,
                  a0=-12, a1=-4, b0=0, b1=72, radii=(0, 0, 0, R_FRAME)),
            prism("rrect", (0,0,0), Y, Z, X, x0, x1,
                  a0=34, a1=42, b0=0, b1=72, radii=(0, 0, R_FRAME, 0)),
            prism("fillet", (0,0,0), Y, Z, X, x0, x1, C=(-4,12), da=1,  db=1,  r=R_WINDOW),
            prism("fillet", (0,0,0), Y, Z, X, x0, x1, C=(34,12), da=-1, db=1,  r=R_WINDOW),
            prism("fillet", (0,0,0), Y, Z, X, x0, x1, C=(-4,64), da=1,  db=-1, r=R_WINDOW),
            prism("fillet", (0,0,0), Y, Z, X, x0, x1, C=(34,64), da=-1, db=-1, r=R_WINDOW),
        ]
        axis += [
            (x0, x1, -5.0, 35.0,  0, 12),      # sill
            (x0, x1, -5.0, 35.0, 64, 72),      # header
        ]
    # embossed brand on the front apron, clear of the lip tabs at any tilt
    brand = brand_boxes(BRAND, BRAND_PX, -26.0, 6.0)
    bb = (min(b[0] for b in brand), max(b[1] for b in brand),
          min(b[2] for b in brand), max(b[3] for b in brand),
          min(b[4] for b in brand), max(b[5] for b in brand))
    return dict(channel=channel, axis=axis, prisms=prisms,
                brand=brand, brand_bbox=bb, s=s, c=c, backY=backY,
                base_x=bx, g2=g2)

def contains(geo, x, y, z):
    """point membership over the whole union, shared with the estimator"""
    s, c = geo["s"], geo["c"]
    for p in geo["prisms"]:
        o, A, B, W = p["origin"], p["A"], p["B"], p["W"]
        dx, dy, dz = x-o[0], y-o[1], z-o[2]
        w = dx*W[0] + dy*W[1] + dz*W[2]
        if p["w0"] <= w <= p["w1"]:
            a = dx*A[0] + dy*A[1] + dz*A[2]
            b = dx*B[0] + dy*B[1] + dz*B[2]
            if prism_contains(p, a, b):
                return True
    for bx0, bx1, by0, by1, bz0, bz1 in geo["axis"]:
        if bx0 <= x <= bx1 and by0 <= y <= by1 and bz0 <= z <= bz1:
            return True
    dy, dz = y + 6.0, z - 6.0
    v = dy*c - dz*s
    u = dy*s + dz*c
    for bx0, bx1, bv0, bv1, bu0, bu1 in geo["channel"]:
        if bx0 <= x <= bx1 and bv0 <= v <= bv1 and bu0 <= u <= bu1:
            return True
    bb = geo["brand_bbox"]
    if bb[0] <= x <= bb[1] and bb[2] <= y <= bb[3] and bb[4] <= z <= bb[5]:
        for bx0, bx1, by0, by1, bz0, bz1 in geo["brand"]:
            if bx0 <= x <= bx1 and by0 <= y <= by1 and bz0 <= z <= bz1:
                return True
    return False

# canonical unit-cube triangulation, outward winding for a right-handed frame
FACES = [
    ((0,0,0),(1,1,0),(1,0,0)), ((0,0,0),(0,1,0),(1,1,0)),   # bottom
    ((0,0,1),(1,0,1),(1,1,1)), ((0,0,1),(1,1,1),(0,1,1)),   # top
    ((0,0,0),(0,0,1),(0,1,1)), ((0,0,0),(0,1,1),(0,1,0)),   # left
    ((1,0,0),(1,1,0),(1,1,1)), ((1,0,0),(1,1,1),(1,0,1)),   # right
    ((0,0,0),(1,0,0),(1,0,1)), ((0,0,0),(1,0,1),(0,0,1)),   # front
    ((0,1,0),(0,1,1),(1,1,1)), ((0,1,0),(1,1,1),(1,1,0)),   # back
]

def sub(a, b): return (a[0]-b[0], a[1]-b[1], a[2]-b[2])
def cross(a, b):
    return (a[1]*b[2]-a[2]*b[1], a[2]*b[0]-a[0]*b[2], a[0]*b[1]-a[1]*b[0])
def norm(a):
    l = math.sqrt(a[0]**2 + a[1]**2 + a[2]**2) or 1.0
    return (a[0]/l, a[1]/l, a[2]/l)
def dot(a, b): return a[0]*b[0] + a[1]*b[1] + a[2]*b[2]

def box_tris(corner):
    """corner(i,j,k) gives the world point; returns 12 triangles"""
    c = {(i,j,k): corner(i,j,k) for i in (0,1) for j in (0,1) for k in (0,1)}
    return [(c[a], c[b], c[d]) for a, b, d in FACES]

def prism_tris(p):
    poly = prism_poly(p)
    o, A, B, W = p["origin"], p["A"], p["B"], p["W"]
    def pt(a, b, w):
        return (o[0] + a*A[0] + b*B[0] + w*W[0],
                o[1] + a*A[1] + b*B[1] + w*W[1],
                o[2] + a*A[2] + b*B[2] + w*W[2])
    w0, w1 = p["w0"], p["w1"]
    tris = []
    n = len(poly)
    for i in range(1, n - 1):        # caps, fan from vertex 0
        a0, b0 = poly[0]; a1, b1 = poly[i]; a2, b2 = poly[i+1]
        tris.append((pt(a0,b0,w1), pt(a1,b1,w1), pt(a2,b2,w1)))
        tris.append((pt(a0,b0,w0), pt(a2,b2,w0), pt(a1,b1,w0)))
    for i in range(n):               # sides
        a0, b0 = poly[i]; a1, b1 = poly[(i+1) % n]
        tris.append((pt(a0,b0,w0), pt(a1,b1,w0), pt(a1,b1,w1)))
        tris.append((pt(a0,b0,w0), pt(a1,b1,w1), pt(a0,b0,w1)))
    return tris

def channel_point(s, c, x, v, u):
    # tilted slot frame -> world; u runs up along the slot, v front(-)/back(+)
    return (x, -6.0 + v*c + u*s, 6.0 + u*c - v*s)

def build(tilt_deg, spec):
    geo = geometry(tilt_deg, spec)
    s, c = geo["s"], geo["c"]
    g2 = geo["g2"]

    tris = []
    for x0, x1, v0, v1, u0, u1 in geo["channel"]:
        tris += box_tris(lambda i, j, k, a=(x0,x1), b=(v0,v1), d=(u0,u1):
                         channel_point(s, c, a[i], b[j], d[k]))
    for x0, x1, y0, y1, z0, z1 in geo["axis"] + geo["brand"]:
        tris += box_tris(lambda i, j, k, a=(x0,x1), b=(y0,y1), d=(z0,z1):
                         (a[i], b[j], d[k]))
    for p in geo["prisms"]:
        tris += prism_tris(p)

    # sanity: nothing below the print bed
    for t in tris:
        for pnt in t:
            assert pnt[2] >= -1e-6, f"vertex below bed at tilt {tilt_deg}: {pnt}"
    # sanity: tilted parts stay embedded in the 6 mm base where they should
    for x, v, u in [(20, -g2-6, -6), (20, -g2, -6),     # tab bottoms
                    (20, -g2, -4), (20, 2.0, -4),       # seat front bottoms
                    (20, 1.0, 0), (20, g2+8, 0)]:       # seat back bottoms
        z = channel_point(s, c, x, v, u)[2]
        assert -1e-6 <= z <= 6.0, f"cap not embedded at tilt {tilt_deg}: {z}"

    seat_z = channel_point(s, c, 0, g2 - spec["thickest"]/2, 34)[2]
    com_y = -6.0 + (g2 - spec["thickest"]/2)*c + (34 + spec["tallest"]/2)*s
    stats = dict(
        tilt=tilt_deg,
        footprint=(2*geo["base_x"], geo["backY"] + 42),
        height=max(pnt[2] for t in tris for pnt in t),
        seat_z=seat_z,
        plug_clearance=seat_z - 16 - 6,            # rigid plug is ~16 mm
        portal_roof=channel_point(s, c, 0, 12.5, 26)[2],
        com_margin=geo["backY"] - com_y,
    )
    return tris, stats

def phone_ghost(tilt_deg, g2, w, t, h):
    # the phone leans on the back rail face at v = g2, its slot datum
    T = math.radians(tilt_deg)
    s, c = math.sin(T), math.cos(T)
    return box_tris(lambda i, j, k,
                    a=(-w/2, w/2), b=(g2-t, g2), d=(34, 34+h):
                    channel_point(s, c, a[i], b[j], d[k]))

def write_stl(path, tris):
    # binary STL: a fifth the size of ASCII, which matters when the files
    # are downloads served from the phone itself
    with open(path, "wb") as f:
        f.write(b"NAS Reborn phone stand".ljust(80))
        f.write(struct.pack("<I", len(tris)))
        for a, b, d in tris:
            n = norm(cross(sub(b, a), sub(d, a)))
            f.write(struct.pack("<12fH", *n, *a, *b, *d, 0))

# --- simple orthographic SVG preview -----------------------------------------

def draw_view(tris_groups, viewdir, uphint, cell, off, label, parts):
    v = norm(viewdir)
    r = norm(cross(v, uphint))
    up = norm(cross(r, v))
    L = norm((0.4, -0.6, 0.7))

    projected = []
    for tris, style in tris_groups:
        for a, b, d in tris:
            n = norm(cross(sub(b, a), sub(d, a)))
            if dot(n, v) >= 0:                     # backface cull
                continue
            pts = [(dot(p, r), -dot(p, up)) for p in (a, b, d)]
            depth = sum(dot(p, v) for p in (a, b, d)) / 3
            shade = 0.35 + 0.65 * max(0.0, dot(n, L))
            projected.append((depth, pts, shade, style))
    xs = [p[0] for _, pts, _, _ in projected for p in pts]
    ys = [p[1] for _, pts, _, _ in projected for p in pts]
    w, h = max(xs) - min(xs), max(ys) - min(ys)
    sc = min((cell - 40) / w, (cell - 60) / h)
    ox = off[0] + (cell - w*sc)/2 - min(xs)*sc
    oy = off[1] + 20 + (cell - 60 - h*sc)/2 - min(ys)*sc

    projected.sort(key=lambda t: -t[0])            # far to near
    for _, pts, shade, style in projected:
        pt = " ".join(f"{ox+x*sc:.1f},{oy+y*sc:.1f}" for x, y in pts)
        if style == "solid":
            g = int(200 * shade)
            parts.append(f'<polygon points="{pt}" fill="rgb({g},{g},{g+18})" '
                         f'stroke="rgb({g//2},{g//2},{g//2})" stroke-width="0.3"/>')
        else:                                       # style is a ghost colour pair
            fill, stroke = style
            parts.append(f'<polygon points="{pt}" fill="{fill}" '
                         f'fill-opacity="0.40" stroke="{stroke}" stroke-width="0.4"/>')
    parts.append(f'<text x="{off[0]+cell/2:.0f}" y="{off[1]+cell-8}" '
                 f'text-anchor="middle" font-family="Helvetica" '
                 f'font-size="20" fill="#333">{label}</text>')

def write_preview(path):
    cell = 540
    parts = [f'<svg xmlns="http://www.w3.org/2000/svg" '
             f'width="{2*cell}" height="{2*cell}" '
             f'style="background:#fff">']
    s6, uni = SPECS
    blue, green = ("#4a90d9", "#2a6099"), ("#57b072", "#2e7048")
    views = [
        (s6,  15, (-0.5, 0.72, -0.48), (0, 0, 1), "6s, front iso, 15°",
         [(PH_W, PH_T, PH_H, blue)]),
        (uni, 15, (-0.5, 0.72, -0.48), (0, 0, 1), "universal, front iso, 15°",
         [(78.1, 8.8, 163.0, green), (PH_W, PH_T, PH_H, blue)]),
        (uni, 15, (0, 0.02, -1),       (0, 1, 0), "universal, top, 15°",
         [(78.1, 8.8, 163.0, green)]),
        (uni, 25, (-1, 0.02, -0.05),   (0, 0, 1), "universal, side, 25°",
         [(78.1, 8.8, 163.0, green), (PH_W, PH_T, PH_H, blue)]),
    ]
    for idx, (spec, tilt, vdir, uphint, label, ghosts) in enumerate(views):
        tris, _ = build(tilt, spec)
        g2 = spec["gap"] / 2
        groups = [(tris, "solid")] + \
                 [(phone_ghost(tilt, g2, w, t, h), col) for w, t, h, col in ghosts]
        off = ((idx % 2) * cell, (idx // 2) * cell)
        draw_view(groups, vdir, uphint, cell, off, label, parts)
    parts.append("</svg>")
    with open(path, "w") as f:
        f.write("\n".join(parts))

if __name__ == "__main__":
    for spec in SPECS:
        for tilt in TILTS:
            tris, stats = build(tilt, spec)
            path = os.path.join(OUT, f"iphone-{spec['key']}-stand-{tilt:02d}deg.stl")
            write_stl(path, tris)
            print(f"{os.path.basename(path)}: {len(tris)} tris, "
                  f"footprint {stats['footprint'][0]:.0f}x{stats['footprint'][1]:.0f} mm, "
                  f"height {stats['height']:.0f} mm, seat at z={stats['seat_z']:.1f}, "
                  f"plug clearance {stats['plug_clearance']:.1f} mm, "
                  f"cable portal roof z={stats['portal_roof']:.1f}, "
                  f"CoM margin {stats['com_margin']:.0f} mm")
    write_preview(os.path.join(OUT, "stand-preview.svg"))
    print("preview written")
