
끌어서 회전, 휠·두 손가락으로 확대
이 모형에 대하여
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 randomfrom 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.435Y_WHEEL = 0.75 # 답면 중심W_WHEEL = 0.14R_DRV = 0.76 # 동륜 반지름 (지름 60인치)Z_AX = ZR + R_DRV # 동륜 축 높이X_DRV = (-0.6, -2.8) # 앞·뒤 동륜R_TRK = 0.42 # 대차 바퀴Z_TAX = ZR + R_TRKX_TRK = (1.55, 3.15)X_TND = (-7.0, -8.3, -9.6, -10.9)CRANK = 0.32Z_BOIL = 2.62 # 보일러 중심R_BOIL = 0.62Z_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 obdef 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 obdef 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 = Truedriver.hide_viewport = Truetruckw.hide_render = Truetruckw.hide_viewport = Truedriver['atelier_cutter'] = Truetruckw['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.0for 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_BOILlathe('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.55Z_SBT = Z_BOIL + 0.69lathe('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.25Z_LMP = Z_SBT - 0.02box('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.45box('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.75Z_PTOP = 1.02Z_PBOT = ZR + 0.08HALF = 1.25def 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.32Z_EAVE = 3.95cab_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.55Z_TD = 1.62LEN_T = XT0 - XT1box('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.75box('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 = 0x_lo, x_hi = -6.45, -10.35for 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)# 운전실 뒤 처마 아래 등






