본문으로 건너뛰기
3D 아틀리에
아메리칸형 증기기관차 (4-4-0) 3D 모형

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

이 모형에 대하여

4-4-0은 앞쪽에 작은 바퀴 4개를 단 선회대차, 그 뒤에 동력을 받는 큰 동륜 4개가 있고 뒤쪽 바퀴는 없는 배열을 말한다. 선회대차가 굽고 고르지 않은 초기 미국 선로를 잘 따라가 19세기 중후반 미국 철도에서 가장 흔한 기관차가 되었고, 그래서 '아메리칸형'이라는 이름으로 불린다.

이 시기의 기관차는 장작을 연료로 썼다. 불똥이 날리지 않도록 굴뚝을 다이아몬드나 풍선 모양으로 크게 부풀렸고, 선로 위 가축이나 장애물을 밀어내는 V자 배장기(카우캐처), 밤길을 비추는 상자형 전조등, 놋쇠로 장식한 증기돔과 종, 목재 운전실이 함께 어우러져 서부 개척 시대 철도를 떠올리게 하는 모습이 되었다.

이 모델은 특정 한 대가 아니라 이 형식의 전형적인 모습을 실제 크기로 재현했다. 지름 약 1.5m 동륜 두 쌍을 연결봉으로 잇고 실린더에서 나온 주연봉이 앞 동륜을 돌리는 구조, 청회색 보일러 외판과 놋쇠 띠, 붉은 바퀴와 운전실, 장작을 실은 탄수차를 표준궤 선로 위에 올려 표현했다.

만든 방법

바퀴는 테와 플랜지 단면을 회전체(lathe)로 깎고 바큇살과 균형추를 원형 배열해 하나로 합친 뒤, 같은 메시를 링크 복제해 동륜 4개와 대차·탄수차 바퀴 12개에 공유했다. 크랭크핀 위치를 좌우 90도 위상차로 계산하고 그 좌표에서 연결봉·주연봉·크로스헤드 위치를 구해 실제로 맞물리는 배치로 놓았다. 굴뚝·돔·종은 회전체, 배장기 살과 장작더미·침목은 루프 배열이며 장작은 고정 시드 난수로 약간씩 흐트러뜨렸다. 운전실의 창과 보일러 관통부는 불리언 차집합으로 뚫었다. 밸브 기어(편심봉·링크)와 브레이크 장치, 운전실 내부 계기류는 대부분 생략하거나 단순한 덩어리로 대신했다.

build.py

이 파일 하나를 Blender 에서 실행하면 위의 모형이 만들어집니다. 513줄.

