# 유압 굴착기 (20톤급 크롤러 굴착기) — Blender Python 코드만으로 만든 실물 크기 모델
# 앞쪽이 +X, 운전석(캡)이 왼쪽(+Y)에 있다. 전체 길이 약 9.5m, 궤도 폭 2.8m, 캡 높이 약 3m.
# - 하부: 궤도판(슈) 한 장을 bmesh 로 만들고, 아이들러·스프로킷을 잇는 경기장 모양 경로를 따라
#   52장씩 링크 복제해 무한궤도를 감았다. 하부 롤러·상부 롤러·스프로킷 톱니도 배열로 놓았다.
# - 상부: 바닥 평면도(뒤쪽은 선회 반경 원호)를 돌출해 카운터웨이트를, 측면 단면을 돌출해 캡·엔진후드를 만들었다.
# - 작업장치: 붐·암은 중심선(베지어)을 따라 두께가 변하는 띠 단면을 만들어 Y 방향으로 돌출했고,
#   핀 위치를 먼저 정한 뒤 그 사이에 유압 실린더(바렐+크롬 로드)를 계산해 끼웠다.
# - 버킷은 바깥 곡선을 두께만큼 안쪽으로 오프셋한 얇은 껍데기와 옆판, 이빨 5개로 구성했다.
import math
from atelier import *

SHOT = {"azimuth": 52, "elevation": 12, "lens": 50, "light": "studio", "background": "#27303b"}

# ---------------------------------------------------------------- 재질
PAINT = mat('Paint_ExcavatorYellow', '#eaa91a', rough=0.38, metal=0.0, coat=0.35)
UNDER = mat('Undercarriage_Black', '#2b2c2e', rough=0.6, metal=0.25)
SHOE = mat('TrackShoe_WornSteel', '#45403a', rough=0.75, metal=0.55)
DARK = mat('Trim_Charcoal', '#202225', rough=0.55, metal=0.1)
GLASS = mat('Cab_TintedGlass', '#1e2b33', rough=0.04, metal=0.2)
CHROME = mat('Piston_Chrome', '#d8dcdf', rough=0.12, metal=1.0)
RUBBER = mat('Hose_Rubber', '#151617', rough=0.85)
TEETH = mat('Bucket_TeethSteel', '#77716a', rough=0.5, metal=0.7)
LAMP = mat('Lamp_Lens', '#f3ecd6', rough=0.1, emit='#fff4dc', emit_strength=1.5)
BEACON = mat('Beacon_Amber', '#e0751c', rough=0.3, emit='#ff8a22', emit_strength=1.2)
STEP = mat('Step_Grating', '#5a5c5f', rough=0.6, metal=0.6)


# ---------------------------------------------------------------- 도우미
def mesh_boxes(name, boxes, material=None):
    """[(center, size), ...] 상자 여러 개를 메시 하나로."""
    bm = bmesh.new()
    for c, s in boxes:
        r = bmesh.ops.create_cube(bm, size=1.0)
        bmesh.ops.scale(bm, vec=Vector(s), verts=r['verts'])
        bmesh.ops.translate(bm, vec=Vector(c), verts=r['verts'])
    me = bpy.data.meshes.new(name)
    bm.to_mesh(me)
    bm.free()
    ob = bpy.data.objects.new(name, me)
    link(ob)
    return assign(ob, material)


def y_cyl(name, r, w, x, y, z, material, seg=24):
    """Y 축 방향 원통(핀, 바퀴, 보스). (x,y,z) 가 중심."""
    return cylinder(name, r=r, h=w, loc=(x, y - w / 2, z), rot=(-math.pi / 2, 0, 0), material=material, seg=seg)


def bezier(p0, p1, p2, n):
    out = []
    for i in range(n + 1):
        t = i / n
        a, b, c = (1 - t) ** 2, 2 * (1 - t) * t, t * t
        out.append((a * p0[0] + b * p1[0] + c * p2[0], a * p0[1] + b * p1[1] + c * p2[1]))
    return out


def band(center, half_top, half_bot):
    """XZ 중심선을 따라 위/아래 두께가 변하는 닫힌 다각형."""
    n = len(center)
    tops, bots = [], []
    for i, (x, z) in enumerate(center):
        a = center[max(i - 1, 0)]
        b = center[min(i + 1, n - 1)]
        dx, dz = b[0] - a[0], b[1] - a[1]
        L = math.hypot(dx, dz)
        nx, nz = -dz / L, dx / L
        t = i / (n - 1)
        ht, hb = half_top(t), half_bot(t)
        tops.append((x + nx * ht, z + nz * ht))
        bots.append((x - nx * hb, z - nz * hb))
    return bots + list(reversed(tops))


