"""
PORTIX R1 — basılı parçalar, gerçek satın alınan parçaların ölçüleriyle.

Tek kaynak: bütün ölçüler P sözlüğünde. Bir parça farklı gelirse kumpasla ölç, buraya yaz, yeniden üret:

    pip install build123d trimesh
    python r1.py                # önce kontrol, sonra STL + numaralı 3MF işler + r1.json
    python r1.py --check        # yalnız kontrol: delikler açık mı, vidalar tutunuyor mu

Çıktılar (../../public/robotics/r1/):
    parts/<ad>.stl          montajdaki yerinde (sitedeki 3B görüntüleyici bunları yükler)
    print/NN_<iş>.3mf       Bambu X2D için numaralı baskı işleri (yalnız PLA, iş başına tek renk)
    r1.json                 ölçüler (görüntüleyici için)
    r1.py                   bu dosyanın kopyası (sitede indirilir)

Koordinatlar: x ileri, y sola, z yukarı, mm, zemin z=0, teker aksı x=0.
Baskı kuralları flywheel projesinden: X2D'de iki nozulun ortak x aralığı 20.5–235.5 mm, PLA,
renk değişimi yok, ince duvar ≥ 0.8 mm, sıkı geçmelerde düz yüzey yerine ezilen kaburga (crush rib).
"""

import json
import math
import sys
import zipfile
from pathlib import Path
from xml.sax.saxutils import quoteattr

import numpy as np
import trimesh
from build123d import (
    Axis,
    Box,
    BuildPart,
    BuildSketch,
    Cylinder,
    GeomType,
    Locations,
    Mode,
    Plane,
    Pos,
    RectangleRounded,
    Rot,
    Vector,
    chamfer,
    export_stl,
    extrude,
    fillet,
)

HERE = Path(__file__).resolve().parent
OUT = HERE.parents[1] / "public" / "robotics" / "r1"

P = dict(
    # --- motor: Robo90 JGA12-N20B 12 V 300 RPM enkoderli (ölç: dişli kutusu 12 x 10)
    n20_w=12.0,
    n20_h=10.0,
    shaft_d=3.0,
    shaft_flat=2.5,
    # --- teker
    wheel_d=63.0,
    wheel_w=18.0,
    rim_d=51.0,
    wheel_gap=0.8,
    # --- şasi
    plate_front=70.0,
    plate_back=80.0,
    plate_w=110.0,
    plate_t=3.0,
    corner_r=20.0,
    clamp_floor=1.5,
    caster_x=-62.0,
    caster_hole_spacing=30.0,  # Robotistan metal bilyeli sarhoş teker: ölç, yarık ±6 mm tolere eder
    # --- üst kat
    deck_z=49.0,
    deck_t=3.0,
    # --- kabuk (Cybertruck gibi pahlı: 45° pah baş aşağı desteksiz basılır)
    shell_h=52.0,
    shell_wall=2.2,
    shell_chamfer=10.0,
    # --- HC-SR04 (45 x 20 PCB, Ø16 dönüştürücü, merkezler arası 26 mm)
    eye_z=48.0,
    eye_d=16.8,
    eye_gap=26.0,
    # --- kafa: M5Stack CoreS3 + DinBase (54 x 54 x 31.5)
    # --- vida
    m3=3.3,
    m3_tap=2.8,
)

R = P["wheel_d"] / 2
PLATE_Z = R - P["n20_h"] / 2 - P["clamp_floor"] - P["plate_t"]  # = sarhoş teker yüksekliği (22)
TOP = PLATE_Z + P["plate_t"]
YB = P["plate_w"] / 2
WHEEL_Y = YB + P["wheel_gap"] + P["wheel_w"] / 2
PLATE_LEN = P["plate_front"] + P["plate_back"]
PLATE_CX = (P["plate_front"] - P["plate_back"]) / 2
FRONT = PLATE_CX + PLATE_LEN / 2
SHELL_TOP = TOP + P["shell_h"]
CLAMP_LEN = 16.0

CLAMP_HOLES = [(sx * 14.0, sy * (YB - CLAMP_LEN / 2)) for sx in (1, -1) for sy in (1, -1)]
DECK_LEGS = [(-64.0, 48.5), (-64.0, -48.5), (-26.0, 48.5), (-26.0, -48.5)]
SHELL_HOLES = [(-77.0, 0.0), (52.0, 42.0), (52.0, -42.0)]
EYE_HOLES = [(59.0, -14.0), (59.0, 14.0)]


def holes(points, d, z=0.0, h=40.0):
    with Locations(*[(x, y, z) for x, y in points]):
        Cylinder(d / 2, h, mode=Mode.SUBTRACT)


# =========================================================================== kafa ölçüleri
# Waveshare ESP32-S3 DualEye 1.28": ölçü çiziminden (docs.waveshare.com). Göz merkezleri ±28.5 mm.
EYE = dict(w=93.5, d=36.5, gap=57.0, t=8.0, bridge=21.0)
# Vizör: fotoğraftaki gibi geniş, köşeleri yuvarlak bir kutu; önde siyah yüz, üstte iki kulak
V = dict(w=118.0, h=56.0, depth=46.0, r=16.0, wall=2.4, clear=0.3, window=33.5)
HX = 12.0                                   # vizörün x merkezi (gövdenin önüne yakın)
NECK_H = 16.0
VZ0 = None                                  # SHELL_TOP hesaplanınca doldurulur
SG90 = dict(l=22.8, w=12.4, h=22.7, tab=32.2, tab_drop=4.3, shaft=5.9)
EAR_Y = 38.0                                # servo gövdesinin y merkezi
EAR_SX = 8.2                                # servo gövdesinin x merkezi
EAR_SHAFT_Y = EAR_Y + SG90["l"] / 2 - SG90["shaft"]

