본문으로 건너뛰기
3D 아틀리에
유압 굴착기 (20톤급 크롤러) 3D 모형

끌어서 회전, 휠·두 손가락으로 확대

이 모형에 대하여

유압 굴착기는 무한궤도를 단 하부 주행체 위에 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 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)
나머지 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 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)

다른 모형