def lerp3(t, a, b, c):
    return a + (b - a) * (t / 0.5) if t < 0.5 else b + (c - b) * ((t - 0.5) / 0.5)


def hcyl(name, p0, p1, y, rb=0.1, rr=0.06, split=0.55):
    """XZ 평면의 두 핀 p0→p1 사이 유압 실린더(바렐 + 크롬 로드 + 양끝 눈)."""
    dx, dz = p1[0] - p0[0], p1[1] - p0[1]
    L = math.hypot(dx, dz)
    th = math.atan2(dx, dz)
    ux, uz = dx / L, dz / L
    lb = L * split
    parts = []
    parts.append(cylinder(name + '_Barrel', r=rb, h=lb - rb * 0.6, loc=(p0[0] + ux * rb * 0.6, y, p0[1] + uz * rb * 0.6),
                          rot=(0, th, 0), material=PAINT, seg=24))
    parts.append(cylinder(name + '_Gland', r=rb * 1.12, h=0.08, loc=(p0[0] + ux * (lb - 0.1), y, p0[1] + uz * (lb - 0.1)),
                          rot=(0, th, 0), material=PAINT, seg=24))
    parts.append(cylinder(name + '_Rod', r=rr, h=L - lb - rr * 0.5, loc=(p0[0] + ux * (lb - 0.05), y, p0[1] + uz * (lb - 0.05)),
                          rot=(0, th, 0), material=CHROME, seg=20))
    for k, p in enumerate((p0, p1)):
        parts.append(y_cyl(f'{name}_Eye{k}', rb * 0.95 if k == 0 else rr * 1.6, 0.16, p[0], y, p[1], DARK, seg=20))
    for o in parts[:2]:
        smooth(o)
    return parts


def slab(name, p1, p2, t, width, y, material, inset=0.0):
    """XZ 선분 p1→p2 바깥쪽(오른손 법선)에 붙는 얇은 판(경사진 앞유리 등)."""
    dx, dz = p2[0] - p1[0], p2[1] - p1[1]
    L = math.hypot(dx, dz)
    nx, nz = dz / L, -dx / L
    ux, uz = dx / L, dz / L
    a = (p1[0] + ux * inset, p1[1] + uz * inset)
    b = (p2[0] - ux * inset, p2[1] - uz * inset)
    pts = [a, b, (b[0] + nx * t, b[1] + nz * t), (a[0] + nx * t, a[1] + nz * t)]
    return extrude_profile(name, pts, width, loc=(0, y, 0), material=material)


# ================================================================ 하부 주행체
TRACK_Y = 1.1          # 궤도 중심 간격의 절반 (게이지 2.2m)
SHOE_W = 0.6
C = 1.83               # 스프로킷·아이들러 중심의 x
ZC = 0.45              # 휠 중심 높이
RP = 0.42              # 궤도판 중심선 반경
N_SHOES = 52

# 궤도판 한 장: 바닥판 + 그라우저(돌기) + 안쪽 링크 2줄 + 부싱
shoe_proto = mesh_boxes('TrackShoe', [
    ((0, 0, 0), (0.172, SHOE_W, 0.03)),
    ((0.03, 0, 0.03), (0.032, SHOE_W, 0.035)),
    ((0, -0.15, -0.055), (0.19, 0.055, 0.085)),
    ((0, 0.15, -0.055), (0.19, 0.055, 0.085)),
    ((-0.09, 0, -0.06), (0.05, 0.30, 0.05)),
], SHOE)
bevel(shoe_proto, 0.008, 2, angle=60)
apply_all(shoe_proto)
# 로컬 +Z = 궤도 바깥 방향(그라우저 쪽), -Z = 안쪽(링크 쪽).

def track_path(s):
    """경기장 모양 궤도 경로: s∈[0,L) → (x, z, 바깥 법선 nx, nz)."""
    straight = 2 * C
    arc = math.pi * RP
    if s < straight:                       # 윗 구간: 앞→뒤
        return C - s, ZC + RP, 0.0, 1.0
    s -= straight
    if s < arc:                            # 뒤쪽 스프로킷 반원
        a = math.pi / 2 + s / RP
        return -C + RP * math.cos(a), ZC + RP * math.sin(a), math.cos(a), math.sin(a)
    s -= arc
    if s < straight:                       # 아래 구간: 뒤→앞 (접지)
        return -C + s, ZC - RP, 0.0, -1.0
    s -= straight
    a = -math.pi / 2 + s / RP              # 앞쪽 아이들러 반원
    return C + RP * math.cos(a), ZC + RP * math.sin(a), math.cos(a), math.sin(a)