# =========================================================================== şasi
def plate():
    with BuildPart() as p:
        with BuildSketch(Plane.XY.offset(PLATE_Z)):
            with Locations((PLATE_CX, 0)):
                RectangleRounded(PLATE_LEN, P["plate_w"], P["corner_r"])
        extrude(amount=P["plate_t"])
        # every cut is centred on the plate (z = PLATE_Z), not on the ground: at z = 0 they missed it
        holes(CLAMP_HOLES, P["m3"], z=PLATE_Z)
        holes(DECK_LEGS, P["m3"], z=PLATE_Z)
        holes(SHELL_HOLES, P["m3"], z=PLATE_Z)
        holes(EYE_HOLES, P["m3"], z=PLATE_Z)
        with Locations(*[(P["caster_x"], s * P["caster_hole_spacing"] / 2, PLATE_Z) for s in (1, -1)]):
            Box(P["m3"], P["m3"] + 12, 40, mode=Mode.SUBTRACT)
        with Locations(*[(x, s * 41.5, PLATE_Z) for x in (-56, -30) for s in (1, -1)]):  # pil cırt bandı
            Box(20, 4, 40, mode=Mode.SUBTRACT)
        with Locations((-8, 0, PLATE_Z)):  # motor kabloları üst kata
            Box(5, 24, 40, mode=Mode.SUBTRACT)
    return p.part


def clamp(side=1):
    """N20 tüneli: motor içeriden itilir, dişli kutusu 1.5 mm ön duvara dayanır. Tünelin iki yanında
    0.3 mm ezilen kaburga: baskı toleransını alır, motor oynamaz."""
    w, h = P["n20_w"] + 0.5, P["n20_h"] + 0.4
    wall = 2.0
    y0, y1 = YB - CLAMP_LEN, YB
    zc = R
    body_h = zc + h / 2 + wall - TOP
    with BuildPart() as p:
        with Locations((0, side * (y0 + y1) / 2, TOP + body_h / 2)):
            Box(w + 2 * wall, CLAMP_LEN, body_h)
        for sx in (1, -1):
            with Locations((sx * 14.0, side * (y0 + y1) / 2, TOP + 1.5)):
                Box(10, CLAMP_LEN, 3)
        tl = CLAMP_LEN - 1.5
        with Locations((0, side * (y0 + tl / 2 - 1), zc)):
            Box(w, tl + 2, h, mode=Mode.SUBTRACT)
        for sx in (1, -1):  # kaburgalar
            with Locations((sx * (w / 2 - 0.15), side * (y0 + tl / 2), zc)):
                Box(0.6, tl - 2, 2.0)
        with Locations(Pos(0, side * (YB - 0.75), zc) * Rot(90, 0, 0)):
            Cylinder(2.6, 4, mode=Mode.SUBTRACT)
        with Locations((0, side * (y0 + 5), zc + h / 2 + wall / 2)):
            Box(w + 2 * wall + 2, 3, wall + 0.2, mode=Mode.SUBTRACT)
        holes([pt for pt in CLAMP_HOLES if pt[1] * side > 0], P["m3_tap"], z=TOP)
    return p.part


def rim(side=1):
    w, rr = P["wheel_w"], P["rim_d"] / 2
    with BuildPart() as p:
        Cylinder(rr, w)
        Cylinder(rr - 2.2, w - 3.6, mode=Mode.SUBTRACT)
        Cylinder(rr - 6, w, mode=Mode.SUBTRACT)
        with Locations((0, 0, w / 2 - 1.75)):
            Cylinder(rr - 5, 3.5)
        for i in range(6):
            a = i * math.pi / 3
            with Locations((13.0 * math.cos(a), 13.0 * math.sin(a), w / 2 - 2)):
                Cylinder(4.8, 6, mode=Mode.SUBTRACT)
        Cylinder(6.5, w)
        Cylinder(P["shaft_d"] / 2 + 0.1, w + 2, mode=Mode.SUBTRACT)
    part = p.part
    flat = P["shaft_flat"] - P["shaft_d"] / 2 + 0.05
    part = part + (Pos(flat + 1.5, 0, 0) * Box(3, 5, w) & Cylinder(P["shaft_d"] / 2 + 0.3, w))
    part = part - Pos(5, 0, -w / 2 + 4) * Rot(0, 90, 0) * Cylinder(P["m3_tap"] / 2, 10)
    return Pos(0, side * WHEEL_Y, R) * Rot(side * -90, 0, 0) * part


def tire(side=1):
    w, ro = P["wheel_w"] - 4.0, P["wheel_d"] / 2
    ri = P["rim_d"] / 2 - 2.2 + 0.2
    with BuildPart() as p:
        Cylinder(ro, w)
        Cylinder(ri, w, mode=Mode.SUBTRACT)
        outer = [e for e in p.edges() if e.geom_type == GeomType.CIRCLE and e.radius > ro - 0.1]
        fillet(outer, 2.5)
    t = p.part
    # bant/iç lastik şeridi için 10 mm geniş, 0.8 mm sığ kanal: sarılan bant kaymaz
    t = t - (Cylinder(ro + 1, 10) - Cylinder(ro - 0.8, 10))
    return Pos(0, side * WHEEL_Y, R) * Rot(side * -90, 0, 0) * t


