
끌어서 회전, 휠·두 손가락으로 확대
이 모형에 대하여
유압 굴착기는 무한궤도를 단 하부 주행체 위에 360도 선회하는 상부 선회체를 올리고, 그 앞에 붐·암·버킷으로 이어지는 작업장치를 단 건설기계다. 엔진이 돌리는 유압 펌프의 기름 압력으로 실린더를 밀고 당겨 관절을 움직인다. 20세기 중반 유럽에서 실용화된 뒤 케이블로 움직이던 굴착기를 빠르게 대체했고, 지금은 세계 어느 공사 현장에서나 볼 수 있다.
이 모델은 운전 중량 20톤 안팎의 표준형 크롤러 굴착기를 특정 제조사에 기대지 않고 실제 크기로 옮긴 것이다. 앞쪽 왼편에 운전석(캡), 오른편에 연료·작동유 탱크 덮개, 뒤쪽에 엔진 후드와 둥근 카운터웨이트를 두는 일반적인 배치를 따랐다. 뒤쪽 끝은 선회 중심에서 같은 반경의 원호로 다듬어 몸체가 돌 때 꼬리가 그리는 원을 드러냈다.
작업장치는 바나나 모양으로 꺾인 붐을 두 개의 붐 실린더가 받치고, 붐 위의 암 실린더가 암을, 암 앞면의 버킷 실린더가 H 링크를 거쳐 버킷을 움직이는 구조를 그대로 재현했다. 버킷을 안쪽으로 오므려 지면 가까이 내린 대기 자세로 놓았다.
만든 방법
궤도판 한 장을 상자 다섯 개(바닥판·그라우저·링크·부싱)로 bmesh 에서 만들고 베벨을 적용한 뒤, 스프로킷과 아이들러를 잇는 경기장 모양 경로를 따라 한쪽에 52장씩 메시를 공유하는 링크 복제로 감았다. 상부 카운터웨이트와 프레임은 뒤쪽이 원호인 평면도를 돌출했고, 캡·후드·탱크 덮개는 측면 단면을 돌출해 베벨로 모서리를 굴렸다. 붐과 암은 2차 베지어 중심선을 따라 두께가 변하는 띠 다각형을 만들어 돌출했으며, 핀 좌표를 먼저 정한 뒤 그 사이에 바렐·크롬 로드·끝눈으로 된 유압 실린더를 계산해 끼웠다. 버킷은 바깥 곡선을 판 두께만큼 안쪽으로 오프셋한 껍데기와 옆판, 이빨 5개로 구성했다. 엔진·유압 배관 내부, 운전석 실내, 궤도의 핀 구조와 상표·경고 문구는 생략하거나 단순화했다.
build.py
이 파일 하나를 Blender 에서 실행하면 위의 모형이 만들어집니다. 455줄.
# 유압 굴착기 (20톤급 크롤러 굴착기) — Blender Python 코드만으로 만든 실물 크기 모델# 앞쪽이 +X, 운전석(캡)이 왼쪽(+Y)에 있다. 전체 길이 약 9.5m, 궤도 폭 2.8m, 캡 높이 약 3m.# - 하부: 궤도판(슈) 한 장을 bmesh 로 만들고, 아이들러·스프로킷을 잇는 경기장 모양 경로를 따라# 52장씩 링크 복제해 무한궤도를 감았다. 하부 롤러·상부 롤러·스프로킷 톱니도 배열로 놓았다.# - 상부: 바닥 평면도(뒤쪽은 선회 반경 원호)를 돌출해 카운터웨이트를, 측면 단면을 돌출해 캡·엔진후드를 만들었다.# - 작업장치: 붐·암은 중심선(베지어)을 따라 두께가 변하는 띠 단면을 만들어 Y 방향으로 돌출했고,# 핀 위치를 먼저 정한 뒤 그 사이에 유압 실린더(바렐+크롬 로드)를 계산해 끼웠다.# - 버킷은 바깥 곡선을 두께만큼 안쪽으로 오프셋한 얇은 껍데기와 옆판, 이빨 5개로 구성했다.import mathfrom 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)나머지 407줄 펼치기접기
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 outdef 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 partsdef 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.6C = 1.83 # 스프로킷·아이들러 중심의 xZC = 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 * RPshoes = []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 ptsfor 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.72HALF_W = 1.25def 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 ptsarc = rear_arc(TAIL_R, HALF_W) # y: -half → +halfdeck = 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.25cab_w = CAB_Y1 - CAB_Y0cab_yc = (CAB_Y0 + CAB_Y1) / 2cab_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 # 붐 중심선의 yF = (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.9A = (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=암 방향 아래) 좌표 → 월드 XZshell_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.0shell = 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 / tltooth_base = bk(-0.46, 1.31)tooth_dir = (nvec[0] * tu + dvec[0] * tv, nvec[1] * tu + dvec[1] * tv)tooth = Nonefor 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)