L_PATH = 4 * C + 2 * math.pi * RP
shoes = []
for side in (-1, 1):
    ty = side * TRACK_Y
    for i in range(N_SHOES):
        x, z, nx, nz = track_path((i + 0.5) * L_PATH / N_SHOES)
        ob = shoe_proto if not shoes else duplicate(shoe_proto)
        ob.name = f'TrackShoe_{"L" if side > 0 else "R"}{i:02d}'
        ob.location = (x, ty, z)
        ob.rotation_euler = (0, math.atan2(nx, nz), 0)
        shoes.append(ob)

def gear_profile(r_tip, r_root, teeth):
    pts = []
    for i in range(teeth * 4):
        a = 2 * math.pi * i / (teeth * 4)
        r = r_tip if i % 4 in (1, 2) else r_root
        pts.append((r * math.cos(a), r * math.sin(a)))
    return pts

for side in (-1, 1):
    tag = 'L' if side > 0 else 'R'
    ty = side * TRACK_Y
    # 트랙 프레임(경사진 윗면)
    frame = extrude_profile(f'TrackFrame_{tag}', [(-1.55, 0.27), (1.55, 0.27), (1.66, 0.42), (1.5, 0.6),
                                                  (0.9, 0.64), (-0.9, 0.64), (-1.5, 0.6), (-1.66, 0.42)],
                            0.44, loc=(0, ty, 0), material=UNDER)
    bevel(frame, 0.02, 1)
    # 스프로킷(뒤) — 톱니 + 주행 모터 허브
    extrude_profile(f'Sprocket_{tag}', gear_profile(0.395, 0.33, 11), 0.09, loc=(-C, ty, ZC), material=UNDER)
    y_cyl(f'SprocketHub_{tag}', 0.27, 0.16, -C, ty, ZC, UNDER, seg=32)
    y_cyl(f'TravelMotor_{tag}', 0.3, 0.3, -C, ty - side * 0.27, ZC, UNDER, seg=32)
    y_cyl(f'TravelMotorCap_{tag}', 0.2, 0.08, -C, ty - side * 0.45, ZC, DARK, seg=24)
    # 아이들러(앞)
    y_cyl(f'Idler_{tag}', 0.295, 0.36, C, ty, ZC, UNDER, seg=40)
    y_cyl(f'IdlerFlange_{tag}', 0.37, 0.09, C, ty, ZC, UNDER, seg=40)
    y_cyl(f'IdlerHub_{tag}', 0.12, 0.48, C, ty, ZC, DARK, seg=20)
    # 하부 롤러 7개 (링크 위를 구른다)
    for k in range(7):
        x = -1.32 + k * 2.64 / 6
        y_cyl(f'TrackRoller_{tag}{k}', 0.085, 0.42, x, ty, 0.23, UNDER, seg=20)
        y_cyl(f'TrackRollerFlange_{tag}{k}', 0.105, 0.05, x, ty - 0.15, 0.23, UNDER, seg=20)
        y_cyl(f'TrackRollerFlange_{tag}{k}b', 0.105, 0.05, x, ty + 0.15, 0.23, UNDER, seg=20)
    # 상부 캐리어 롤러 2개
    for k, x in enumerate((-0.65, 0.65)):
        y_cyl(f'CarrierRoller_{tag}{k}', 0.075, 0.36, x, ty, 0.69, UNDER, seg=20)
        box(f'CarrierBracket_{tag}{k}', (0.14, 0.12, 0.1), (x, ty - side * 0.12, 0.62), material=UNDER, base=True)

# 센터 프레임과 선회 베어링
cf = box('CenterFrame', (1.6, 2.0, 0.42), (0, 0, 0.36), material=UNDER, base=True)
bevel(cf, 0.05, 2)
for s in (-1, 1):
    extrude_profile(f'CenterFrameLeg_{s}', [(-0.75, 0.35), (0.75, 0.35), (1.25, 0.5), (1.25, 0.6), (-1.25, 0.6), (-1.25, 0.5)],
                         0.3, loc=(0, s * 0.75, 0), material=UNDER)