def deck():
    z0 = P["deck_z"]
    with BuildPart() as p:
        with BuildSketch(Plane.XY.offset(z0)):
            with Locations((-28, 0)):
                RectangleRounded(96, 100, 12)
        extrude(amount=P["deck_t"])
        for x, y in DECK_LEGS:
            with Locations((x, y, (TOP + z0) / 2)):
                Cylinder(3.5, z0 - TOP)
        holes(DECK_LEGS, P["m3_tap"], z=TOP, h=20)
        with Locations((-8, 0, z0)):
            Box(8, 30, 20, mode=Mode.SUBTRACT)
        with Locations((-45, 40, z0), (-45, -40, z0)):
            Box(30, 5, 20, mode=Mode.SUBTRACT)
    return p.part


# =========================================================================== kabuk
def shell():
    L, W, H = PLATE_LEN - 1, P["plate_w"] - 1, P["shell_h"]
    wall, ch = P["shell_wall"], P["shell_chamfer"]

    def block(l, w, h, r, c, z0):
        with BuildPart() as b:
            with BuildSketch(Plane.XY.offset(z0)):
                with Locations((PLATE_CX, 0)):
                    RectangleRounded(l, w, r)
            extrude(amount=h)
            chamfer(b.edges().group_by(Axis.Z)[-1], c)
        return b.part

    outer = block(L, W, H, P["corner_r"] - 0.5, ch, TOP)
    inner = block(L - 2 * wall, W - 2 * wall, H - wall + 1, P["corner_r"] - 0.5 - wall, ch - wall * 0.41, TOP - 1)
    body = outer - inner
    front = PLATE_CX + L / 2
    for s in (1, -1):  # HC-SR04 "farları"
        body = body - Pos(front - 3, s * P["eye_gap"] / 2, P["eye_z"]) * Rot(0, 90, 0) * Cylinder(P["eye_d"] / 2, 20)
    for s in (1, -1):  # teker yuvaları
        body = body - Pos(0, s * W / 2, TOP + 8) * Box(28, 12, 16)
        body = body - Pos(0, s * W / 2, TOP + 16) * Rot(90, 0, 0) * Cylinder(14, 12)
    back = PLATE_CX - L / 2
    body = body - Pos(back, 0, P["deck_z"] + 20) * Box(12, 14, 9)  # omurilik ESP32'nin USB'si
    body = body - Pos(-52, 34, SHELL_TOP) * Box(19.4, 13.2, 10)  # KCD1 anahtar
    for x, y in NECK_SCREWS:  # kafa yuvası vidaları
        body = body - Pos(x, y, SHELL_TOP) * Cylinder(P["m3"] / 2, 10)
    body = body - Pos(NECK_CABLE[0], NECK_CABLE[1], SHELL_TOP) * Cylinder(7, 10)  # DinBase DC kablosu
    for x, y in SHELL_HOLES:
        ear = Pos(x, y, TOP + 3) * Box(12, 12, 6) - Pos(x, y, TOP + 3) * Cylinder(P["m3_tap"] / 2, 10)
        body = body + (ear & outer)
    return body


# =========================================================================== kafa (vizör)
VZ0 = SHELL_TOP + NECK_H
VZC = VZ0 + V["h"] / 2
XF = HX + V["depth"] / 2                    # vizörün ön yüzü
XB = HX - V["depth"] / 2                    # arka yüzü
NECK_SCREWS = [(HX - 6.0, 14.0), (HX - 6.0, -14.0)]   # gövdeye: M3 × 8 kabuğun içinden yukarı
NECK_TOP_SCREWS = [(HX, 11.0), (HX, -11.0)]          # vizöre: M3 × 12 vizörün içinden aşağı
NECK_CABLE = (HX, 0.0)


def vprofile(w, h, r, x0, length):
    """Vizörün yuvarlak köşeli kesiti (y-z düzleminde), x boyunca uzatılmış."""
    with BuildPart() as p:
        with BuildSketch(Plane.YZ.offset(x0)):
            with Locations((0, VZC)):
                RectangleRounded(w, h, r)
        extrude(amount=length)
    return p.part


def inner_dims(extra=0.0):
    w2 = V["wall"] + extra
    return V["w"] - 2 * w2, V["h"] - 2 * w2, V["r"] - w2


