#!/usr/bin/env python3
"""art_case.py — iPhone 18 Pro case that shows a painting through cut-out windows.

A painting card (65 x 100 mm) slides in from the BOTTOM EDGE into a thin
channel directly behind the patterned shell; the phone rests on two side rails
and closes the channel, so there is nothing to bridge when printing. Swap it any time, phone stays in. The windows reveal parts of
the painting; move the card a few mm or flip it and the composition changes.

Patterns:  cells  - irregular rounded Voronoi windows, stained-glass feel
           moon   - a crescent, scattered stars, and a little swarm of dots
Run:  python3 art_case.py --pattern cells --seed 3
"""
import math, json, argparse
import numpy as np
import cadquery as cq
from shapely.geometry import Polygon, Point, box, MultiPolygon
from shapely.ops import unary_union
from scipy.spatial import Voronoi

P = dict(
    phone_h=150.0, phone_w=71.9, phone_d=8.75, corner_r=11.0, fit=0.25,
    wall=1.6, lip=1.1, lip_t=1.2,
    shell=1.2,              # outer patterned shell thickness (lunar pattern uses 2.0, set below)
    channel=1.2,            # card channel (cards up to ~1.0 mm)
    floor=0.0,              # no floor: phone rests on 3.5 mm side rails, card sits right under the shell
    rail=3.5,               # side rails the phone rests on, either side of the card channel
    plateau_w=63.0, plateau_h=42.0, plateau_top=4.5, plateau_r=8.0, plateau_clear=1.5,
    rib=1.8,                # minimum material between windows
    margin=4.0,             # solid border around the pattern area
    left_cut=(32.0, 86.0), right_cuts=((38.0, 66.0), (96.0, 124.0)), cut_depth_margin=1.0,
    bottom_cut_w=52.0,
    thumb_r=7.0,            # thumb notch at the bottom to push the card out
)

def rounded_rect(w, h, r):
    return box(-w/2, -h/2, w/2, h/2).buffer(r).buffer(-2*r).buffer(r)

def pattern_cells(area: Polygon, rib, seed, n=16, round_r=2.5):
    rng = np.random.default_rng(seed)
    minx, miny, maxx, maxy = area.bounds
    pts = np.column_stack([rng.uniform(minx, maxx, n), rng.uniform(miny, maxy, n)])
    # a few far-away points so every real cell is bounded
    far = np.array([[minx-200, miny-200], [maxx+200, miny-200], [minx-200, maxy+200], [maxx+200, maxy+200]])
    vor = Voronoi(np.vstack([pts, far]))
    cells = []
    for i in range(n):
        reg = vor.regions[vor.point_region[i]]
        if -1 in reg or not reg: continue
        poly = Polygon(vor.vertices[reg]).intersection(area)
        poly = poly.buffer(-rib/2).buffer(-round_r).buffer(round_r)     # rib gap + rounded corners
        if poly.is_empty or poly.area < 40: continue
        cells.append(poly)
    return cells

def pattern_moon(area: Polygon, rib, seed):
    rng = np.random.default_rng(seed)
    minx, miny, maxx, maxy = area.bounds
    cx, cy = (minx+maxx)/2, miny + (maxy-miny)*0.68
    R = (maxx-minx)*0.30
    crescent = Point(cx, cy).buffer(R).difference(Point(cx + R*0.45, cy + R*0.25).buffer(R*0.85))
    shapes = [crescent.intersection(area.buffer(-rib))]
    # swarm of dots in the lower half, denser toward the middle (a huddle)
    for _ in range(26):
        r = abs(rng.normal(0, (maxx-minx)*0.22)); a = rng.uniform(0, 2*math.pi)
        x, y = cx + r*math.cos(a), miny + (maxy-miny)*0.30 + r*0.6*math.sin(a)
        d = Point(x, y).buffer(rng.uniform(1.6, 3.4))
        shapes.append(d)
    # a few stars (4-point) up top
    for _ in range(5):
        x, y = rng.uniform(minx+6, maxx-6), rng.uniform(cy + R*0.2, maxy-5)
        s = rng.uniform(2.5, 4.5)
        star = Polygon([(x, y+s), (x+s*0.3, y+s*0.3), (x+s, y), (x+s*0.3, y-s*0.3),
                        (x, y-s), (x-s*0.3, y-s*0.3), (x-s, y), (x-s*0.3, y+s*0.3)])
        shapes.append(star)
    merged = unary_union([s for s in shapes if not s.is_empty]).intersection(area.buffer(-rib))
    polys = list(merged.geoms) if isinstance(merged, MultiPolygon) else [merged]
    # keep ribs: drop anything thinner than rib by opening
    return [p for p in polys if not p.is_empty and p.area > 6]