lathe('SwingBearing', [(0, 0.76), (0.98, 0.76), (0.98, 0.9), (1.02, 0.9), (1.02, 1.0), (0.95, 1.0), (0.95, 1.06), (0, 1.06)],
      material=DARK, seg=64, shade_smooth=False)

# ================================================================ 상부 선회체
TAIL_R = 2.72
HALF_W = 1.25
def rear_arc(r, half, n=18, x_cut=None):
    """선회 중심 기준 반경 r 원호(뒤쪽) 위의 점들, y: -half → +half."""
    a0 = math.asin(half / r)
    pts = []
    for i in range(n + 1):
        a = math.pi - a0 + (2 * a0) * i / n
        pts.append((r * math.cos(a), -r * math.sin(a)))
    return pts

arc = rear_arc(TAIL_R, HALF_W)          # y: -half → +half
deck = prism('UpperFrame', [(1.45, -HALF_W), (1.45, HALF_W)] + list(reversed(arc)), 0.21, loc=(0, 0, 1.04), material=DARK)
# 카운터웨이트: 원호 뒷부분
cw_arc = rear_arc(TAIL_R + 0.01, HALF_W + 0.005)
cwt = prism('Counterweight', [(-1.55, -HALF_W - 0.005), (-1.55, HALF_W + 0.005)] + list(reversed(cw_arc)),
            0.95, loc=(0, 0, 1.22), material=PAINT)
bevel(cwt, 0.07, 3, angle=40)

# 엔진 후드
hood = extrude_profile('EngineHood', [(-1.7, 1.24), (-0.45, 1.24), (-0.45, 2.08), (-0.62, 2.3), (-1.7, 2.3)],
                       2.42, loc=(0, 0, 0), material=PAINT)
bevel(hood, 0.04, 2)
# 후드 위 그릴(가로 슬랫)
for k in range(9):
    box(f'HoodGrille_{k}', (0.06, 1.1, 0.02), (-1.55 + k * 0.1, -0.45, 2.3), material=DARK, base=True)
# 배기관과 에어클리너
cylinder('ExhaustStack', r=0.065, h=0.55, loc=(-1.25, -0.9, 2.28), material=DARK, seg=20)
cylinder('ExhaustCap', r=0.075, h=0.05, loc=(-1.25, -0.9, 2.83), material=DARK, seg=20)
cylinder('AirPrecleaner', r=0.1, h=0.18, loc=(-0.95, -1.05, 2.29), material=DARK, seg=20)

# 오른쪽 수납함·연료/작동유 탱크 덮개
tank = extrude_profile('RightTankHousing', [(-0.48, 1.24), (1.45, 1.24), (1.45, 1.86), (1.2, 2.1), (-0.48, 2.1)],
                       0.84, loc=(0, -0.83, 0), material=PAINT)
bevel(tank, 0.03, 2)
# 측면 패널 줄눈(살짝 튀어나온 어두운 띠)
for k, x in enumerate((0.15, 0.85)):
    box(f'TankPanelSeam_{k}', (0.018, 0.01, 0.78), (x, -HALF_W - 0.004, 1.3), material=DARK, base=True)
for k, x in enumerate((-1.2, -0.75)):
    box(f'HoodPanelSeam_{k}', (0.018, 0.01, 0.95), (x, -1.214, 1.3), material=DARK, base=True)
    box(f'HoodPanelSeamL_{k}', (0.018, 0.01, 0.95), (x, 1.214, 1.3), material=DARK, base=True)
# 측면 공기 흡입 루버
for k in range(6):
    box(f'SideLouver_{k}', (0.5, 0.03, 0.025), (-1.1, -1.216, 1.6 + k * 0.08), material=DARK)

# 왼쪽 후드 루버, 카운터웨이트 홈줄과 후미등
for k in range(6):
    box(f'SideLouverL_{k}', (0.5, 0.03, 0.025), (-1.1, 1.216, 1.6 + k * 0.08), material=DARK)
groove = [(x * 1.004, y * 1.004) for x, y in rear_arc(TAIL_R + 0.012, HALF_W + 0.006)]
curve_tube('Counterweight_Groove', [(x, y, 1.62) for x, y in groove], radius=0.018, material=DARK)
for s_ in (-1, 1):
    a = math.asin(0.85 / TAIL_R) * s_
    x, y = -TAIL_R * math.cos(a) - 0.01, TAIL_R * math.sin(a)
    box(f'TailLamp_{s_}', (0.04, 0.22, 0.09), (x, y, 1.85), rot=(0, 0, -a), material=mat('TailLamp_Red', '#a3201a', rough=0.2, emit='#ff3020', emit_strength=0.6))