def visor_shell():
    """Beyaz dış kabuk: önü ve arkası açık bir tüp. İçinde ön ve arkada kapakların dayandığı
    2 mm'lik dudaklar, tavanda iki SG90 için vida blokları ve mil delikleri, tabanda boyun vidaları,
    kablo deliği ve hoparlör ızgarası."""
    wi, hi, ri = inner_dims()
    body = vprofile(V["w"], V["h"], V["r"], XB, V["depth"]) - vprofile(wi, hi, ri, XB - 1, V["depth"] + 2)
    lip_w, lip_h, lip_r = inner_dims(2.0)
    for x0 in (XF - 9.0, XB + 3.4):          # ön dudak (yüz plakasının arkası), arka dudak
        body = body + (vprofile(wi + 0.1, hi + 0.1, ri, x0, 2.0) - vprofile(lip_w, lip_h, lip_r, x0 - 1, 4.0))
    top_in = VZ0 + V["h"] - V["wall"]
    for s in (1, -1):
        # mil deliği: SG90'ın dişli çıkıntısı (Ø11.8) tavandan dışarı çıkar, kulak üstte takılır
        body = body - Pos(EAR_SX, s * EAR_SHAFT_Y, top_in) * Cylinder(6.6, 10)
        for k in (1, -1):                    # tırnak vida blokları
            yb = s * (EAR_Y + k * (SG90["l"] / 2 + 2.35))
            blk = Pos(EAR_SX, yb, top_in - SG90["tab_drop"] / 2) * Box(SG90["w"], 4.6, SG90["tab_drop"])
            body = body + (blk - Pos(EAR_SX, yb, top_in - 3) * Cylinder(0.8, 8))
    # taban: boyun vidaları için kalın pedler (geçiş deliği: vida boyuna tutunur), kablo deliği,
    # hoparlör ızgarası
    for x, y in NECK_TOP_SCREWS:
        pad = Pos(x, y, VZ0 + V["wall"] + 2) * Cylinder(4.5, 4)
        body = body + pad - Pos(x, y, VZ0 + 3) * Cylinder(P["m3"] / 2, 10)
    body = body - Pos(NECK_CABLE[0], 0, VZ0) * Box(14, 16, 10)
    for i in range(5):
        body = body - Pos(-2 + i * 3.2, 30, VZ0) * Box(1.6, 18, 10)
    return body


def visor_face():
    """Siyah yüz: iki göz penceresi, arkasında kartın iki gözünün oturduğu cep."""
    wi, hi, ri = inner_dims(V["clear"])
    plate = vprofile(wi, hi, ri, XF - 7.0, 6.0)
    for s in (1, -1):
        plate = plate - Pos(XF, s * EYE["gap"] / 2, VZC) * Rot(0, 90, 0) * Cylinder(V["window"] / 2, 20)
        plate = plate - Pos(XF - 5.5, s * EYE["gap"] / 2, VZC) * Rot(0, 90, 0) * Cylinder(EYE["d"] / 2 + 0.25, 3.01)
    plate = plate - Pos(XF - 5.5, 0, VZC) * Box(3.01, EYE["gap"] - 10, EYE["bridge"] + 0.5)
    return plate


def visor_back():
    """Beyaz arka kapak: iki direk kartı öne, yüz plakasına bastırır."""
    wi, hi, ri = inner_dims(V["clear"])
    x0 = XB + 1.0
    back = vprofile(wi, hi, ri, x0, 2.4)
    board_back = XF - 4.0 - EYE["t"]
    for s in (1, -1):
        L = board_back - (x0 + 2.4) - 0.3
        back = back + Pos(x0 + 2.4 + L / 2, s * 15.0, VZC) * Rot(0, 90, 0) * Cylinder(3.0, L)
    for i in range(4):                        # havalandırma
        back = back - Pos(x0, -30 + i * 20, VZC + 14) * Box(10, 12, 3)
    return back


def ear(side=1):
    """Turuncu kulak: tek kollu servo kolunun oturduğu oyuk, vida deliği."""
    with BuildPart() as p:
        with BuildSketch(Plane.XY.offset(0)):
            RectangleRounded(34, 12, 5.9)
        extrude(amount=3.0)
        with Locations((-11, 0, 0)):
            Cylinder(1.1, 10, mode=Mode.SUBTRACT)
            Box(7.5, 7.5, 3.2, mode=Mode.SUBTRACT)          # kol göbeği
        with Locations((-1, 0, 0)):
            Box(19, 5.0, 3.2, mode=Mode.SUBTRACT)          # kol
    e = p.part
    top = VZ0 + V["h"] + 4.0                                # dişli çıkıntısının üstü
    return Pos(EAR_SX + 11, side * EAR_SHAFT_Y, top) * e


def neck():
    """Siyah boyun: altta gövdeye 2 × M3 × 8 (kabuğun içinden yukarı), üstte vizöre 2 × M3 × 12
    (vizörün içinden aşağı). Vidalar boyuna tutunur: dört deliğin hepsi kılavuz ölçüsünde ve her
    birinin altında/üstünde göbek var, vida 5–6 mm diş bulur. İçi boş: USB-C kablosu ve iki servo
    kablosu buradan iner."""
    z0, z1 = SHELL_TOP, VZ0
    with BuildPart() as p:
        with Locations((HX - 3, 0, z0 + 1.5)):
            Box(36, 40, 3)
        with Locations((HX, 0, z1 - 1.5)):
            Box(30, 34, 3)
        with BuildSketch(Plane.XY.offset(z0)):
            with Locations((HX - 1, 0)):
                RectangleRounded(24, 28, 6)
        extrude(amount=z1 - z0)
        with BuildSketch(Plane.XY.offset(z0 - 1)):
            with Locations((HX - 1, 0)):
                RectangleRounded(17, 20, 3)
        extrude(amount=z1 - z0 + 2, mode=Mode.SUBTRACT)
        with Locations(*[(x, y, z0 + 3 + 2.5) for x, y in NECK_SCREWS]):  # alt plakanın üstünde
            Cylinder(3.5, 5)
        # üst göbek plakanın üst yüzüne kadar çıkar: kablo boşluğu plakayı da deldiği için vidanın bir yanı
        # açıkta kalıyordu; M3 × 12'nin ulaştığı 5.6 mm'nin tamamı dolu olsun
        with Locations(*[(x, y, z1 - 5.5) for x, y in NECK_TOP_SCREWS]):
            Cylinder(3.5, 11)
        holes(NECK_SCREWS, P["m3_tap"], z=z0, h=20)
        holes(NECK_TOP_SCREWS, P["m3_tap"], z=z1, h=22)
    return p.part