def pattern_contour(area: Polygon, rib, seed, band=4.2, kind="terrain", round_r=1.2):
    """Windows = bands between contour lines of a smooth field. Ribs are the
    contour lines, so they are continuous and all one width."""
    import contourpy
    rng = np.random.default_rng(seed)
    minx, miny, maxx, maxy = area.bounds
    xs = np.arange(minx - 2, maxx + 2, 0.4); ys = np.arange(miny - 2, maxy + 2, 0.4)
    X, Y = np.meshgrid(xs, ys)
    if kind == "terrain":
        Z = 0.012 * (Y - miny)                                  # gentle slope so lines run across
        for _ in range(2):                                      # two broad soft hills
            cx, cy = rng.uniform(minx, maxx), rng.uniform(miny, maxy)
            sx, sy = rng.uniform(24, 40), rng.uniform(24, 40)
            Z += rng.uniform(0.9, 1.3) * np.exp(-((X-cx)**2/(2*sx**2) + (Y-cy)**2/(2*sy**2)))
    else:                                                       # ripple: rings from one off-corner point
        cx, cy = maxx + 8, maxy - 10
        Z = np.hypot(X - cx, Y - cy) / 40.0
    # normalise so one band = `band` mm on average along the gradient
    g = np.gradient(Z); gm = np.mean(np.hypot(g[0], g[1])) / 0.4
    Z = Z / gm                                                  # now |grad Z| ~ 1 per mm on average
    pitch = band + rib
    levels = []
    z0, z1 = Z.min(), Z.max()
    l = z0 - pitch
    while l < z1 + pitch:
        levels += [l, l + band]; l += pitch
    cg = contourpy.contour_generator(xs, ys, Z, fill_type=contourpy.FillType.OuterOffset)
    out = []
    for i in range(0, len(levels) - 1, 2):                    # every other band is a window
        polys, offs = cg.filled(levels[i], levels[i+1])
        for pts, off in zip(polys, offs):
            ext = pts[off[0]:off[1]]
            holes = [pts[off[k]:off[k+1]] for k in range(1, len(off) - 1)]
            poly = Polygon(ext, holes).buffer(0)
            poly = poly.intersection(area).buffer(-round_r).buffer(round_r)
            if poly.is_empty: continue
            geoms = poly.geoms if isinstance(poly, MultiPolygon) else [poly]
            out += [p for p in geoms if p.area > 12]
    return out