box('RearCamera', (0.08, 0.12, 0.08), (-TAIL_R - 0.03, 0, 2.17), material=DARK, base=True)

# 계단과 난간(오른쪽 위)
box('Step_Lower', (0.45, 0.28, 0.04), (1.0, -1.38, 0.98), material=STEP)
box('Step_Upper', (0.45, 0.2, 0.04), (1.0, -1.33, 1.24), material=STEP)
for x in (0.8, 1.2):
    box(f'StepHanger_{x}', (0.04, 0.04, 0.32), (x, -1.38, 0.96), material=DARK, base=True)
rail_pts = [(1.25, -1.17, 2.1), (1.25, -1.17, 2.55), (-0.4, -1.17, 2.55), (-0.4, -1.17, 2.1)]
curve_tube('Handrail_Right', rail_pts, radius=0.022, material=PAINT)
curve_tube('Handrail_RightMid', [(0.4, -1.17, 2.1), (0.4, -1.17, 2.55)], radius=0.022, material=PAINT)
curve_tube('Handrail_Hood', [(-0.7, -1.18, 2.3), (-0.7, -1.18, 2.62), (-1.6, -1.18, 2.62), (-1.6, -1.18, 2.3)], radius=0.022, material=PAINT)

# ---------------------------------------------------------------- 캡(운전석)
CAB_Y0, CAB_Y1 = 0.3, 1.25
cab_w = CAB_Y1 - CAB_Y0
cab_yc = (CAB_Y0 + CAB_Y1) / 2
cab_prof = [(-0.6, 1.24), (1.36, 1.24), (1.39, 1.9), (1.27, 2.98), (1.18, 3.04), (-0.38, 3.04), (-0.55, 2.92), (-0.62, 2.7)]
cab = extrude_profile('Cab', cab_prof, cab_w, loc=(0, cab_yc, 0), material=PAINT)
bevel(cab, 0.03, 2)
roof = extrude_profile('CabRoof', [(-0.45, 3.02), (1.24, 3.02), (1.3, 3.06), (1.22, 3.1), (-0.4, 3.1)],
                       cab_w + 0.06, loc=(0, cab_yc, 0), material=DARK)
# 앞유리(위·아래) — 앞면에 붙인 얇은 판
slab('Cab_FrontGlassUpper', (1.39, 1.95), (1.27, 2.95), 0.012, cab_w - 0.12, cab_yc, GLASS, inset=0.03)
slab('Cab_FrontGlassLower', (1.36, 1.3), (1.39, 1.86), 0.012, cab_w - 0.12, cab_yc, GLASS, inset=0.03)
# 왼쪽 문·옆창
side_glass = [(-0.45, 1.4), (1.25, 1.4), (1.28, 1.9), (1.17, 2.9), (-0.32, 2.93), (-0.47, 2.8)]
extrude_profile('Cab_SideGlassLeft', side_glass, 0.012, loc=(0, CAB_Y1 + 0.006, 0), material=GLASS)
box('Cab_DoorPillar', (0.06, 0.02, 1.55), (0.25, CAB_Y1 + 0.008, 1.38), material=PAINT, base=True)
box('Cab_WindowRail', (1.7, 0.02, 0.05), (0.4, CAB_Y1 + 0.008, 1.92), material=PAINT)
box('Cab_DoorHandle', (0.12, 0.04, 0.03), (0.15, CAB_Y1 + 0.02, 1.85), material=DARK)
# 오른쪽(붐 쪽) 창과 뒷창
extrude_profile('Cab_SideGlassRight', [(0.1, 1.95), (1.25, 1.95), (1.17, 2.9), (0.1, 2.93)], 0.012,
                loc=(0, CAB_Y0 - 0.006, 0), material=GLASS)
