#!/usr/bin/env python3
"""
kit v3 — complete morphospace kit, single sheet, fully recalibrated.
Comb winner: drawn 3.4 mm  ->  t=3.7 eff, k=0.20, FIT_PRESS=-0.10 (SW=3.40)
Contents: 11 bars (4xS50, 4xS75, 3xS110), cookies H3/H4/H5,
4x 3-layer clevis (4 fork plates + 8 cheeks + 14 pins), 4 lollipops,
2 heads, 4 feet (sole+fin). Dropped: cones, v1 snap forks, comb.
"""
import math
from shapely.geometry import Polygon, Point, box
from shapely import affinity

t, k, FIT_PRESS = 3.7, 0.20, -0.10
SW = round(t - k + FIT_PRESS, 2)
FIT_RUN = 0.05
SLOT_D, STRUT_W = 9.0, 16.0
HUB_R = 22.0
DISC_R, POCKET_R, WINDOW_R = 12.0, 12.0 + FIT_RUN, 9.0
PLATE_RO, MOUTH, FBAR = 23.0, 21.0, 34.0
PIN_W, PIN_L, PIN_R, TAIL_PIN_X = 8.0, 12.0, 17.5, 26.0
PIN_ANGS = [142, 218]
HOLE_L = round(PIN_W - k + FIT_PRESS, 2)

CUT, ENG, TXT = [], [], []

def pin_hole(cx, cy, along_deg):
    h = box(-HOLE_L/2, -SW/2, HOLE_L/2, SW/2)
    return affinity.translate(affinity.rotate(h, along_deg, origin=(0,0)), cx, cy)

def _pins_cut(b):
    for a in PIN_ANGS:
        b = b.difference(pin_hole(PIN_R*math.cos(math.radians(a)),
                                  PIN_R*math.sin(math.radians(a)), a+90))
    return b.difference(pin_hole(TAIL_PIN_X, 0, 90))

MORT = round(9 - k + FIT_PRESS, 2)   # 8.70: grips the 9mm fin tab
def strut(L):
    b = box(0, -STRUT_W/2, L, STRUT_W/2).buffer(2, join_style=1).buffer(-2, join_style=1)
    b = b.difference(box(0, -SW/2, SLOT_D, SW/2))
    b = b.difference(box(L-SLOT_D, -SW/2, L, SW/2))
    for cx in range(25, int(L)-15, 25):   # mid-bar mortises, 25mm pitch from left datum
        b = b.difference(box(cx-SW/2, -MORT/2, cx+SW/2, MORT/2))
    return b

def hub(n):
    b = Point(0,0).buffer(HUB_R, quad_segs=48)
    for i in range(n):
        b = b.difference(affinity.rotate(box(-SW/2, HUB_R-SLOT_D, SW/2, HUB_R+1),
                                         360*i/n, origin=(0,0)))
    return b

MOUTH_HALF_DEG = 60.0   # wedge mouth: swing limited at pocket rim, not outer rim
def fork_plate():
    outer = Point(0,0).buffer(PLATE_RO, quad_segs=64).union(
        box(0, -STRUT_W/2, PLATE_RO+FBAR, STRUT_W/2))
    b = outer.difference(Point(0,0).buffer(POCKET_R, quad_segs=64))
    wedge = Polygon([(0,0)] + [(28*math.cos(math.radians(a)), 28*math.sin(math.radians(a)))
                               for a in range(int(180-MOUTH_HALF_DEG), int(180+MOUTH_HALF_DEG)+1, 2)])
    b = b.difference(wedge)
    b = b.difference(box(PLATE_RO+FBAR-SLOT_D, -SW/2, PLATE_RO+FBAR, SW/2))
    return _pins_cut(b)

def cheek():
    b = Point(0,0).buffer(PLATE_RO, quad_segs=64).union(box(0, -8, TAIL_PIN_X+7, 8))
    b = b.difference(Point(0,0).buffer(WINDOW_R, quad_segs=64))
    return _pins_cut(b)

def cheek_index(cx, cy):
    ENG.append(((cx-16, cy), (cx-10.2, cy)))
    ENG.append(((cx-13, cy+1.8), (cx-10.2, cy)))
    ENG.append(((cx-13, cy-1.8), (cx-10.2, cy)))

def pin():
    return box(0, 0, PIN_W, PIN_L).buffer(0.8, join_style=1).buffer(-0.8, join_style=1)

def disc_ticks(cx, cy):
    for a in range(0, 360, 15):
        r0 = 4.0 if a % 45 == 0 else 5.5
        ENG.append(((cx+r0*math.cos(math.radians(a)), cy+r0*math.sin(math.radians(a))),
                    (cx+8.2*math.cos(math.radians(a)), cy+8.2*math.sin(math.radians(a)))))

def lolli():
    b = Point(0,0).buffer(DISC_R, quad_segs=64).union(
        box(-STRUT_W/2+2, -DISC_R-26, STRUT_W/2-2, 0))
    return b.difference(box(-SW/2, -DISC_R-26, SW/2, -DISC_R-26+SLOT_D))

def head():
    b = Point(0,0).buffer(DISC_R, quad_segs=64).union(box(0,-6,27,6)).union(
        Point(41,2).buffer(14, quad_segs=64)).union(
        Polygon([(46,9),(68,3),(68,-3),(46,-7)])).union(
        Polygon([(36,14),(41,26),(45,14)]))
    return b.difference(box(41-SW/2, -12.5, 41+SW/2, -12.5+SLOT_D))