def eye_mount():
    t = 3.0
    x = FRONT - 0.5 - P["shell_wall"] - 2.5
    hz = P["eye_z"] - TOP + 13
    with BuildPart() as p:
        with Locations((x - t / 2, 0, TOP + hz / 2)):
            Box(t, 50, hz)
        with Locations(((x + 54) / 2, 0, TOP + 1.5)):
            Box(x - 54, 50, 3)
        for s in (1, -1):
            with Locations(Pos(x - t / 2, s * P["eye_gap"] / 2, P["eye_z"]) * Rot(0, 90, 0)):
                Cylinder(8.6, 10, mode=Mode.SUBTRACT)
        holes(EYE_HOLES, P["m3"], z=TOP)
    return p.part


def caster_spacer(h):
    with BuildPart() as p:
        with Locations((P["caster_x"], 0, PLATE_Z - h / 2)):
            Box(26, P["caster_hole_spacing"] + 18, h)
        with Locations(*[(P["caster_x"], s * P["caster_hole_spacing"] / 2, PLATE_Z - h / 2) for s in (1, -1)]):
            Box(P["m3"], P["m3"] + 12, h + 2, mode=Mode.SUBTRACT)
    return p.part


def fit_test():
    """01 numaralı küçük test: kelepçenin tüneli, D delikli göbek, M3 kılavuzsuz vida deliği ve tek göz
    cebi (kartın bir gözü arkadan oturmalı). 20 dakikada basılır; tutmazsa P'yi düzelt, büyük işlere öyle geç."""
    pieces = []
    c = clamp(1)
    pieces.append(("test_kelepce", c))
    with BuildPart() as hub:
        Cylinder(6.5, 10)
        Cylinder(P["shaft_d"] / 2 + 0.1, 12, mode=Mode.SUBTRACT)
    h = hub.part
    flat = P["shaft_flat"] - P["shaft_d"] / 2 + 0.05
    h = h + (Pos(flat + 1.5, 0, 0) * Box(3, 5, 10) & Cylinder(P["shaft_d"] / 2 + 0.3, 10))
    h = h - Pos(5, 0, 0) * Rot(0, 90, 0) * Cylinder(P["m3_tap"] / 2, 10)
    pieces.append(("test_gobek", h))
    with BuildPart() as tap:
        Box(14, 30, 6)
        with Locations((0, -8, 0), (0, 8, 0)):
            Cylinder(P["m3_tap"] / 2, 8, mode=Mode.SUBTRACT)
    pieces.append(("test_m3", tap.part))
    with BuildPart() as eyet:  # tek göz: pencere Ø33.5 + arkasında Ø36.75 cep
        Box(44, 44, 6)
        Cylinder(V["window"] / 2, 8, mode=Mode.SUBTRACT)
        with Locations((0, 0, 1.5)):
            Cylinder(EYE["d"] / 2 + 0.25, 3.01, mode=Mode.SUBTRACT)
    pieces.append(("test_goz", eyet.part))
    return pieces



# =========================================================================== kontrol
# Her çalıştırmada dosya yazılmadan önce: vida delikleri ve yarıklar gerçekten açık mı, vida geçiş
# deliğinden serbest geçiyor mu, tutunacağı yerde kaç mm malzeme var. İki hata gözle yakalanmadan
# baskıya gitti: şasinin bütün kesimleri z = 0'da yapılmıştı ve plakayı ıskaladı; boynun üst
# delikleri geçiş ölçüsündeydi ve vida orada 0 mm diş buldu. Bu liste ikisini de yakalar.

M3_OUT = 1.5     # M3 vidanın dış yarıçapı: diş bu yarıçapta malzemeye girer
M3_FREE = 1.55   # geçiş deliğinde vidanın sürtmeden geçtiği yarıçap


def _ring(x, y, z, r, n=8):
    return [(x + r * math.cos(2 * math.pi * i / n), y + r * math.sin(2 * math.pi * i / n), z) for i in range(n)]


def _steps(z0, z1, step=0.5):
    z = min(z0, z1) + step / 2
    while z < max(z0, z1):
        yield z
        z += step


def _inside(part, pt):
    return part.is_inside(Vector(*pt))


def hole(part, label, pt):
    """Nokta boşlukta olmalı: delik, yarık, kablo deliği."""
    return label, not _inside(part, pt), "kapalı" if _inside(part, pt) else "açık"


def solid(part, label, pt):
    """Nokta malzemede olmalı: plaka, duvar."""
    return label, _inside(part, pt), "dolu" if _inside(part, pt) else "boş"


def free(part, label, x, y, z0, z1):
    """Geçiş deliği: vida z0..z1 boyunca hiçbir yere sürtmeden geçer."""
    hit = [z for z in _steps(z0, z1) if any(_inside(part, p) for p in _ring(x, y, z, M3_FREE))]
    return label, not hit, "serbest" if not hit else f"{len(hit) * 0.5:.1f} mm sürtüyor"


def thread(part, label, x, y, z0, z1, need):
    """Kılavuz deliği: vidanın dış yarıçapında en az `need` mm malzeme (diş) olmalı, ekseni boş olmalı."""
    mm = 0.5 * sum(all(_inside(part, p) for p in _ring(x, y, z, M3_OUT)) for z in _steps(z0, z1))
    axis = any(_inside(part, (x, y, z)) for z in _steps(z0, z1))
    ok = mm >= need and not axis
    return label, ok, f"{mm:.1f} mm diş" + (" · delik kapalı" if axis else "") + f" (en az {need})"