slab('Cab_RearGlass', (-0.38, 3.0), (-0.6, 2.4), 0.012, cab_w - 0.18, cab_yc, GLASS, inset=0.06)
# 문 옆 손잡이와 왼쪽 발판
curve_tube('Cab_GrabHandle', [(-0.5, CAB_Y1, 1.7), (-0.55, CAB_Y1 + 0.07, 1.72), (-0.55, CAB_Y1 + 0.07, 2.5), (-0.5, CAB_Y1, 2.52)], radius=0.018, material=DARK)
box('Step_CabLeft', (0.5, 0.22, 0.04), (0.6, 1.36, 1.0), material=STEP)
for x in (0.4, 0.8):
    box(f'StepHangerL_{x}', (0.04, 0.04, 0.28), (x, 1.36, 0.98), material=DARK, base=True)
# 와이퍼, 작업등, 경광등, 미러
curve_tube('Cab_Wiper', [(1.405, 0.55, 2.0), (1.33, 0.75, 2.62)], radius=0.01, material=DARK)
for k, y in enumerate((0.45, 1.1)):
    box(f'Cab_WorkLampHousing_{k}', (0.1, 0.16, 0.1), (1.24, y, 3.1), material=DARK, base=True)
    box(f'Cab_WorkLampLens_{k}', (0.012, 0.13, 0.075), (1.296, y, 3.15), material=LAMP)
cylinder('BeaconBase', r=0.06, h=0.03, loc=(-0.2, 1.05, 3.1), material=DARK, seg=20)
cylinder('Beacon', r=0.05, h=0.1, loc=(-0.2, 1.05, 3.13), material=BEACON, seg=20, r2=0.04)
curve_tube('MirrorStay_Left', [(1.0, 1.25, 2.6), (1.15, 1.42, 2.65), (1.15, 1.42, 2.8)], radius=0.015, material=DARK)
box('Mirror_Left', (0.03, 0.18, 0.24), (1.15, 1.45, 2.8), material=DARK, base=True)
curve_tube('MirrorStay_Right', [(1.3, -1.1, 2.1), (1.3, -1.1, 2.45), (1.42, -1.18, 2.5)], radius=0.015, material=DARK)
box('Mirror_Right', (0.03, 0.2, 0.22), (1.42, -1.2, 2.45), material=DARK, base=True)

# ================================================================ 작업장치 (붐·암·버킷)
BY = -0.06                   # 붐 중심선의 y
F = (0.72, 1.72)             # 붐 풋 핀
K = (2.95, 4.25)             # 붐 꺾임 제어점
T = (5.65, 4.32)               # 붐 끝 핀(암 연결)
boom_c = bezier(F, K, T, 24)
boom_poly = band(boom_c, lambda t: lerp3(t, 0.26, 0.40, 0.22), lambda t: lerp3(t, 0.26, 0.46, 0.22))
boom = extrude_profile('Boom', boom_poly, 0.56, loc=(0, BY, 0), material=PAINT)
bevel(boom, 0.03, 2)
# 붐 풋 브래킷(상부 프레임에 고정)
for s in (-1, 1):
    extrude_profile(f'BoomFootBracket_{s}', [(0.25, 1.24), (1.3, 1.24), (1.3, 1.45), (0.95, 1.9), (0.5, 1.98), (0.3, 1.7)],
                    0.06, loc=(0, BY + s * 0.32, 0), material=PAINT)
y_cyl('BoomFootPin', 0.09, 0.78, F[0], BY, F[1], CHROME)
y_cyl('BoomFootBoss', 0.24, 0.6, F[0], BY, F[1], PAINT, seg=32)

# 붐 실린더 2개 (붐 양옆)
bc_base = (1.3, 1.42)
bc_top = (2.62, 3.28)
for s in (-1, 1):
    hcyl(f'BoomCylinder_{"L" if s > 0 else "R"}', bc_base, bc_top, BY + s * 0.4, rb=0.105, rr=0.065, split=0.6)
    box(f'BoomCylBaseLug_{s}', (0.22, 0.05, 0.3), (bc_base[0], BY + s * 0.29, 1.24), material=PAINT, base=True)
y_cyl('BoomCylBasePin', 0.05, 1.0, bc_base[0], BY, bc_base[1], CHROME)
y_cyl('BoomCylTopBoss', 0.13, 0.66, bc_top[0], BY, bc_top[1], PAINT)
y_cyl('BoomCylTopPin', 0.05, 1.0, bc_top[0], BY, bc_top[1], CHROME)