def craters(area: Polygon, rib, seed, shell, n_big=7, n_small=22, n_micro=45, slope_deg=50.0):
    """Lunar surface. Big craters: through-windows with sloped walls, spread out with
    best-of-k placement so they do not clump. Small craters: mostly blind dimples,
    allowed to overlap anything (as real small impacts do). Micro-craters: shallow
    texture. Returns (window_polys_at_inner_face, cutter_solids)."""
    rng = np.random.default_rng(seed)
    minx, miny, maxx, maxy = area.bounds
    t = math.tan(math.radians(slope_deg))
    floor_of = lambda r: r - shell / t
    placed_windows = []       # (x, y, floor_r)
    all_placed = []           # (x, y, r)
    cut, windows = [], []

    def add(x, y, r):
        fr = floor_of(r)
        if fr > 1.5:
            cone = cq.Solid.makeCone(r, fr, shell + 0.02, pnt=cq.Vector(x, y, -0.01), dir=cq.Vector(0, 0, 1))
            cut.append(cq.Workplane("XY").add(cone)); windows.append(Point(x, y).buffer(fr))
            placed_windows.append((x, y, fr))
        else:
            depth = min(shell * 0.55, max(0.3, (r - 0.5) / t))
            cone = cq.Solid.makeCone(r, max(0.3, r - depth * t), depth + 0.01, pnt=cq.Vector(x, y, -0.005), dir=cq.Vector(0, 0, 1))
            cut.append(cq.Workplane("XY").add(cone))
        all_placed.append((x, y, r))

    def window_ok(x, y, r):
        fr = floor_of(r)
        return all(math.hypot(x-px, y-py) > fr + pfr + rib for px, py, pfr in placed_windows)

    # big craters: best-of-k spread (pick the candidate farthest from everything placed)
    big = sorted([13.5, 11.0] + [5.0 + 7.0 * (rng.random() ** 2.0) for _ in range(n_big - 2)], reverse=True)
    for r in big:
        best, best_d = None, -1
        for _ in range(60):
            x, y = rng.uniform(minx + r, maxx - r), rng.uniform(miny + r, maxy - r)
            if not area.contains(Point(x, y).buffer(r * 0.9)): continue
            if not window_ok(x, y, r): continue
            d = min([math.hypot(x-px, y-py) - pr for px, py, pr in all_placed] + [99])
            if d > best_d: best, best_d = (x, y), d
        if best: add(best[0], best[1], r)

    # small craters anywhere inside the area; windows still keep rib spacing
    for r in [1.8 + 2.4 * (rng.random() ** 1.5) for _ in range(n_small)]:
        for _ in range(80):
            x, y = rng.uniform(minx + r, maxx - r), rng.uniform(miny + r, maxy - r)
            if not area.contains(Point(x, y).buffer(r * 0.9)): continue
            if floor_of(r) > 1.5 and not window_ok(x, y, r): continue
            add(x, y, r); break

    # micro craters: pure texture, shallow
    for r in [0.7 + 0.8 * rng.random() for _ in range(n_micro)]:
        x, y = rng.uniform(minx + 2, maxx - 2), rng.uniform(miny + 2, maxy - 2)
        if not area.contains(Point(x, y)): continue
        if any(math.hypot(x-px, y-py) < pfr + 0.6 for px, py, pfr in placed_windows): continue
        depth = 0.35
        cone = cq.Solid.makeCone(r, max(0.25, r - depth * t), depth + 0.01, pnt=cq.Vector(x, y, -0.005), dir=cq.Vector(0, 0, 1))
        cut.append(cq.Workplane("XY").add(cone))
    return windows, cut

def pattern_swarm(area: Polygon, rib, seed, arms=6, w_out=3.2, w_in=1.9, core=4.5):
    """Six spiral arms converging on a small core: the huddle, drawn as a galaxy.
    Logarithmic spiral so arms never cross; ribbon width tapers inward."""
    from shapely.geometry import LineString
    from shapely.affinity import rotate
    minx, miny, maxx, maxy = area.bounds
    cx, cy = (minx + maxx) / 2, miny + (maxy - miny) * 0.47
    R0 = 0.5 * (maxy - miny) * 0.98
    k = 0.42                                           # spiral tightness
    out = [Point(cx, cy).buffer(core)]
    for i in range(arms):
        th0 = 2 * math.pi * i / arms
        pts, widths = [], []
        rs = np.geomspace(R0, core * 0.9, 160)
        for r in rs:
            th = th0 + math.log(R0 / r) / k
            pts.append((cx + r * math.cos(th), cy + r * math.sin(th)))
        # tapered ribbon: union of short buffered segments with shrinking width
        segs = []
        for j in range(len(pts) - 1):
            f = j / (len(pts) - 2)
            w = w_out * (1 - f) + w_in * f
            segs.append(LineString([pts[j], pts[j+1]]).buffer(w / 2, cap_style=1))
        out.append(unary_union(segs))
    merged = unary_union(out).intersection(area.buffer(-rib * 0.5))
    merged = merged.buffer(-0.6).buffer(0.6)
    polys = list(merged.geoms) if isinstance(merged, MultiPolygon) else [merged]
    # drop islands of material too small to print (would fall out of the window)
    clean = []
    for p in polys:
        if p.is_empty or p.area < 10: continue
        keep = [h for h in p.interiors if Polygon(h).area > 12 and Polygon(h).buffer(-rib/2).area > 0.5]
        clean.append(Polygon(p.exterior, keep))
    return clean