def head_eye(dx, dy):
    pts = [(dx+45+2.2*math.cos(math.radians(a)), dy+6+2.2*math.sin(math.radians(a)))
           for a in range(0,361,30)]
    for p0, p1 in zip(pts, pts[1:]):
        ENG.append((p0, p1))

def sole():
    b = box(0,0,44,26).buffer(4, join_style=1).buffer(-4, join_style=1)
    return b.difference(box(22-SW/2, 13-MORT/2, 22+SW/2, 13+MORT/2))

def fin():
    b = Polygon([(0,0),(26,0),(20,22),(6,22)]).union(box(13-4.5,-3.4,13+4.5,0.5))
    return b.difference(box(13-SW/2, 22-SLOT_D, 13+SW/2, 23))

def place(g, dx, dy, rot=0):
    CUT.append(affinity.translate(affinity.rotate(g, rot, origin=(0,0)), dx, dy))

# ---------------- layout ----------------
y = 14
for L, n, lab in [(50,4,"S50"), (75,4,"S75"), (110,3,"S110")]:
    for j in range(n):
        place(strut(L), 12, y + STRUT_W/2)
        if j == 0:
            TXT.append((12 + 10.5, y + STRUT_W/2 - 1.6, lab, 3.2))
        y += STRUT_W + 5
    y += 3
# bars end ~ y=248
for nsl, hx, lab in [(3, 46, "H3"), (4, 106, "H4"), (5, 166, "H5")]:
    place(hub(nsl), hx, 285)
    TXT.append((hx-5, 283, lab, 4.0))
for i in range(2):                    # heads
    hx, hy = 30, 335 + i*44
    place(head(), hx, hy); disc_ticks(hx, hy); head_eye(hx, hy)
for i in range(4):                    # fork plates
    place(fork_plate(), 305, 38 + i*50)
for i in range(4):                    # cheeks, col A (left of forks)
    cx, cy = 232, 40 + i*52
    place(cheek(), cx, cy); cheek_index(cx, cy)
for i in range(4):                    # cheeks, col B (right of forks)
    cx, cy = 390, 38 + i*50
    place(cheek(), cx, cy); cheek_index(cx, cy)
for i in range(4):                    # lollipops
    dx, dy = 300 + (i%2)*46, 330 + (i//2)*56
    place(lolli(), dx, dy); disc_ticks(dx, dy)
for i in range(14):                   # pins
    place(pin(), 396 + (i%4)*14, 232 + (i//4)*20)
labels = ["ANAIS", "HTMAA", "WK 2", "v3.1"]
for i in range(4):                    # soles
    sx, sy = 288 + (i%2)*50, 228 + (i//2)*32
    place(sole(), sx, sy)
    TXT.append((sx+27, sy+16.5, labels[i], 4.0))
for i in range(4):                    # fins
    place(fin(), 396 + (i%2)*36, 328 + (i//2)*34)
place(strut(50), 165, 350, rot=90)    # 12th bar: spare S50
TXT.append((158.5, 362, "S50", 3.0))

# ---------------- export ----------------
minx = min(g.bounds[0] for g in CUT)-6; miny = min(g.bounds[1] for g in CUT)-6
maxx = max(g.bounds[2] for g in CUT)+6; maxy = max(g.bounds[3] for g in CUT)+6
W, H = maxx-minx, maxy-miny
def d(poly):
    def ring(r): return "M " + " L ".join(f"{x-minx:.2f},{H-(y-miny):.2f}" for x, y in r.coords) + " Z"
    return " ".join([ring(poly.exterior)] + [ring(i) for i in poly.interiors])
parts = []
for g in CUT:
    for p in (g.geoms if g.geom_type == "MultiPolygon" else [g]):
        parts.append(f'<path d="{d(p)}" fill="none" stroke="black" stroke-width="0.2"/>')
for (p0, p1) in ENG:
    parts.append(f'<line x1="{p0[0]-minx:.2f}" y1="{H-(p0[1]-miny):.2f}" '
                 f'x2="{p1[0]-minx:.2f}" y2="{H-(p1[1]-miny):.2f}" stroke="blue" stroke-width="0.2"/>')
from matplotlib.textpath import TextPath
from matplotlib.font_manager import FontProperties
_fp = FontProperties(family="DejaVu Sans", weight="bold")
for (tx, ty, txt, size) in TXT:
    tp = TextPath((0, 0), txt, size=size, prop=_fp)
    dd = []
    for verts in tp.to_polygons():
        if len(verts) < 2: continue
        dd.append("M " + " L ".join(f"{tx+vx-minx:.2f},{H-(ty+vy-miny):.2f}" for vx, vy in verts) + " Z")
    parts.append(f'<path d="{" ".join(dd)}" fill="blue" stroke="none" fill-rule="evenodd"/>')
svg = (f'<svg xmlns="http://www.w3.org/2000/svg" width="{W:.0f}mm" height="{H:.0f}mm" '
       f'viewBox="0 0 {W:.2f} {H:.2f}">' + "".join(parts) + "</svg>")
open("kit_v3_1.svg", "w").write(svg)
print(f"kit_v3_1.svg {W:.0f} x {H:.0f} mm | 12 bars, 3 cookies, 4 forks, 8 cheeks, "
      f"14 pins, 4 lollis, 2 heads, 4 soles, 4 fins | SW={SW}")