# 암
D_ANG = math.radians(-76)
dvec = (math.cos(D_ANG), math.sin(D_ANG))
nvec = (-dvec[1], dvec[0]) if -dvec[1] > 0 else (dvec[1], -dvec[0])   # 앞쪽(+x) 법선
ARM_L = 2.9
A = (T[0] + dvec[0] * ARM_L, T[1] + dvec[1] * ARM_L)                  # 버킷 핀
H = (T[0] - dvec[0] * 0.75 - nvec[0] * 0.25, T[1] - dvec[1] * 0.75 - nvec[1] * 0.25)  # 암 머리(암 실린더 연결)
arm_c = bezier(H, T, A, 20)
arm_poly = band(arm_c, lambda t: lerp3(t, 0.2, 0.34, 0.18), lambda t: lerp3(t, 0.2, 0.34, 0.18))
arm = extrude_profile('Arm', arm_poly, 0.42, loc=(0, BY, 0), material=PAINT)
bevel(arm, 0.025, 2)
y_cyl('BoomTipBoss', 0.2, 0.64, T[0], BY, T[1], PAINT, seg=32)
y_cyl('BoomTipPin', 0.075, 0.76, T[0], BY, T[1], CHROME)
y_cyl('ArmHeadBoss', 0.16, 0.46, H[0], BY, H[1], PAINT)

# 붐 작업등
box('BoomLampHousing', (0.14, 0.1, 0.12), (1.75, BY + 0.33, 2.85), material=DARK)
box('BoomLampLens', (0.012, 0.08, 0.09), (1.825, BY + 0.33, 2.85), material=LAMP)

# 암 실린더 (붐 위)
ac_base = (2.5, 4.38)
box('ArmCylBracket', (0.4, 0.3, 0.32), (ac_base[0], BY, ac_base[1] - 0.2), rot=(0, math.radians(-35), 0), material=PAINT)
hcyl('ArmCylinder', ac_base, H, BY, rb=0.11, rr=0.07, split=0.58)

# 버킷 실린더와 H-링크 (암 앞면)
bkc_base = (T[0] + nvec[0] * 0.42 - dvec[0] * 0.1, T[1] + nvec[1] * 0.42 - dvec[1] * 0.1)
box('BucketCylBracket', (0.3, 0.26, 0.3), (bkc_base[0] - nvec[0] * 0.15, BY, bkc_base[1] - nvec[1] * 0.15),
    rot=(0, -D_ANG - math.pi / 2, 0), material=PAINT)
CURL = math.radians(22)

BK_S = 0.9                   # 버킷 크기 배율

def bk(u, v):
    u, v = u * BK_S, v * BK_S
    u, v = u * math.cos(CURL) - v * math.sin(CURL), u * math.sin(CURL) + v * math.cos(CURL)
    return (A[0] + nvec[0] * u + dvec[0] * v, A[1] + nvec[1] * u + dvec[1] * v)

Lk = (A[0] + nvec[0] * 0.43 - dvec[0] * 0.42, A[1] + nvec[1] * 0.43 - dvec[1] * 0.42)
G = (A[0] - dvec[0] * 0.48, A[1] - dvec[1] * 0.48)                    # 암 쪽 링크 핀
Q = bk(0.42, 0.06)  # 버킷 쪽 링크 핀
hcyl('BucketCylinder', bkc_base, Lk, BY, rb=0.095, rr=0.06, split=0.55)

def bar(name, p0, p1, y, w=0.05, h=0.12, material=PAINT):
    dx, dz = p1[0] - p0[0], p1[1] - p0[1]
    L = math.hypot(dx, dz)
    return box(name, (L, w, h), ((p0[0] + p1[0]) / 2, y, (p0[1] + p1[1]) / 2), rot=(0, -math.atan2(dz, dx), 0), material=material)

for s in (-1, 1):
    bar(f'GuideLink_{s}', G, Lk, BY + s * 0.24, w=0.05, h=0.14)
    bar(f'BucketLink_{s}', Lk, Q, BY + s * 0.17, w=0.06, h=0.16)
y_cyl('LinkPin_G', 0.06, 0.6, G[0], BY, G[1], CHROME)
y_cyl('LinkPin_L', 0.06, 0.6, Lk[0], BY, Lk[1], CHROME)
y_cyl('LinkPin_Q', 0.06, 0.5, Q[0], BY, Q[1], CHROME)