def pattern_phases(area: Polygon, rib, seed, r=8.2, gap=3.0):
    """Five moon phases down the back: crescent, half, full, half, crescent,
    computed with the real terminator ellipse."""
    from shapely.affinity import scale
    from shapely.geometry import box as sbox
    minx, miny, maxx, maxy = area.bounds
    cx = (minx + maxx) / 2
    phis = [40, 90, 180, 90, 40]; waning = [False, False, False, True, True]
    pitch = 2 * r + gap
    y0 = (miny + maxy) / 2 + pitch * (len(phis) - 1) / 2
    out = []
    for k, (phi, wn) in enumerate(zip(phis, waning)):
        y = y0 - k * pitch
        disc = Point(cx, y).buffer(r, resolution=96)
        c = math.cos(math.radians(phi))
        halfL = disc.intersection(sbox(cx - 2*r, y - 2*r, cx, y + 2*r))
        halfR = disc.intersection(sbox(cx, y - 2*r, cx + 2*r, y + 2*r))
        E = scale(disc, xfact=max(abs(c), 1e-3), yfact=1.0, origin=(cx, y))
        lit = halfL.difference(E) if c > 0 else halfL.union(halfR.intersection(E))
        if wn: lit = scale(lit, xfact=-1, yfact=1, origin=(cx, y))
        lit = lit.buffer(-0.5).buffer(0.5)
        if not lit.is_empty and lit.area > 5: out.append(lit)
    return out

def to_cq(poly: Polygon, z0, h):
    wp = cq.Workplane("XY").workplane(offset=z0)
    ext = list(poly.exterior.coords)[:-1]
    solid = wp.polyline(ext).close().extrude(h)
    for ring in poly.interiors:
        solid = solid.cut(cq.Workplane("XY").workplane(offset=z0).polyline(list(ring.coords)[:-1]).close().extrude(h))
    return solid

def build(P, pattern="cells", seed=3):
    H, W, D = P["phone_h"] + 2*P["fit"], P["phone_w"] + 2*P["fit"], P["phone_d"]
    wall, lip, lip_t = P["wall"], P["lip"], P["lip_t"]
    back = P["shell"] + P["channel"] + P["floor"]
    R = P["corner_r"] + P["fit"]
    OH, OW, OR = H + 2*wall, W + 2*wall, R + wall
    total_h = back + D + lip_t

    body = cq.Workplane("XY").rect(OW, OH).extrude(total_h).edges("|Z").fillet(OR)
    cav = (cq.Workplane("XY").workplane(offset=back).rect(W, H).extrude(D + lip_t + 1).edges("|Z").fillet(R))
    body = body.cut(cav)
    lip_ring = cq.Workplane("XY").workplane(offset=back + D).rect(OW, OH).extrude(lip_t).edges("|Z").fillet(OR)
    lip_hole = (cq.Workplane("XY").workplane(offset=back + D).rect(W - 2*lip, H - 2*lip).extrude(lip_t + 1)
                .edges("|Z").fillet(max(0.5, R - lip)))
    body = body.union(lip_ring.cut(lip_hole))
    try: body = body.faces("<Z").edges().fillet(1.0)
    except Exception: pass

    # plateau cutout through everything
    pw, ph = P["plateau_w"] + 2*P["plateau_clear"], P["plateau_h"] + 2*P["plateau_clear"]
    py = H/2 - P["plateau_top"] - P["plateau_h"]/2
    body = body.cut(cq.Workplane("XY").center(0, py).rect(pw, ph).extrude(back + 1).edges("|Z").fillet(P["plateau_r"]))

    # ---- card channel: full width below the plateau, open at the bottom edge ----
    card_w = W - 2*P["rail"]
    top_of_card = py - ph/2 - 2.0                      # 2 mm below the plateau cutout
    card_h = top_of_card - (-H/2)                      # runs to the bottom edge
    z_ch = P["shell"]
    channel = (cq.Workplane("XY").workplane(offset=z_ch)
               .center(0, top_of_card - card_h/2 - (wall + 1)/2)
               .rect(card_w + 0.6, card_h + wall + 1).extrude(P["channel"]))
    body = body.cut(channel)                           # this also opens the slot through the bottom wall
    # thumb notch in the shell at the bottom so you can push the card out
    body = body.cut(cq.Workplane("XY").center(0, -H/2 - wall).circle(P["thumb_r"]).extrude(P["shell"] + 0.01))

    # ---- artistic windows through the shell only ----
    m = P["margin"]
    area = box(-card_w/2 + m, -H/2 + m + P["thumb_r"], card_w/2 - m, top_of_card - m)
    area = area.buffer(3).buffer(-3)
    extra_cutters = []
    if pattern == "lunar":
        windows, extra_cutters = craters(area, P["rib"], seed, P["shell"])
    windows = windows if pattern == "lunar" else {"cells": lambda: pattern_cells(area, P["rib"], seed),
               "moon": lambda: pattern_moon(area, P["rib"], seed),
               "contour": lambda: pattern_contour(area, P["rib"], seed, kind="terrain"),
               "ripple": lambda: pattern_contour(area, P["rib"], seed, kind="ripple"),
               "swarm": lambda: pattern_swarm(area, P["rib"], seed),
               "phases": lambda: pattern_phases(area, P["rib"], seed)}[pattern]()
    if pattern == "lunar":
        for c in extra_cutters:
            body = body.cut(c)
    else:
        for w in windows:
            body = body.cut(to_cq(w, -1, P["shell"] + 2))

    # ---- side & bottom cutouts (above the back, so the channel stays intact) ----
    def side_cut(x_sign, y0, y1):
        yc = H/2 - (y0 + y1)/2
        return (cq.Workplane("XY").workplane(offset=back + P["cut_depth_margin"])
                .center(x_sign*(W/2 + wall/2), yc).rect(wall + 2, y1 - y0).extrude(D + lip_t + 1))
    body = body.cut(side_cut(-1, *P["left_cut"]))
    for yc in P["right_cuts"]: body = body.cut(side_cut(+1, *yc))
    body = body.cut(cq.Workplane("XY").workplane(offset=back + P["cut_depth_margin"])
                    .center(0, -(H/2 + wall/2)).rect(P["bottom_cut_w"], wall + 2).extrude(D + lip_t + 1))

    info = dict(outer_mm=[round(OW,1), round(OH,1), round(total_h,2)],
                card_mm=[round(card_w,1), round(card_h,1)], card_max_t=P["channel"] - 0.2,
                windows=len(windows), open_fraction=round(sum(w.area for w in windows)/area.area, 2),
                pattern=pattern, seed=seed)
    return body, info, (card_w, card_h, windows, area)