def verify(parts):
    """Bütün kontrolleri çalıştır; [(parça, açıklama, tamam mı, ölçülen)] döner."""
    p = parts
    mid = PLATE_Z + P["plate_t"] / 2
    out = []

    def add(name, *results):
        out.extend((name, *r) for r in results)

    # şasi: dört delik grubu geçiş, yarıklar açık, plaka dolu
    for group, pts in (("kelepçe", CLAMP_HOLES), ("üst kat", DECK_LEGS), ("kabuk", SHELL_HOLES), ("far", EYE_HOLES)):
        for x, y in pts:
            add("sasi", free(p["sasi"], f"{group} deliği ({x:g}, {y:g})", x, y, PLATE_Z + 0.2, PLATE_Z + P["plate_t"] - 0.2))
    for s in (1, -1):
        y = s * P["caster_hole_spacing"] / 2
        add("sasi", hole(p["sasi"], f"sarhoş teker yarığı y {y:g}", (P["caster_x"], y, mid)),
            hole(p["sasi"], f"sarhoş teker yarığı ucu y {y + 6:g}", (P["caster_x"], y + 6, mid)))
    for x in (-56, -30):
        for s in (1, -1):
            add("sasi", hole(p["sasi"], f"cırt yarığı ({x}, {s * 41.5:g})", (x, s * 41.5, mid)))
    add("sasi", hole(p["sasi"], "motor kablo yarığı", (-8, 0, mid)))
    for pt in ((0, 0), (30, 0), (-40, 0), (-62, 0)):
        add("sasi", solid(p["sasi"], f"plaka ({pt[0]}, {pt[1]})", (*pt, mid)))

    # kelepçeler: ayaklarda diş, tünel ve mil deliği açık
    for side, name in ((1, "kelepce-sol"), (-1, "kelepce-sag")):
        for x, y in (pt for pt in CLAMP_HOLES if pt[1] * side > 0):
            add(name, thread(p[name], f"ayak deliği ({x:g}, {y:g})", x, y, TOP, TOP + 3, 2.5))
        add(name, hole(p[name], "motor tüneli", (0, side * (YB - 8), R)),
            hole(p[name], "mil çıkışı", (0, side * (YB - 0.75), R)))

    # üst kat: bacaklarda M3 × 12 için diş, kablo yarıkları açık
    for x, y in DECK_LEGS:
        add("ust-kat", thread(p["ust-kat"], f"bacak ({x:g}, {y:g})", x, y, TOP, TOP + 9, 8))
    dz = P["deck_z"] + 1.5
    add("ust-kat", hole(p["ust-kat"], "kablo yarığı", (-8, 0, dz)),
        hole(p["ust-kat"], "yan yarık sol", (-45, 40, dz)), hole(p["ust-kat"], "yan yarık sağ", (-45, -40, dz)))

    # kabuk: kulakçıklarda diş, tepedeki ve öndeki delikler açık
    for x, y in SHELL_HOLES:
        add("kabuk", thread(p["kabuk"], f"kulakçık ({x:g}, {y:g})", x, y, TOP, TOP + 5, 4))
    for x, y in NECK_SCREWS:
        add("kabuk", free(p["kabuk"], f"boyun vidası ({x:g}, {y:g})", x, y, SHELL_TOP - P["shell_wall"] + 0.2, SHELL_TOP - 0.2))
    front, back = PLATE_CX + (PLATE_LEN - 1) / 2, PLATE_CX - (PLATE_LEN - 1) / 2
    add("kabuk", hole(p["kabuk"], "kafa kablo deliği", (*NECK_CABLE, SHELL_TOP - 1)),
        hole(p["kabuk"], "anahtar penceresi", (-52, 34, SHELL_TOP - 1)),
        hole(p["kabuk"], "sol far deliği", (front - 1, P["eye_gap"] / 2, P["eye_z"])),
        hole(p["kabuk"], "sağ far deliği", (front - 1, -P["eye_gap"] / 2, P["eye_z"])),
        hole(p["kabuk"], "omurilik USB penceresi", (back + 1, 0, P["deck_z"] + 20)))

    # boyun: iki uçta da vida diş bulur, kablo boşluğu açık
    for x, y in NECK_SCREWS:
        add("boyun", thread(p["boyun"], f"alt vida ({x:g}, {y:g}) M3 × 8", x, y, SHELL_TOP, SHELL_TOP + 5.8, 5))
    for x, y in NECK_TOP_SCREWS:
        add("boyun", thread(p["boyun"], f"üst vida ({x:g}, {y:g}) M3 × 12", x, y, VZ0 - 5.6, VZ0, 5))
    add("boyun", hole(p["boyun"], "kablo boşluğu", (HX - 1, 0, (SHELL_TOP + VZ0) / 2)))

    # vizör: pedlerden vida serbest geçer, servo milleri ve kablo deliği açık
    for x, y in NECK_TOP_SCREWS:
        add("vizor", free(p["vizor"], f"ped ({x:g}, {y:g})", x, y, VZ0 + 0.2, VZ0 + V["wall"] + 3.8))
    top_in = VZ0 + V["h"] - V["wall"]
    for s in (1, -1):
        add("vizor", hole(p["vizor"], f"servo mili y {s * EAR_SHAFT_Y:g}", (EAR_SX, s * EAR_SHAFT_Y, top_in + 1)))
    add("vizor", hole(p["vizor"], "kablo deliği", (NECK_CABLE[0], 0, VZ0 + 1)))
    for s in (1, -1):
        add("vizor-yuz", hole(p["vizor-yuz"], f"göz penceresi y {s * EYE['gap'] / 2:g}", (XF - 4, s * EYE["gap"] / 2, VZC)))

    # far tutucu: ayak geçiş delikleri, dönüştürücü delikleri
    x = FRONT - 0.5 - P["shell_wall"] - 2.5
    for hx, hy in EYE_HOLES:
        add("far-tutucu", free(p["far-tutucu"], f"ayak deliği ({hx:g}, {hy:g})", hx, hy, TOP + 0.2, TOP + 2.8))
    for s in (1, -1):
        add("far-tutucu", hole(p["far-tutucu"], f"göz deliği y {s * P['eye_gap'] / 2:g}", (x - 1.5, s * P["eye_gap"] / 2, P["eye_z"])))

    # jantlar: mil deliği açık, göbek dolu; kulaklar: kol vidası deliği; sarhoş ara parçası: yarıklar
    for side, name in ((1, "jant-sol"), (-1, "jant-sag")):
        add(name, hole(p[name], "mil deliği", (0, side * WHEEL_Y, R)), solid(p[name], "göbek", (0, side * WHEEL_Y, R + 4.5)))
    for side, name in ((1, "kulak-sol"), (-1, "kulak-sag")):
        add(name, hole(p[name], "kol vidası", (EAR_SX, side * EAR_SHAFT_Y, VZ0 + V["h"] + 5.5)))
    for s in (1, -1):
        add("sarhos-ara-2mm", hole(p["sarhos-ara-2mm"], f"yarık y {s * 15}", (P["caster_x"], s * 15, PLATE_Z - 1)))
    return out