# ---------------------------------------------------------------- 버킷
# 버킷 로컬(핀 A 기준, u=암 앞쪽 법선 방향, v=암 방향 아래) 좌표 → 월드 XZ
shell_uv = [(0.55, -0.1), (0.72, 0.15), (0.82, 0.48), (0.8, 0.82), (0.66, 1.12), (0.42, 1.33), (0.1, 1.44), (-0.22, 1.42), (-0.48, 1.3)]
outer = [bk(u, v) for u, v in shell_uv]
# 바깥 곡선을 안쪽으로 오프셋해 두께를 만든다
inner = []
for i, (u, v) in enumerate(shell_uv):
    a = shell_uv[max(i - 1, 0)]
    b = shell_uv[min(i + 1, len(shell_uv) - 1)]
    du, dv = b[0] - a[0], b[1] - a[1]
    L = math.hypot(du, dv)
    inner.append(bk(u + dv / L * -0.035, v - du / L * -0.035))
BUCKET_W = 1.0
shell = extrude_profile('Bucket_Shell', outer + list(reversed(inner)), BUCKET_W, loc=(0, BY, 0), material=PAINT)
side_uv = [(-0.15, -0.12), (0.55, -0.12)] + shell_uv[1:] + [(-0.15, 0.3)]
for s in (-1, 1):
    extrude_profile(f'Bucket_SidePlate_{s}', [bk(u, v) for u, v in side_uv], 0.035,
                         loc=(0, BY + s * (BUCKET_W / 2 + 0.0175), 0), material=PAINT)
# 윗판과 핀 보스
top_uv = [(-0.15, -0.12), (0.55, -0.12), (0.55, -0.04), (-0.1, -0.04)]
extrude_profile('Bucket_TopPlate', [bk(u, v) for u, v in top_uv], BUCKET_W, loc=(0, BY, 0), material=PAINT)
for s in (-1, 1):
    extrude_profile(f'Bucket_PinLug_{s}', [bk(-0.2, -0.12), bk(0.6, -0.12), bk(0.5, 0.15), bk(-0.1, 0.12)], 0.05,
                    loc=(0, BY + s * 0.24, 0), material=PAINT)
y_cyl('BucketPin_A', 0.065, 0.6, A[0], BY, A[1], CHROME)
y_cyl('BucketBoss_A', 0.12, 0.52, A[0], BY, A[1], DARK)
# 날(커팅 에지)과 이빨 5개
edge_a = bk(-0.22, 1.42)
edge_b = bk(-0.48, 1.3)
bar('Bucket_CuttingEdge', edge_a, edge_b, BY, w=BUCKET_W + 0.07, h=0.05, material=TEETH)
tu, tv = (-0.48 - -0.22), (1.3 - 1.42)
tl = math.hypot(tu, tv)
tu, tv = tu / tl, tv / tl
tooth_base = bk(-0.46, 1.31)
tooth_dir = (nvec[0] * tu + dvec[0] * tv, nvec[1] * tu + dvec[1] * tv)
tooth = None
for k in range(5):
    y = BY - 0.42 + k * 0.21
    if tooth is None:
        # 쐐기 모양 이빨: XZ 단면 삼각형을 Y 로 돌출 후 회전
        tooth = extrude_profile('Bucket_Tooth', [(0, -0.05), (0.24, -0.008), (0.24, 0.008), (0, 0.05)], 0.1,
                                loc=(tooth_base[0], y, tooth_base[1]), material=TEETH)
        tooth.rotation_euler = (0, -math.atan2(tooth_dir[1], tooth_dir[0]), 0)
        bevel(tooth, 0.01, 1)
    else:
        duplicate(tooth, loc=(tooth_base[0], y, tooth_base[1]), name=f'Bucket_Tooth_{k}')

# ---------------------------------------------------------------- 유압 호스
def boom_top(i, off):
    p = boom_c[i]
    a, b = boom_c[max(i - 1, 0)], boom_c[min(i + 1, len(boom_c) - 1)]
    dx, dz = b[0] - a[0], b[1] - a[1]
    L = math.hypot(dx, dz)
    t = i / (len(boom_c) - 1)
    h = lerp3(t, 0.26, 0.40, 0.22) + off
    return (p[0] - dz / L * h, p[1] + dx / L * h)

for k, dy in enumerate((-0.13, 0.13)):
    pts = [(1.05, BY + dy, 1.5)] + [(*boom_top(i, 0.035), ) for i in (3, 7, 11, 15, 19, 22)]
    pts = [pts[0]] + [(x, BY + dy, z) for x, z in pts[1:]]
    pts.append((H[0] + 0.1, BY + dy, H[1] - 0.25))
    curve_tube(f'Hose_Boom_{k}', pts, radius=0.03, material=RUBBER)