if __name__ == "__main__":
    ap = argparse.ArgumentParser(); ap.add_argument("--pattern", default="cells"); ap.add_argument("--seed", type=int, default=3)
    a = ap.parse_args()
    if a.pattern == "lunar": P["shell"] = 2.0          # depth for the crater walls
    case, info, (cw, ch, windows, area) = build(P, a.pattern, a.seed)
    tag = f"art_case_{a.pattern}_s{a.seed}"
    cq.exporters.export(case, tag + ".stl", tolerance=0.02, angularTolerance=0.2)
    info["volume_cm3"] = round(case.val().Volume()/1000, 1)
    json.dump(info, open(tag + ".json", "w"), indent=1); print(json.dumps(info, indent=1))
    # painting template: card outline + the windows drawn on it, so you know what will show
    minx, miny = -cw/2, min(w.bounds[1] for w in windows) - 50
    def path(poly):
        d = "M " + " L ".join(f"{x+cw/2+10:.2f},{(ch/2 - y) + 10 + 0:.2f}" for x, y in poly.exterior.coords) + " Z"
        return d
    H = P["phone_h"] + 2*P["fit"]
    def sx(x): return x + cw/2 + 10
    def sy(y): return (-(-H/2) - y) + 10 - (ch - (H))  # map card top (at top_of_card) to 10mm
    top_of_card = -H/2 + ch
    svg = [f'<svg xmlns="http://www.w3.org/2000/svg" width="{cw+20}mm" height="{ch+20}mm" viewBox="0 0 {cw+20} {ch+20}">',
           f'<rect x="10" y="10" width="{cw}" height="{ch}" rx="3" fill="none" stroke="#000" stroke-width="0.3"/>',
           '<text x="10" y="7" font-size="3" font-family="sans-serif">print at 100%. cut the outer rectangle; grey shapes are what shows through the case</text>']
    for w in windows:
        pts = " ".join(f"{sx(x):.2f},{(top_of_card - y) + 10:.2f}" for x, y in w.exterior.coords)
        svg.append(f'<polygon points="{pts}" fill="#ddd" stroke="#888" stroke-width="0.2"/>')
    svg.append(f'<circle cx="{sx(0):.2f}" cy="{ch+10:.2f}" r="{P["thumb_r"]}" fill="none" stroke="#bbb" stroke-dasharray="1 1" stroke-width="0.2"/>')
    svg.append("</svg>")
    open(tag + "_template.svg", "w").write("\n".join(svg))