def report(results):
    bad = [r for r in results if not r[2]]
    for name, label, ok, got in results:
        if not ok:
            print(f"  HATA  {name:15s} {label}: {got}")
    print(f"kontrol: {len(results) - len(bad)}/{len(results)} tamam")
    return not bad

# =========================================================================== çıktı
def to_bed(part, rot=(0, 0, 0)):
    s = Rot(*rot) * part
    bb = s.bounding_box()
    return Pos(-(bb.min.X + bb.max.X) / 2, -(bb.min.Y + bb.max.Y) / 2, -bb.min.Z) * s


def mesh_of(part, tol=0.03):
    tmp = OUT / "_tmp.stl"
    export_stl(part, str(tmp), tolerance=tol, angular_tolerance=0.15)
    m = trimesh.load(tmp)
    tmp.unlink()
    return m


BED = (256.0, 256.0)
SHARED_X = (20.5 + 1.5, 235.5 - 1.5)  # iki nozulun ortak alanı, kenardan 1.5 mm pay
MARGIN, GAP = 8.0, 6.0


def pack(meshes):
    """Raf yerleşimi: büyükten küçüğe, yalnız ortak x aralığında. Sığmazsa hata."""
    items = sorted(meshes, key=lambda nm: -np.prod(nm[1].extents[:2]))
    x, y, row, out = SHARED_X[0], MARGIN, 0.0, []
    for name, m in items:
        sx, sy = m.extents[:2]
        if x + sx > SHARED_X[1] and x > SHARED_X[0]:
            x, y, row = SHARED_X[0], y + row + GAP, 0.0
        if x + sx > SHARED_X[1] or y + sy > BED[1] - MARGIN:
            raise ValueError(f"{name} plakaya sığmıyor: {np.round(m.extents, 1)}")
        mm = m.copy()
        mm.apply_translation([x - m.bounds[0][0], y - m.bounds[0][1], -m.bounds[0][2]])
        out.append((name, mm))
        x += sx + GAP
        row = max(row, sy)
    return out


def write_3mf(path, objects):
    """Tek renkli iş: düz 3MF, Bambu Studio'da File > Import ile açılır."""
    res, build = [], []
    for i, (name, m) in enumerate(objects, start=1):
        v = "".join(f'<vertex x="{a:.4f}" y="{b:.4f}" z="{c:.4f}"/>' for a, b, c in m.vertices)
        t = "".join(f'<triangle v1="{a}" v2="{b}" v3="{c}"/>' for a, b, c in m.faces)
        res.append(f'<object id="{i}" name={quoteattr(name)} type="model"><mesh><vertices>{v}</vertices><triangles>{t}</triangles></mesh></object>')
        build.append(f'<item objectid="{i}"/>')
    model = (
        '<?xml version="1.0" encoding="UTF-8"?><model unit="millimeter" xml:lang="en-US" '
        'xmlns="http://schemas.microsoft.com/3dmanufacturing/core/2015/02">'
        f"<resources>{''.join(res)}</resources><build>{''.join(build)}</build></model>"
    )
    with zipfile.ZipFile(path, "w", zipfile.ZIP_DEFLATED) as z:
        z.writestr("[Content_Types].xml", '<?xml version="1.0" encoding="UTF-8"?><Types xmlns="http://schemas.openxmlformats.org/package/2006/content-types"><Default Extension="rels" ContentType="application/vnd.openxmlformats-package.relationships+xml"/><Default Extension="model" ContentType="application/vnd.ms-package.3dmanufacturing-3dmodel+xml"/></Types>')
        z.writestr("_rels/.rels", '<?xml version="1.0" encoding="UTF-8"?><Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships"><Relationship Target="/3D/3dmodel.model" Id="rel0" Type="http://schemas.microsoft.com/3dmanufacturing/2013/01/3dmodel"/></Relationships>')
        z.writestr("3D/3dmodel.model", model)