# 아메리칸형(4-4-0) 증기기관차와 탄수차 — 19세기 미국 철도를 상징하는 나무 때는 기관차.
# 앞쪽 4륜 선회대차 + 지름 1.52m 동륜 2쌍(바퀴 배열 4-4-0), 다이아몬드형 굴뚝, 상자형 전조등,
# V자 배장기(카우캐처), 놋쇠 증기돔과 종, 목재 운전실, 장작을 실은 탄수차를 실제 크기(미터)로 만들었다.
# 바퀴는 회전체(lathe)로 테·플랜지를 깎고 바큇살을 원형 배열한 뒤 같은 메시를 링크 복제해 공유한다.
# 연결봉·주연봉은 크랭크핀 좌표에서 계산해 두 점 사이 막대로 놓았고(좌우 90도 위상차),
# 배장기 살, 장작더미, 침목은 루프로 배열했다. 운전실 창과 보일러 관통부는 불리언으로 뚫었다.
import random
from atelier import *
random.seed(4404)
SHOT = {"azimuth": -32, "elevation": 9, "lens": 55, "light": "golden", "background": "#27303d"}
# ---------------------------------------------------------------- 재질
M_JACKET = mat('BoilerJacket_RussiaIron', '#46555f', 0.35, 0.6)
M_BLACK = mat('Smokebox_Graphite', '#26272a', 0.55, 0.3)
M_BRASS = mat('Brass_Polished', '#c9a04a', 0.25, 1.0)
M_RED = mat('Vermilion_Paint', '#9c2b1f', 0.45, 0.0, coat=0.3)
M_STEEL = mat('Rod_Steel', '#a9adb1', 0.3, 1.0)
M_IRON = mat('Wheel_Tire_Iron', '#4a4846', 0.5, 0.8)
M_WOOD = mat('Cab_Varnished_Wood', '#7a4524', 0.5, 0.0)
M_CREAM = mat('Trim_Cream', '#d8c79a', 0.5, 0.0)
M_LOG = mat('Firewood_Logs', '#6b5038', 0.85, 0.0)
M_TIE = mat('Tie_Creosote', '#3d3128', 0.9, 0.0)
M_BALLAST = mat('Ballast_Granite', '#73716d', 0.95, 0.0)
M_LAMP = mat('Headlamp_Lens', '#ffd99a', 0.2, 0.0, emit='#ffcf80', emit_strength=3.0)
# ---------------------------------------------------------------- 기준 치수
Z_BAL = 0.25 # 자갈 도상 윗면
Z_TIE = Z_BAL + 0.18 # 침목 윗면
ZR = Z_TIE + 0.15 # 레일 윗면
GAUGE = 1.435
Y_WHEEL = 0.75 # 답면 중심
W_WHEEL = 0.14
R_DRV = 0.76 # 동륜 반지름 (지름 60인치)
Z_AX = ZR + R_DRV # 동륜 축 높이
X_DRV = (-0.6, -2.8) # 앞·뒤 동륜
R_TRK = 0.42 # 대차 바퀴
Z_TAX = ZR + R_TRK
X_TRK = (1.55, 3.15)
X_TND = (-7.0, -8.3, -9.6, -10.9)
CRANK = 0.32
Z_BOIL = 2.62 # 보일러 중심
R_BOIL = 0.62
Z_DECK = 2.18 # 러닝보드·운전실 바닥
나머지 465줄 펼치기
def bar(name, p1, p2, w, h, material, up=(0, 0, 1)):
"""두 점 p1→p2 를 잇는 각재(단면 w×h)."""
p1, p2 = Vector(p1), Vector(p2)
d = p2 - p1
ob = box(name, (d.length, w, h), loc=(p1 + p2) / 2, material=material)
q = Vector((1, 0, 0)).rotation_difference(d.normalized())
ob.rotation_euler = q.to_euler()
return ob
def rod_y(name, p1, p2, w, h, material):
"""XZ 평면 안에서(같은 y) 두 점을 잇는 막대. 폭 w 는 y 방향."""
dx, dz = p2[0] - p1[0], p2[2] - p1[2]
L = math.hypot(dx, dz)
c = ((p1[0] + p2[0]) / 2, p1[1], (p1[2] + p2[2]) / 2)
return box(name, (L, w, h), loc=c, rot=(0, -math.atan2(dz, dx), 0), material=material)
def cyl_y(name, r, length, center, material, seg=24):
"""y 축 방향 원기둥(중심 기준)."""
return cylinder(name, r, length, loc=center, rot=(math.pi / 2, 0, 0), material=material, seg=seg, base=False)
def cyl_x(name, r, length, center, material, seg=32):
return cylinder(name, r, length, loc=center, rot=(0, math.pi / 2, 0), material=material, seg=seg, base=False)
# ---------------------------------------------------------------- 바퀴
def make_wheel(name, R, nspk, counterweight, pin_r=None):
"""Z 축을 축으로 하는 바퀴(바깥면 +Z). 테+플랜지는 회전체, 살은 원형 배열."""
w = W_WHEEL
parts = []
tire = lathe(name + '_Tire', [
(R - 0.075, -w / 2), (R + 0.035, -w / 2), (R + 0.035, -w / 2 + 0.022),
(R, -w / 2 + 0.045), (R, w / 2), (R - 0.075, w / 2), (R - 0.075, -w / 2)],
material=M_IRON, seg=64)
parts.append(tire)
rim = lathe(name + '_Rim', [
(R - 0.13, -0.035), (R - 0.07, -0.045), (R - 0.07, 0.045), (R - 0.13, 0.035), (R - 0.13, -0.035)],
material=M_RED, seg=64)
parts.append(rim)
hub_r = 0.13 if R > 0.6 else 0.09
hub = lathe(name + '_Hub', [
(0, 0.09), (hub_r * 0.55, 0.09), (hub_r * 0.8, 0.07), (hub_r, 0.05), (hub_r, -0.06), (0, -0.06)],
material=M_RED, seg=32)
parts.append(hub)
for i in range(nspk):
a = 2 * math.pi * (i + 0.5) / nspk
r0, r1 = hub_r * 0.85, R - 0.10
rm = (r0 + r1) / 2
s = box(f'{name}_Spoke{i}', (r1 - r0, 0.045, 0.05),
loc=(rm * math.cos(a), rm * math.sin(a), 0), rot=(0, 0, a), material=M_RED)
parts.append(s)
if counterweight:
# 크랭크 반대편 초승달 균형추
pts = []
n = 14
span = math.radians(70)
for k in range(n + 1):
a = math.pi - span + 2 * span * k / n
pts.append(((R - 0.11) * math.cos(a), (R - 0.11) * math.sin(a)))
for k in range(n, -1, -1):
a = math.pi - span * 0.8 + 2 * span * 0.8 * k / n
pts.append(((R * 0.48) * math.cos(a), (R * 0.48) * math.sin(a)))
cw = prism(name + '_Counterweight', list(reversed(pts)), 0.075, loc=(0, 0, -0.035), material=M_RED)
parts.append(cw)
if pin_r:
boss = cylinder(name + '_CrankBoss', 0.09, 0.07, loc=(pin_r, 0, 0.0), material=M_RED, seg=20)
pin = cylinder(name + '_CrankPin', 0.05, 0.20, loc=(pin_r, 0, 0.05), material=M_STEEL, seg=16)
parts += [boss, pin]
ob = join(parts, name)
return ob
def place_wheel(proto, name, x, side, z, angle_deg=0.0):
"""side=+1(왼쪽, +Y) / -1(오른쪽). angle_deg: 크랭크핀의 세계 XZ 각도."""
rx = -math.pi / 2 if side > 0 else math.pi / 2
a = math.radians(angle_deg)
ry = -a if side > 0 else -a
return duplicate(proto, loc=(x, side * Y_WHEEL, z), rot=(rx, ry, 0), name=name)
# 실제 핀 위치를 확인할 수 있도록 회전 규칙을 수치로 검산하는 대신 핀 좌표를 직접 계산한다.
def pin_world(x, z, side, angle_deg):
a = math.radians(angle_deg)
return (x + CRANK * math.cos(a), z + CRANK * math.sin(a))
driver = make_wheel('DriverWheel', R_DRV, 16, True, pin_r=CRANK)
truckw = make_wheel('TruckWheel', R_TRK, 10, False)
PHASE = {1: 35.0, -1: 125.0} # 좌우 크랭크 90도 위상차
drv_objs = []
for side in (1, -1):
sname = 'L' if side > 0 else 'R'
for k, x in enumerate(X_DRV):
drv_objs.append(place_wheel(driver, f'Driver_{sname}{k + 1}', x, side, Z_AX, PHASE[side]))
for k, x in enumerate(X_TRK):
place_wheel(truckw, f'LeadTruckWheel_{sname}{k + 1}', x, side, Z_TAX)
for k, x in enumerate(X_TND):
place_wheel(truckw, f'TenderWheel_{sname}{k + 1}', x, side, Z_TAX)
driver.hide_render = True
driver.hide_viewport = True
truckw.hide_render = True
truckw.hide_viewport = True
driver['atelier_cutter'] = True
truckw['atelier_cutter'] = True
# 축
for k, x in enumerate(X_DRV):
cyl_y(f'DriverAxle{k + 1}', 0.085, 1.36, (x, 0, Z_AX), M_IRON)
for k, x in enumerate(X_TRK + X_TND):
cyl_y(f'TruckAxle{k + 1}', 0.06, 1.36, (x, 0, Z_TAX), M_IRON)
# ---------------------------------------------------------------- 선로
def build_track(x0, x1):
pts = [(-1.8, 0), (1.8, 0), (1.5, Z_BAL), (-1.5, Z_BAL)]
extrude_profile('Track_Ballast', pts, x1 - x0, loc=((x0 + x1) / 2, 0, 0), material=M_BALLAST, axis='X')
tie = box('Track_Tie', (0.24, 2.6, 0.18), loc=(x0 + 0.3, 0, Z_BAL), material=M_TIE, base=True)
n = int((x1 - x0 - 0.3) / 0.58)
for i in range(1, n):
duplicate(tie, loc=(x0 + 0.3 + i * 0.58, 0, Z_BAL), name='Track_Tie')
prof = [(-0.065, 0), (0.065, 0), (0.065, 0.012), (0.009, 0.025), (0.009, 0.105),
(0.036, 0.112), (0.036, 0.15), (-0.036, 0.15), (-0.036, 0.112), (-0.009, 0.105),
(-0.009, 0.025), (-0.065, 0.012)]
yc = GAUGE / 2 + 0.036
for s in (1, -1):
r = extrude_profile('Track_Rail', prof, x1 - x0 + 0.4, loc=((x0 + x1) / 2, s * yc, Z_TIE),
material=M_STEEL, axis='X')
r.name = 'Track_Rail_L' if s > 0 else 'Track_Rail_R'
# 타이 플레이트
plate = box('Track_TiePlate', (0.18, 0.2, 0.012), loc=(x0 + 0.3, yc, Z_TIE - 0.006), material=M_IRON)
for i in range(n):
for s in (1, -1):
if i == 0 and s == 1:
continue
duplicate(plate, loc=(x0 + 0.3 + i * 0.58, s * yc, Z_TIE - 0.006), name='Track_TiePlate')
build_track(-12.6, 5.6)
# ---------------------------------------------------------------- 프레임·보일러
for s in (1, -1):
box(f'Frame_Bar_{"L" if s > 0 else "R"}', (9.0, 0.1, 0.32), loc=(-1.4, s * 0.52, 1.12), material=M_BLACK)
box('Frame_RearBuffer', (0.3, 2.3, 0.36), loc=(-5.75, 0, 1.12), material=M_RED)
box('Footplate_Rear', (2.4, 2.7, 0.08), loc=(-4.55, 0, Z_DECK - 0.04), material=M_WOOD)
boiler = cyl_x('Boiler_Barrel', R_BOIL, 6.2, (-1.1, 0, Z_BOIL), M_JACKET, seg=48)
smooth(boiler)
# 보일러 띠(놋쇠)
for k, x in enumerate([-3.5, -2.6, -1.95, -0.95, 0.45, 1.35, 1.95]):
b = cyl_x(f'Boiler_Band{k}', R_BOIL + 0.012, 0.05, (x, 0, Z_BOIL), M_BRASS, seg=48)
smooth(b)
# 화실(운전실 아래로 내려가는 상자)
box('Firebox', (1.3, 1.08, 1.2), loc=(-4.0, 0, 1.25), material=M_BLACK, base=True)
# 연실(smokebox)
sbx = cyl_x('Smokebox', 0.69, 1.15, (2.575, 0, Z_BOIL), M_BLACK, seg=48)
smooth(sbx)
door = lathe('Smokebox_Door', [(0.0, 0.07), (0.3, 0.06), (0.55, 0.03), (0.63, 0.0)], material=M_BLACK, seg=48)
door.location = (3.15, 0, Z_BOIL)
door.rotation_euler = (0, math.pi / 2, 0)
cyl_x('Smokebox_DoorHandle', 0.05, 0.2, (3.25, 0, Z_BOIL), M_BRASS, seg=16)
# 번호판(원형 놋쇠)
nb = cyl_x('Smokebox_NumberPlate', 0.16, 0.03, (3.22, 0, Z_BOIL + 0.32), M_BRASS, seg=32)
# 실린더 받침(새들)
box('Cylinder_Saddle', (0.75, 1.4, 1.0), loc=(2.33, 0, Z_AX - 0.05), material=M_BLACK, base=True)
# ---------------------------------------------------------------- 실린더·연결봉
Y_CYL = 1.0
for s in (1, -1):
sn = 'L' if s > 0 else 'R'
c = cyl_x(f'Cylinder_{sn}', 0.28, 0.78, (2.33, s * Y_CYL, Z_AX + 0.04), M_JACKET, seg=32)
smooth(c)
for xx in (1.94, 2.72):
cyl_x(f'Cylinder_Head_{sn}', 0.3, 0.05, (xx, s * Y_CYL, Z_AX + 0.04), M_BRASS, seg=32)
# 증기실(밸브 박스)
box(f'SteamChest_{sn}', (0.7, 0.36, 0.3), loc=(2.33, s * (Y_CYL - 0.02), Z_AX + 0.32), material=M_JACKET, base=True)
box(f'SteamChest_Cover_{sn}', (0.74, 0.4, 0.04), loc=(2.33, s * (Y_CYL - 0.02), Z_AX + 0.62), material=M_BRASS, base=True)
# 크랭크핀 좌표
pins = [pin_world(x, Z_AX, s, PHASE[s]) for x in X_DRV]
y_side = s * 0.89
y_main = s * 0.99
# 연결봉(앞뒤 동륜)
rod_y(f'SideRod_{sn}', (pins[1][0], y_side, pins[1][1]), (pins[0][0], y_side, pins[0][1]), 0.05, 0.11, M_STEEL)
for p in pins:
cyl_y(f'SideRod_Boss_{sn}', 0.08, 0.06, (p[0], y_side, p[1]), M_STEEL, seg=16)
# 주연봉: 크로스헤드는 실린더 축 높이에서 미끄러진다
L = 2.1
zc = Z_AX + 0.04
pf = pins[0]
xc = pf[0] + math.sqrt(L * L - (zc - pf[1]) ** 2)
rod_y(f'MainRod_{sn}', (pf[0], y_main, pf[1]), (xc, y_main, zc), 0.05, 0.13, M_STEEL)
cyl_y(f'MainRod_BigEnd_{sn}', 0.09, 0.07, (pf[0], y_main, pf[1]), M_STEEL, seg=16)
box(f'Crosshead_{sn}', (0.22, 0.12, 0.26), loc=(xc, s * Y_CYL, zc), material=M_STEEL)
# 피스톤봉·가이드
cyl_x(f'PistonRod_{sn}', 0.035, 1.94 - xc, ((1.94 + xc) / 2, s * Y_CYL, zc), M_STEEL, seg=12)
for dz in (0.16, -0.16):
box(f'GuideBar_{sn}', (0.95, 0.05, 0.05), loc=(1.45, s * Y_CYL, zc + dz), material=M_STEEL)
box(f'GuideYoke_{sn}', (0.08, 0.5, 0.45), loc=(0.94, s * 0.78, zc), material=M_BLACK)
# ---------------------------------------------------------------- 러닝보드·난간
for s in (1, -1):
sn = 'L' if s > 0 else 'R'
box(f'RunningBoard_{sn}', (5.7, 0.62, 0.05), loc=(-0.75, s * 0.96, Z_DECK - 0.025), material=M_WOOD)
box(f'RunningBoard_Edge_{sn}', (5.7, 0.04, 0.12), loc=(-0.75, s * 1.29, Z_DECK - 0.06), material=M_BRASS)
# 받침 브래킷
for x in (-1.7, 0.6, 1.6):
box(f'RunningBoard_Bracket_{sn}', (0.06, 0.66, 0.06), loc=(x, s * 0.88, Z_DECK - 0.08), material=M_BLACK)
# 동륜 덮개(스플래셔)
for k, x in enumerate(X_DRV):
pts = []
for i in range(17):
a = math.radians(30 + 120 * i / 16)
pts.append((x + (R_DRV + 0.06) * math.cos(a), Z_AX + (R_DRV + 0.06) * math.sin(a)))
for i in range(16, -1, -1):
a = math.radians(30 + 120 * i / 16)
pts.append((x + (R_DRV + 0.1) * math.cos(a), Z_AX + (R_DRV + 0.1) * math.sin(a)))
pts = [p for p in pts]
sp = extrude_profile(f'Splasher_{sn}{k + 1}', pts, 0.2, loc=(0, s * 0.75, 0), material=M_BRASS)
# 보일러 손잡이 난간
hr = [(-3.4, s * 0.74, Z_BOIL + 0.3), (2.0, s * 0.74, Z_BOIL + 0.3)]
curve_tube(f'Handrail_{sn}', hr, radius=0.016, material=M_BRASS)
for x in (-3.0, -1.6, -0.2, 1.2):
bar(f'Handrail_Stanchion_{sn}', (x, s * 0.58, Z_BOIL + 0.24), (x, s * 0.75, Z_BOIL + 0.3), 0.025, 0.025, M_BRASS)
# ---------------------------------------------------------------- 돔·종·굴뚝·전조등
Z_TOP = Z_BOIL + R_BOIL
lathe('Dome_Steam', [(0.42, -0.12), (0.43, 0.02), (0.38, 0.06), (0.36, 0.45), (0.38, 0.5),
(0.37, 0.56), (0.30, 0.66), (0.18, 0.72), (0.0, 0.74)],
loc=(-1.45, 0, Z_TOP), material=M_BRASS, seg=48)
lathe('Dome_Sand', [(0.32, -0.1), (0.33, 0.02), (0.28, 0.05), (0.27, 0.3), (0.29, 0.34),
(0.22, 0.44), (0.12, 0.49), (0.0, 0.5)],
loc=(0.95, 0, Z_TOP), material=M_BRASS, seg=40)
# 기적(whistle)
cylinder('Whistle_Pipe', 0.03, 0.18, loc=(-1.45, 0, Z_TOP + 0.73), material=M_BRASS, seg=12)
lathe('Whistle_Bell', [(0.0, 0.0), (0.06, 0.0), (0.06, 0.22), (0.04, 0.25), (0.0, 0.26)],
loc=(-1.45, 0, Z_TOP + 0.9), material=M_BRASS, seg=20)
# 종과 요크
lathe('Bell', [(0.0, 0.34), (0.06, 0.34), (0.1, 0.31), (0.12, 0.2), (0.16, 0.08), (0.21, 0.0),
(0.19, -0.01), (0.0, -0.01)],
loc=(-0.25, 0, Z_TOP + 0.3), material=M_BRASS, seg=40)
for s in (1, -1):
box('Bell_Yoke', (0.06, 0.04, 0.55), loc=(-0.25, s * 0.25, Z_TOP - 0.05), material=M_BLACK, base=True)
box('Bell_Yoke_Top', (0.06, 0.54, 0.05), loc=(-0.25, 0, Z_TOP + 0.5), material=M_BLACK, base=True)
cyl_y('Bell_Axle', 0.025, 0.5, (-0.25, 0, Z_TOP + 0.47), M_BLACK, seg=12)
# 다이아몬드 굴뚝(불똥 막이)
X_STK = 2.55
Z_SBT = Z_BOIL + 0.69
lathe('Smokestack', [
(0.30, -0.12), (0.30, 0.0), (0.22, 0.1), (0.21, 0.55), (0.26, 0.62),
(0.62, 1.12), (0.66, 1.18), (0.66, 1.24), (0.6, 1.26), (0.42, 1.4),
(0.40, 1.46), (0.43, 1.5), (0.40, 1.53), (0.0, 1.53)],
loc=(X_STK, 0, Z_SBT), material=M_BLACK, seg=48)
torus('Smokestack_BrassRing', R=0.215, r=0.025, loc=(X_STK, 0, Z_SBT + 0.4), material=M_BRASS, seg=40, rseg=8)
torus('Smokestack_TopRing', R=0.66, r=0.025, loc=(X_STK, 0, Z_SBT + 1.21), material=M_BRASS, seg=48, rseg=8)
# 전조등
X_LMP = 3.25
Z_LMP = Z_SBT - 0.02
box('Headlamp_Bracket', (0.5, 0.5, 0.12), loc=(X_LMP - 0.05, 0, Z_LMP - 0.04), material=M_BLACK, base=True)
box('Headlamp_Case', (0.62, 0.62, 0.62), loc=(X_LMP, 0, Z_LMP + 0.08), material=M_RED, base=True)
box('Headlamp_Trim', (0.66, 0.66, 0.05), loc=(X_LMP, 0, Z_LMP + 0.08), material=M_BRASS, base=True)
box('Headlamp_TrimTop', (0.66, 0.66, 0.05), loc=(X_LMP, 0, Z_LMP + 0.68), material=M_BRASS, base=True)
wedge_pts = [(-0.36, 0.0), (0.36, 0.0), (0.0, 0.22)]
rf = extrude_profile('Headlamp_Roof', [(-0.36, 0), (0.36, 0), (0.0, 0.2)], 0.7,
loc=(X_LMP, 0, Z_LMP + 0.73), material=M_RED, axis='X')
cylinder('Headlamp_Chimney', 0.06, 0.18, loc=(X_LMP - 0.05, 0, Z_LMP + 0.85), material=M_BLACK, seg=16)
cone('Headlamp_ChimneyCap', 0.1, 0.08, loc=(X_LMP - 0.05, 0, Z_LMP + 1.03), material=M_BLACK, seg=16)
cyl_x('Headlamp_Bezel', 0.24, 0.05, (X_LMP + 0.33, 0, Z_LMP + 0.39), M_BRASS, seg=32)
cyl_x('Headlamp_Lens', 0.2, 0.03, (X_LMP + 0.35, 0, Z_LMP + 0.39), M_LAMP, seg=32)
for s in (1, -1):
box('Headlamp_SidePanel', (0.4, 0.02, 0.36), loc=(X_LMP, s * 0.315, Z_LMP + 0.22), material=M_CREAM, base=True)
# ---------------------------------------------------------------- 배장기(카우캐처)·완충보
X_BEAM = 3.45
box('Pilot_Beam', (0.22, 2.7, 0.36), loc=(X_BEAM, 0, 1.0), material=M_RED, base=True)
box('Pilot_Beam_Plate', (0.03, 2.5, 0.08), loc=(X_BEAM + 0.12, 0, 1.14), material=M_BRASS, base=True)
# 프레임과 완충보 연결
X_APEX = 4.75
Z_PTOP = 1.02
Z_PBOT = ZR + 0.08
HALF = 1.25
def v_edge(y):
return X_APEX - (abs(y) / HALF) * (X_APEX - X_BEAM - 0.12)
for i in range(15):
y = -HALF + 2 * HALF * i / 14
if abs(y) < 1e-6:
continue
top = (X_BEAM + 0.1, y, Z_PTOP)
bot = (v_edge(y) - 0.03, y, Z_PBOT + 0.03)
bar(f'Pilot_Slat{i:02d}', top, bot, 0.05, 0.05, M_RED)
for s in (1, -1):
bar('Pilot_BottomRail', (X_APEX + 0.03, 0, Z_PBOT), (X_BEAM + 0.1, s * (HALF + 0.03), Z_PBOT), 0.08, 0.06, M_RED)
bar('Pilot_CornerPost', (X_BEAM + 0.1, s * HALF, Z_PTOP), (X_BEAM + 0.1, s * HALF, Z_PBOT), 0.07, 0.07, M_RED)
bar('Pilot_Spine', (X_BEAM + 0.1, 0, Z_PTOP), (X_APEX, 0, Z_PBOT), 0.08, 0.08, M_RED)
# 연결기(링크 앤 핀)
cyl_x('Coupler_Front', 0.07, 0.4, (X_BEAM + 0.3, 0, 1.18), M_IRON, seg=16)
# ---------------------------------------------------------------- 선회대차
for s in (1, -1):
box('LeadTruck_Frame', (2.3, 0.08, 0.26), loc=(2.35, s * 0.6, Z_TAX - 0.1), material=M_BLACK)
for x in X_TRK:
box('LeadTruck_Journal', (0.2, 0.08, 0.2), loc=(x, s * 0.6, Z_TAX), material=M_BLACK)
box('LeadTruck_Bolster', (0.4, 1.3, 0.25), loc=(2.35, 0, Z_TAX + 0.03), material=M_BLACK)
box('LeadTruck_Pivot', (0.25, 0.25, 0.4), loc=(2.35, 0, Z_TAX + 0.15), material=M_BLACK)
# ---------------------------------------------------------------- 운전실
X_CF, X_CB = -3.55, -5.6 # 앞벽, 뒷벽
Y_CW = 1.32
Z_EAVE = 3.95
cab_parts = []
front = box('Cab_FrontWall', (0.07, 2 * Y_CW, Z_EAVE - Z_DECK), loc=(X_CF, 0, Z_DECK), material=M_WOOD, base=True)
cutter = cyl_x('Cab_Cutter_Boiler', R_BOIL + 0.01, 0.4, (X_CF, 0, Z_BOIL), None)
boolean(front, cutter)
for s in (1, -1):
wc = box('Cab_Cutter_FrontWin', (0.4, 0.5, 0.55), loc=(X_CF, s * 0.92, 3.18), base=True)
boolean(front, wc)
for s in (1, -1):
sn = 'L' if s > 0 else 'R'
side = box(f'Cab_SideWall_{sn}', (X_CF - X_CB, 0.07, Z_EAVE - Z_DECK), loc=((X_CF + X_CB) / 2, s * Y_CW, Z_DECK),
material=M_WOOD, base=True)
for k, xc in enumerate((-4.08, -4.95)):
wc = box(f'Cab_Cutter_SideWin', (0.66, 0.4, 0.62), loc=(xc, s * Y_CW, 3.1), base=True)
boolean(side, wc)
# 창틀·몰딩(크림)
box(f'Cab_BeltRail_{sn}', (X_CF - X_CB + 0.06, 0.03, 0.06), loc=((X_CF + X_CB) / 2, s * (Y_CW + 0.045), 3.02),
material=M_CREAM, base=True)
box(f'Cab_SillRail_{sn}', (X_CF - X_CB + 0.06, 0.03, 0.06), loc=((X_CF + X_CB) / 2, s * (Y_CW + 0.045), Z_DECK),
material=M_CREAM, base=True)
for xp in (X_CF, -4.515, X_CB):
box(f'Cab_Post_{sn}', (0.08, 0.03, Z_EAVE - Z_DECK), loc=(xp, s * (Y_CW + 0.045), Z_DECK), material=M_CREAM, base=True)
# 판벽 세로 줄눈
for i in range(1, 10):
x = X_CB + (X_CF - X_CB) * i / 10
box(f'Cab_Panel_{sn}', (0.02, 0.012, 0.8), loc=(x, s * (Y_CW + 0.04), 2.22), material=M_RED, base=True)
# 손잡이 봉
curve_tube(f'Cab_Grab_{sn}', [(X_CB - 0.06, s * (Y_CW - 0.15), 2.3), (X_CB - 0.06, s * (Y_CW - 0.15), 3.6)],
radius=0.018, material=M_BRASS)
# 지붕(아치형, 처마 돌출)
roof_pts = []
for i in range(17):
t = -1 + 2 * i / 16
roof_pts.append((t * 1.55, Z_EAVE + 0.28 * (1 - t * t)))
for i in range(16, -1, -1):
t = -1 + 2 * i / 16
roof_pts.append((t * 1.55, Z_EAVE + 0.07 + 0.28 * (1 - t * t)))
roof = extrude_profile('Cab_Roof', roof_pts, X_CF - X_CB + 0.5, loc=((X_CF + X_CB) / 2 - 0.05, 0, -0.05),
material=M_RED, axis='X')
box('Cab_Roof_Vent', (0.9, 0.5, 0.12), loc=((X_CF + X_CB) / 2, 0, Z_EAVE + 0.27), material=M_RED, base=True)
# 운전실 안쪽: 보일러 뒷판, 화구문
cyl_x('Cab_Backhead', R_BOIL, 0.05, (-4.15, 0, Z_BOIL), M_BLACK, seg=48)
box('Cab_FireDoor', (0.06, 0.4, 0.3), loc=(-4.2, 0, Z_DECK + 0.25), material=M_BLACK, base=True)
for k, (yy, zz) in enumerate([(-0.3, 3.0), (0.3, 3.0), (0.0, 3.2)]):
cyl_x(f'Cab_Gauge{k}', 0.07, 0.04, (-4.2, yy, zz), M_BRASS, seg=16)
# 바닥 아래 계단
for s in (1, -1):
box('Cab_Step', (0.4, 0.3, 0.04), loc=(-5.2, s * 1.2, 1.5), material=M_BLACK, base=True)
box('Cab_StepHanger', (0.04, 0.04, 0.68), loc=(-5.2, s * 1.2, 1.5), material=M_BLACK, base=True)
# ---------------------------------------------------------------- 탄수차
XT0, XT1 = -6.15, -11.55
Z_TD = 1.62
LEN_T = XT0 - XT1
box('Drawbar', (0.65, 0.14, 0.1), loc=(-5.95, 0, 1.3), material=M_IRON)
for s in (1, -1):
box('Tender_SideSill', (LEN_T, 0.2, 0.25), loc=((XT0 + XT1) / 2, s * 1.15, Z_TD - 0.25), material=M_BLACK, base=True)
box('Tender_CenterSill', (LEN_T, 0.4, 0.25), loc=((XT0 + XT1) / 2, 0, Z_TD - 0.25), material=M_BLACK, base=True)
box('Tender_FrontSill', (0.12, 2.55, 0.35), loc=(XT0 - 0.06, 0, Z_TD - 0.35), material=M_RED, base=True)
box('Tender_RearSill', (0.12, 2.55, 0.35), loc=(XT1 + 0.06, 0, Z_TD - 0.35), material=M_RED, base=True)
Z_TANK = 2.75
box('Tender_Tank', (LEN_T - 0.3, 2.6, Z_TANK - Z_TD), loc=((XT0 + XT1) / 2 - 0.15, 0, Z_TD), material=M_RED, base=True)
# 크림색 테두리 패널
for s in (1, -1):
box('Tender_Panel', (LEN_T - 0.7, 0.012, Z_TANK - Z_TD - 0.3),
loc=((XT0 + XT1) / 2 - 0.15, s * 1.306, Z_TD + 0.15), material=M_CREAM, base=True)
box('Tender_PanelInner', (LEN_T - 0.8, 0.012, Z_TANK - Z_TD - 0.4),
loc=((XT0 + XT1) / 2 - 0.15, s * 1.312, Z_TD + 0.2), material=M_RED, base=True)
box('Tender_RearPanel', (0.012, 2.2, 0.8), loc=(XT1 - 0.006, 0, Z_TD + 0.15), material=M_CREAM, base=True)
box('Tender_RearPanelInner', (0.012, 2.08, 0.68), loc=(XT1 - 0.012, 0, Z_TD + 0.21), material=M_RED, base=True)
cyl_x('Coupler_Rear', 0.07, 0.4, (XT1 - 0.1, 0, 1.42), M_IRON, seg=16)
for s in (1, -1):
box('Tender_RearStep', (0.3, 0.35, 0.04), loc=(XT1 + 0.1, s * 1.1, 0.95), material=M_BLACK, base=True)
box('Tender_RearStepHanger', (0.04, 0.04, 0.26), loc=(XT1 + 0.1, s * 1.1, 0.99), material=M_BLACK, base=True)
box('Tender_FrontStep', (0.3, 0.35, 0.04), loc=(XT0 - 0.25, s * 1.1, 0.95), material=M_BLACK, base=True)
box('Tender_FrontStepHanger', (0.04, 0.04, 0.26), loc=(XT0 - 0.25, s * 1.1, 0.99), material=M_BLACK, base=True)
# 장작칸 측벽(플레어)
for s in (1, -1):
extrude_profile('Tender_Flare', [(0.0, 0.0), (s * 0.06, 0.0), (s * 0.16, 0.42), (s * 0.1, 0.42)],
LEN_T - 0.3, loc=((XT0 + XT1) / 2 - 0.15, s * 1.24, Z_TANK), material=M_RED, axis='X')
box('Tender_FlareCap', (LEN_T - 0.3, 0.12, 0.05), loc=((XT0 + XT1) / 2 - 0.15, s * 1.39, Z_TANK + 0.42),
material=M_BRASS, base=True)
box('Tender_FlareBack', (0.08, 2.6, 0.42), loc=(XT1 + 0.04, 0, Z_TANK), material=M_RED, base=True)
# 물 주입구
cylinder('Tender_WaterHatch', 0.25, 0.18, loc=(-10.8, 0, Z_TANK), material=M_BLACK, seg=24)
cylinder('Tender_WaterHatchLid', 0.29, 0.04, loc=(-10.8, 0, Z_TANK + 0.18), material=M_BRASS, seg=24)
# 장작더미
log = cylinder('Firewood_Log', 0.075, 1.12, rot=(math.pi / 2, 0, 0), material=M_LOG, seg=8, base=False)
nlog = 0
x_lo, x_hi = -6.45, -10.35
for layer in range(5):
z = Z_TANK + 0.08 + layer * 0.135
shrink = 0.18 * layer
xa, xb = x_lo - shrink, x_hi + shrink
n = int((xa - xb) / 0.16)
for i in range(n):
x = xa - 0.08 - i * 0.16 - (0.08 if layer % 2 else 0)
if x < xb:
continue
for yc in (-0.6, 0.6):
if layer >= 3 and random.random() < 0.25:
continue
ob = log if nlog == 0 else duplicate(log, name='Firewood_Log')
ob.location = (x + random.uniform(-0.02, 0.02), yc + random.uniform(-0.06, 0.06), z)
ob.rotation_euler = (math.pi / 2 + random.uniform(-0.05, 0.05), 0, random.uniform(-0.08, 0.08))
nlog += 1
# 탄수차 대차(아치바형)
for xc in ((X_TND[0] + X_TND[1]) / 2, (X_TND[2] + X_TND[3]) / 2):
for s in (1, -1):
bar('TenderTruck_ArchBar', (xc - 0.85, s * 0.9, Z_TAX), (xc + 0.85, s * 0.9, Z_TAX), 0.06, 0.07, M_BLACK)
bar('TenderTruck_ArchTop', (xc - 0.85, s * 0.9, Z_TAX + 0.08), (xc - 0.3, s * 0.9, Z_TAX + 0.32), 0.06, 0.06, M_BLACK)
bar('TenderTruck_ArchTop', (xc + 0.85, s * 0.9, Z_TAX + 0.08), (xc + 0.3, s * 0.9, Z_TAX + 0.32), 0.06, 0.06, M_BLACK)
bar('TenderTruck_ArchTop', (xc - 0.3, s * 0.9, Z_TAX + 0.32), (xc + 0.3, s * 0.9, Z_TAX + 0.32), 0.06, 0.06, M_BLACK)
for dx in (-0.65, 0.65):
box('TenderTruck_Journal', (0.24, 0.16, 0.2), loc=(xc + dx, s * 0.88, Z_TAX), material=M_BLACK)
box('TenderTruck_Spring', (0.24, 0.12, 0.26), loc=(xc, s * 0.9, Z_TAX - 0.02), material=M_IRON, base=True)
box('TenderTruck_Bolster', (0.3, 1.9, 0.2), loc=(xc, 0, Z_TAX + 0.2), material=M_BLACK, base=True)
box('TenderTruck_CenterPlate', (0.5, 0.5, Z_TD - 0.25 - Z_TAX - 0.4), loc=(xc, 0, Z_TAX + 0.4),
material=M_BLACK, base=True)
# ---------------------------------------------------------------- 배관·잔부품
for s in (1, -1):
sn = 'L' if s > 0 else 'R'
# 모래관: 모래돔에서 동륜 앞으로
curve_tube(f'SandPipe_{sn}', [(0.95, s * 0.2, Z_TOP + 0.12), (0.95, s * 0.5, Z_BOIL + 0.42),
(0.95, s * 0.66, Z_BOIL + 0.05), (0.95, s * 0.66, Z_DECK - 0.1),
(0.3, s * 0.74, Z_AX - 0.2)],
radius=0.022, material=M_BRASS)
# 급수관(인젝터): 운전실 앞에서 보일러 앞쪽 체크밸브까지
curve_tube(f'FeedPipe_{sn}', [(-3.55, s * 0.6, Z_DECK + 0.08), (-1.0, s * 0.6, Z_DECK + 0.08),
(0.5, s * 0.58, Z_BOIL - 0.25), (1.4, s * 0.53, Z_BOIL - 0.32)],
radius=0.03, material=M_BRASS)
cylinder(f'CheckValve_{sn}', 0.06, 0.14, loc=(1.42, s * 0.56, Z_BOIL - 0.36), material=M_BRASS, seg=16)
# 역전기 연결봉(운전실 → 가이드 요크)
bar(f'ReachRod_{sn}', (-3.6, s * 1.08, Z_DECK - 0.15), (0.9, s * 1.08, Z_AX + 0.38), 0.035, 0.05, M_STEEL)
# 증기관(연실 → 증기실)
curve_tube(f'SteamPipe_{sn}', [(2.33, s * 0.55, Z_BOIL - 0.3), (2.33, s * 0.82, Z_BOIL - 0.62),
(2.33, s * 0.95, Z_AX + 0.62)], radius=0.07, material=M_BLACK)
# 연실 앞 테두리
torus('Smokebox_FrontRing', R=0.66, r=0.03, loc=(3.15, 0, Z_BOIL), rot=(0, math.pi / 2, 0), material=M_BRASS, seg=48, rseg=8)
# 탄수차 핸드브레이크
cylinder('Tender_BrakeStaff', 0.025, 1.2, loc=(-11.3, 0.95, Z_TANK - 0.2), material=M_IRON, seg=10)
torus('Tender_BrakeWheel', R=0.2, r=0.02, loc=(-11.3, 0.95, Z_TANK + 1.0), material=M_IRON, seg=24, rseg=6)
# 운전실 뒤 처마 아래 등

다른 모형