def main():
    # name: (part, print rotation)
    parts = {
        "sasi": (plate(), (0, 0, 0)),
        "kelepce-sol": (clamp(1), (0, 0, 0)),
        "kelepce-sag": (clamp(-1), (0, 0, 0)),
        "jant-sol": (rim(1), (-90, 0, 0)),
        "jant-sag": (rim(-1), (90, 0, 0)),
        "lastik-sol": (tire(1), (90, 0, 0)),
        "lastik-sag": (tire(-1), (-90, 0, 0)),
        "ust-kat": (deck(), (180, 0, 0)),
        "kabuk": (shell(), (180, 0, 0)),
        "boyun": (neck(), (0, 0, 0)),
        "vizor": (visor_shell(), (0, 90, 0)),
        "vizor-yuz": (visor_face(), (0, -90, 0)),
        "vizor-arka": (visor_back(), (0, 90, 0)),
        "kulak-sol": (ear(1), (0, 0, 0)),
        "kulak-sag": (ear(-1), (0, 0, 0)),
        "far-tutucu": (eye_mount(), (0, 0, 0)),
        "sarhos-ara-2mm": (caster_spacer(2), (0, 0, 0)),
    }

    # önce kontrol: bir delik kapalıysa ya da vida tutunmuyorsa hiçbir dosya yazılmaz, eskiler silinmez
    if not report(verify({k: v[0] for k, v in parts.items()})):
        sys.exit("kontrol geçmedi: dosyalar yazılmadı")
    if "--check" in sys.argv:
        return

    (OUT / "parts").mkdir(parents=True, exist_ok=True)
    (OUT / "print").mkdir(parents=True, exist_ok=True)
    for f in list((OUT / "print").glob("*.3mf")) + list((OUT / "parts").glob("*.stl")):
        f.unlink()

    info = {}
    for name, (part, _) in parts.items():
        export_stl(part, str(OUT / "parts" / f"{name}.stl"), tolerance=0.1, angular_tolerance=0.3)
        bb = part.bounding_box()
        info[name] = dict(size=[round(bb.size.X, 1), round(bb.size.Y, 1), round(bb.size.Z, 1)], cm3=round(part.volume / 1000, 1))
        print(f"{name:16s} {bb.size.X:6.1f} × {bb.size.Y:6.1f} × {bb.size.Z:6.1f} mm  {part.volume / 1000:5.1f} cm³")

    # numaralı işler: (dosya, renk, parçalar). Yalnız PLA; iş başına tek renk, renk değişimi yok.
    jobs = [
        ("test", "siyah", [(n, p) for n, p in fit_test()]),
        ("sasi", "beyaz", ["sasi"]),
        ("ust_kat", "beyaz", ["ust-kat"]),
        ("jantlar", "beyaz", ["jant-sol", "jant-sag"]),
        ("kelepceler_ve_kucukler", "siyah", ["kelepce-sol", "kelepce-sag", "far-tutucu", "sarhos-ara-2mm"]),
        ("lastikler", "siyah", ["lastik-sol", "lastik-sag"]),
        ("boyun_ve_yuz", "siyah", ["boyun", "vizor-yuz"]),
        ("vizor", "beyaz", ["vizor", "vizor-arka"]),
        ("kabuk", "turuncu", ["kabuk"]),
        ("kulaklar", "turuncu", ["kulak-sol", "kulak-sag"]),
    ]
    contents = ["| Dosya | Renk (PLA) | İçindekiler |", "| --- | --- | --- |"]
    job_info = []
    for k, (stem, color, members) in enumerate(jobs, start=1):
        meshes = []
        for m in members:
            if isinstance(m, tuple):
                meshes.append((m[0], mesh_of(to_bed(m[1]))))
            else:
                part, rot = parts[m]
                meshes.append((m, mesh_of(to_bed(part, rot))))
        placed = pack(meshes)
        fname = f"{k:02d}_{stem}.3mf"
        write_3mf(OUT / "print" / fname, placed)
        names = [n for n, _ in placed]
        contents.append(f"| `{fname}` | {color} | {', '.join(names)} |")
        job_info.append(dict(file=fname, color=color, parts=names))
        print(f"{fname:34s} {color:8s} {', '.join(names)}")
    (OUT / "print" / "contents.md").write_text("\n".join(contents) + "\n")

    geo = dict(R=R, plateZ=PLATE_Z, top=TOP, yb=YB, wheelY=WHEEL_Y, front=FRONT, shellTop=SHELL_TOP, plateCx=PLATE_CX,
               visor=dict(hx=HX, z0=VZ0, **V), eye=EYE, **P)
    (OUT / "r1.json").write_text(json.dumps(dict(parts=info, jobs=job_info, geo=geo), indent=1))
    # sitedeki indirilebilir kopya hep bu dosyanın kendisi olsun: elle kopyalanınca geride kalıyordu
    (OUT / "r1.py").write_text(Path(__file__).read_text())
    print(f"aks {R:.1f} · plaka altı {PLATE_Z:.1f} · iz {2 * WHEEL_Y:.1f} · boy {PLATE_LEN:.0f} · kabuk üstü {SHELL_TOP:.0f}")


if __name__ == "__main__":
    main()
