본문으로 건너뛰기
3D 아틀리에
루트마스터 (런던 2층 버스) 3D 모형

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

이 모형에 대하여

루트마스터는 런던 교통국(London Transport)이 트롤리버스와 낡은 버스를 대체하려고 개발한 2층 버스다. AEC와 파크 로열이 생산을 맡아 1956년 시제차가 나왔고, 1968년까지 약 2,800대가 만들어졌다. 알루미늄 차체에 섀시 프레임을 따로 두지 않는 일체형 구조를 써서 당시 버스보다 가벼웠다.

운전사는 엔진 옆에 따로 마련된 반쪽 운전석(하프 캡)에 앉고, 승객은 차체 뒤쪽 왼편의 문 없는 승강장으로 타고 내렸다. 차장이 함께 타서 요금을 받았기 때문에 승객은 정류장이 아닌 곳에서도 손잡이 기둥을 잡고 오르내릴 수 있었다. 정규 노선 운행은 2005년에 끝났지만, 일부 차량은 이후에도 관광용 문화유산 노선에서 달렸다.

이 모델은 표준형 RM(길이 약 8.4m)을 바탕으로, 런던 교통국의 빨간 도장과 위·아래층 사이의 크림색 띠, 앞뒤가 둥근 돔 지붕, 앞쪽 오른편의 운전석과 가운데의 크롬 라디에이터 그릴, 뒤쪽의 개방형 승강장과 계단을 표현했다. 행선판에는 문화유산 노선으로 운행되었던 15번 노선을 적었다.

만든 방법

차체는 높이마다 둥근 모서리 직사각형 단면을 만들어 위아래로 잇는 로프트 방식으로 만들었고, 지붕 부분은 단면을 원호 비율로 줄여 앞뒤 돔을 얻었다. 아래층 객실과 보닛·펜더 덩어리에서 원통과 상자를 불리언으로 빼 바퀴 아치와 뒤쪽 승강장을 냈으며, 잘린 면에는 절단체의 재질(내장 크림색, 바닥 회색)이 옮겨지도록 했다. 창과 행선판, 그릴, 등화는 표면 위에 얹은 둥근 모서리 판과 회전체로 만들었고, 바퀴는 타이어·휠·허브를 회전체로 만든 뒤 링크 복제했다. 실내 좌석, 엔진, 하부 섀시의 세부, 차체 패널 이음매와 광고판은 생략하거나 단순화했다.

build.py

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

# 런던 루트마스터 2층 버스 (AEC Routemaster RM형, 1956~1968 생산)
# 차체는 "둥근 모서리 직사각형 평면"을 높이별로 쌓아 잇는 로프트(loft)로 만들었다.
# 위층은 지붕으로 갈수록 평면을 안쪽으로 줄여 앞뒤가 둥근 돔 지붕을 얻고,
# 아래층은 운전석(하프 캡)·보닛·앞 펜더 덩어리와 객실 덩어리로 나눈 뒤
# 불리언으로 바퀴 아치와 뒤쪽 개방형 승강장(오픈 플랫폼)을 잘라 냈다.
# 창·행선판·전조등·그릴은 표면에 붙인 얇은 판과 원통이며, 바퀴는 회전체(lathe)를 링크 복제했다.
# 실제 치수: 길이 약 8.38m, 너비 2.44m, 높이 약 4.38m, 축간거리 약 5.13m. 정면은 -Y.
from atelier import *
SHOT = {"azimuth": -46, "elevation": 9, "lens": 50, "light": "studio", "background": "#2b3442"}
# ---------------------------------------------------------------- 재질
RED = mat('London Transport red', '#a8161b', rough=0.32, coat=0.6)
CREAM = mat('Cream relief band', '#d8c79c', rough=0.4)
GLASS = mat('Window glass (tinted)', '#17212a', rough=0.04)
TYRE = mat('Tyre rubber', '#1d1d1f', rough=0.85)
WHEEL = mat('Wheel steel (dark grey)', '#3a3c40', rough=0.45, metal=0.4)
CHROME = mat('Chrome', '#c9ced3', rough=0.12, metal=1.0)
INTERIOR = mat('Platform interior cream', '#c2b58f', rough=0.6)
UNDER = mat('Chassis black', '#232528', rough=0.8)
BLIND = mat('Destination blind', '#121418', rough=0.3)
GOLD = mat('Gold fleetname', '#c49a45', rough=0.3, metal=0.8)
LENS = mat('Lamp lens and blind lettering', '#f1ead2', rough=0.1, emit='#fff4d8', emit_strength=0.5)
BLINDTXT = LENS
TAIL = mat('Tail lamp red', '#8e1414', rough=0.15, emit='#b01818', emit_strength=0.5)
FLOOR = mat('Platform floor (slatted grey)', '#585b5e', rough=0.75)
# ---------------------------------------------------------------- 주요 치수
L, W = 8.38, 2.44
HX = W / 2
YF, YR = -L / 2, L / 2 # 앞(-Y) / 뒤(+Y)
FRONT_AXLE = YF + 1.25
REAR_AXLE = FRONT_AXLE + 5.13
WHEEL_R = 0.50
BODY_BOT = 0.38
DECK = 2.20 # 위층 차체 시작 높이
TOP = 4.38
BULKHEAD = YF + 1.55 # 아래층 객실 앞벽
PLAT_Y = 2.95 # 승강장 시작
PLAT_X = 0.12
def rrect(hx, yf, yr, rf, rr, n=8, xo=0.0):
"""둥근 모서리 직사각형(앞 모서리 반지름 rf, 뒤 rr) 점 목록, 반시계."""
rf, rr = max(rf, 0.004), max(rr, 0.004)
pts = []
for cx, cy, r, a0 in ((hx - rr, yr - rr, rr, 0), (-hx + rr, yr - rr, rr, 90),
(-hx + rf, yf + rf, rf, 180), (hx - rf, yf + rf, rf, 270)):
나머지 285줄 펼치기
for i in range(n + 1):
a = math.radians(a0 + 90 * i / n)
pts.append((xo + cx + r * math.cos(a), cy + r * math.sin(a)))
return pts
def loft(name, levels, material, n=8, sharp=35):
"""levels=[(z, hx, yf, yr, rf, rr), ...] 를 아래에서 위로 이어 닫힌 메시를 만든다."""
bm = bmesh.new()
rings = []
for z, hx, yf, yr, rf, rr in levels:
rings.append([bm.verts.new((x, y, z)) for x, y in rrect(hx, yf, yr, rf, rr, n)])
m = len(rings[0])
for a, b in zip(rings, rings[1:]):
for i in range(m):
bm.faces.new((a[i], a[(i + 1) % m], b[(i + 1) % m], b[i]))
bm.faces.new(list(reversed(rings[0])))
bm.faces.new(rings[-1])
bmesh.ops.recalc_face_normals(bm, faces=bm.faces)
me = bpy.data.meshes.new(name)
bm.to_mesh(me)
bm.free()
ob = bpy.data.objects.new(name, me)
link(ob)
assign(ob, material)
smooth(ob)
me.set_sharp_from_angle(angle=math.radians(sharp))
return ob
def plate(name, c, u, v, w, h, r, t, material, n=5):
"""중심 c, 가로축 u, 세로축 v 위의 둥근 모서리 판(두께 t, 법선 u×v)."""
c, u, v = Vector(c), Vector(u).normalized(), Vector(v).normalized()
nrm = u.cross(v)
r = min(r, w / 2 - 1e-3, h / 2 - 1e-3)
pts2 = rrect(w / 2, -h / 2, h / 2, r, r, n)
bm = bmesh.new()
lo = [bm.verts.new(c + u * px + v * py - nrm * t / 2) for px, py in pts2]
hi = [bm.verts.new(c + u * px + v * py + nrm * t / 2) for px, py in pts2]
bm.faces.new(list(reversed(lo)))
bm.faces.new(hi)
k = len(pts2)
for i in range(k):
bm.faces.new((lo[i], lo[(i + 1) % k], hi[(i + 1) % k], hi[i]))
bmesh.ops.recalc_face_normals(bm, faces=bm.faces)
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)
# 면 방향 별 (가로축 u, 세로축 v) — 법선 = u×v 가 바깥을 향한다
NEAR = ((0, 1, 0), (0, 0, 1)) # +X (승강장 쪽, 영국 기준 좌측/보도 쪽)
OFF = ((0, -1, 0), (0, 0, 1)) # -X (운전석 쪽)
FRONT = ((1, 0, 0), (0, 0, 1)) # -Y
REAR = ((-1, 0, 0), (0, 0, 1)) # +Y
ZUP = ((1, 0, 0), (0, 1, 0))
def side_pane(name, side, y0, y1, z0, z1, x=HX, mat_=GLASS, r=0.07, t=0.016):
s = 1 if side > 0 else -1
uv = NEAR if s > 0 else OFF
return plate(name, (s * x, (y0 + y1) / 2, (z0 + z1) / 2), *uv, abs(y1 - y0), z1 - z0, r, t, mat_)
def end_pane(name, face, y, x0, x1, z0, z1, mat_=GLASS, r=0.07, t=0.016):
uv = FRONT if face < 0 else REAR
return plate(name, ((x0 + x1) / 2, y, (z0 + z1) / 2), *uv, x1 - x0, z1 - z0, r, t, mat_)
# ---------------------------------------------------------------- 위층 차체 (돔 지붕)
up_levels = [(DECK, HX, YF + 0.02, YR, 0.36, 0.55), (3.68, HX - 0.015, YF + 0.03, YR, 0.36, 0.55)]
for i in range(1, 13):
t = i / 12
s = 1 - math.sqrt(1 - t * t)
up_levels.append((3.68 + (TOP - 3.68) * t, HX - 0.015 - 0.52 * s, YF + 0.03 + 0.62 * s,
YR - 0.58 * s, 0.36 + 0.1 * s, 0.55 - 0.1 * s))
upper = loft('Upper deck body', up_levels, RED, n=10)
# 위·아래층 사이 크림색 띠
band = loft('Between-decks cream band', [(DECK + 0.015, HX + 0.008, YF + 0.012, YR + 0.008, 0.37, 0.56),
(DECK + 0.125, HX + 0.008, YF + 0.012, YR + 0.008, 0.37, 0.56)], CREAM, n=10)
# ---------------------------------------------------------------- 아래층 객실
lower = loft('Lower saloon body', [(BODY_BOT, HX - 0.03, BULKHEAD, YR - 0.02, 0.05, 0.53),
(BODY_BOT + 0.1, HX, BULKHEAD, YR, 0.05, 0.55),
(DECK + 0.05, HX, BULKHEAD, YR, 0.05, 0.55)], RED, n=10)
# 앞부분: 보닛·펜더·운전석 아랫단
nose = loft('Bonnet and front wings', [(BODY_BOT, HX - 0.03, YF + 0.04, BULKHEAD + 0.05, 0.2, 0.02),
(BODY_BOT + 0.1, HX, YF + 0.02, BULKHEAD + 0.05, 0.22, 0.02),
(1.24, HX, YF + 0.02, BULKHEAD + 0.05, 0.22, 0.02),
(1.30, HX - 0.04, YF + 0.06, BULKHEAD + 0.05, 0.2, 0.02)], RED, n=8)
# 하프 캡 운전석 (오른쪽 = -X)
CAB_IN = -0.30
cab = box('Half cab', (HX + CAB_IN + 0.0, BULKHEAD - YF + 0.03, DECK + 0.05 - 1.26),
((-HX + CAB_IN) / 2, (YF + 0.02 + BULKHEAD + 0.05) / 2, (1.26 + DECK + 0.05) / 2), material=RED)
bevel(cab, 0.06, 3)
# 바퀴 아치와 승강장 절단
ARCH = UNDER
cutters = []
for nm, y in (('front', FRONT_AXLE), ('rear', REAR_AXLE)):
c = cylinder(f'Cutter wheel arch {nm}', r=0.63, h=3.0, loc=(0, y, WHEEL_R), rot=(0, math.pi / 2, 0),
base=False, seg=48, material=ARCH)
cutters.append(c)
plat_cut = box('Cutter rear platform', (2.0, 2.0, DECK + 0.1 - 0.50),
(PLAT_X + 1.0, PLAT_Y + 1.0, (0.50 + DECK + 0.1) / 2), material=INTERIOR)
for ob, cs in ((lower, cutters + [plat_cut]), (nose, cutters[:1])):
for c in cs:
boolean(ob, c)
ob.modifiers[-1].material_mode = 'TRANSFER'
apply_all(lower)
apply_all(nose)
for c in cutters + [plat_cut]:
bpy.data.objects.remove(c, do_unlink=True)
# 승강장 바닥면은 회색으로
for p in lower.data.polygons:
if p.material_index == lower.data.materials.find('Platform interior cream') and p.normal.z > 0.9:
if 'Platform floor (slatted grey)' not in lower.data.materials:
lower.data.materials.append(FLOOR)
p.material_index = lower.data.materials.find('Platform floor (slatted grey)')
# 섀시 하부
box('Chassis underframe', (1.2, 7.4, 0.2), (0, 0.1, 0.31), material=UNDER)
# ---------------------------------------------------------------- 창문
UP_Z0, UP_Z1 = 2.76, 3.60
LO_Z0, LO_Z1 = 1.44, 2.12
bar_src = None
# 위층 옆창 6칸 (양쪽), 위쪽 1/3 은 내림창 가로대
y_a, y_b = YF + 0.40, YR - 0.58
pitch = (y_b - y_a) / 6
for side in (1, -1):
tag = 'nearside' if side > 0 else 'offside'
for i in range(6):
y0 = y_a + i * pitch + 0.07
y1 = y_a + (i + 1) * pitch - 0.07
side_pane(f'Upper window {tag} {i + 1}', side, y0, y1, UP_Z0, UP_Z1)
side_pane(f'Upper window drop-bar {tag} {i + 1}', side, y0 + 0.02, y1 - 0.02, 3.34, 3.37,
x=HX + 0.01, mat_=CHROME, r=0.01, t=0.012)
# 위층 앞창 2장, 뒤 비상창
for i, (x0, x1) in enumerate(((-0.86, -0.045), (0.045, 0.86))):
end_pane(f'Upper front window {i + 1}', -1, YF + 0.03, x0, x1, UP_Z0, UP_Z1, r=0.09)
end_pane('Upper rear emergency window', 1, YR, -0.6, 0.6, 2.82, 3.46, r=0.09)
# 아래층 옆창: 보도 쪽 4칸, 운전석 쪽 4칸 + 계단창
la, lb = BULKHEAD + 0.12, PLAT_Y - 0.08
lp = (lb - la) / 4
for side in (1, -1):
tag = 'nearside' if side > 0 else 'offside'
for i in range(4):
y0, y1 = la + i * lp + 0.06, la + (i + 1) * lp - 0.06
side_pane(f'Lower window {tag} {i + 1}', side, y0, y1, LO_Z0, LO_Z1)
side_pane(f'Lower window drop-bar {tag} {i + 1}', side, y0 + 0.02, y1 - 0.02, 1.90, 1.93,
x=HX + 0.01, mat_=CHROME, r=0.01, t=0.012)
side_pane('Lower window offside staircase', -1, PLAT_Y + 0.06, YR - 0.62, LO_Z0 + 0.12, LO_Z1)
end_pane('Lower rear window', 1, YR, -0.64, -0.02, 1.50, 2.10)
# 운전석 창
end_pane('Cab windscreen', -1, YF + 0.02, -HX + 0.08, CAB_IN - 0.07, 1.46, 2.12, r=0.05)
side_pane('Cab offside window', -1, YF + 0.20, BULKHEAD - 0.25, 1.46, 2.12)
side_pane('Cab nearside window', 1, YF + 0.20, BULKHEAD - 0.12, 1.46, 2.12, x=CAB_IN)
# 객실 앞벽 창(보닛 위로 보이는)
end_pane('Lower saloon front bulkhead window', -1, BULKHEAD, CAB_IN + 0.12, HX - 0.14, 1.46, 2.10)
# ---------------------------------------------------------------- 행선판
end_pane('Front destination blind box', -1, YF + 0.02, -0.82, 0.40, 2.30, 2.62, mat_=BLIND, r=0.03, t=0.05)
end_pane('Front route number box', -1, YF + 0.02, 0.48, 0.86, 2.30, 2.62, mat_=BLIND, r=0.03, t=0.05)
text3d('Front blind text', 'TRAFALGAR SQUARE', 0.105, 0.003, (-0.21, YF - 0.008, 2.425), (math.pi / 2, 0, 0), BLINDTXT)
text3d('Front route number', '15', 0.22, 0.003, (0.67, YF - 0.008, 2.385), (math.pi / 2, 0, 0), BLINDTXT)
side_pane('Side route blind box', 1, BULKHEAD + 0.15, BULKHEAD + 1.25, 2.38, 2.62, x=HX + 0.005, mat_=BLIND, r=0.03, t=0.03)
text3d('Side blind text', '15 ALDWYCH', 0.12, 0.003, (HX + 0.024, BULKHEAD + 0.70, 2.455), (math.pi / 2, 0, math.pi / 2), BLINDTXT)
end_pane('Rear route number box', 1, YR, 0.15, 0.55, 2.38, 2.62, mat_=BLIND, r=0.03, t=0.04)
text3d('Rear route number', '15', 0.17, 0.003, (0.35, YR + 0.024, 2.43), (math.pi / 2, 0, math.pi), BLINDTXT)
# 금박 사명 "LONDON TRANSPORT"
for side in (1, -1):
text3d(f'Fleetname {"nearside" if side > 0 else "offside"}', 'LONDON TRANSPORT', 0.18, 0.002,
(side * (HX + 0.004), 0.0, 1.05), (math.pi / 2, 0, side * math.pi / 2), GOLD)
# ---------------------------------------------------------------- 앞면: 그릴·전조등·번호판
GX0, GX1 = -0.20, 0.50
end_pane('Radiator grille surround', -1, YF + 0.02, GX0, GX1, 0.52, 1.20, mat_=CHROME, r=0.08, t=0.03)
end_pane('Radiator grille core', -1, YF + 0.005, GX0 + 0.05, GX1 - 0.05, 0.57, 1.15, mat_=UNDER, r=0.05, t=0.03)
slat = box('Grille slat', (0.012, 0.02, 0.56), (GX0 + 0.08, YF - 0.012, 0.86), material=CHROME)
for i in range(1, 14):
duplicate(slat, loc=(GX0 + 0.08 + i * 0.041, YF - 0.012, 0.86), name='Grille slat')
lathe('Grille roundel badge', [(0, 0), (0.07, 0), (0.075, 0.012), (0, 0.016)], loc=(0.15, YF - 0.005, 1.27),
rot=(math.pi / 2, 0, 0), material=CHROME, seg=32)
for nm, x in (('nearside', 0.92), ('offside', -0.92)):
lathe(f'Headlamp rim {nm}', [(0, 0), (0.115, 0), (0.12, 0.03), (0.105, 0.05), (0, 0.05)],
loc=(x, YF + 0.05, 0.86), rot=(math.pi / 2, 0, 0), material=CHROME, seg=32)
lathe(f'Headlamp lens {nm}', [(0, 0), (0.096, 0), (0.09, 0.012), (0.05, 0.022), (0, 0.025)],
loc=(x, YF + 0.003, 0.86), rot=(math.pi / 2, 0, 0), material=LENS, seg=32)
lathe(f'Sidelamp {nm}', [(0, 0), (0.04, 0), (0.035, 0.02), (0, 0.025)],
loc=(x, YF + 0.04, 1.08), rot=(math.pi / 2, 0, 0), material=LENS, seg=20)
end_pane('Front number plate', -1, YF + 0.03, -0.62, -0.24, 0.48, 0.60, mat_=BLIND, r=0.01, t=0.02)
text3d('Front registration', 'WLT 615', 0.07, 0.002, (-0.43, YF + 0.012, 0.515), (math.pi / 2, 0, 0), BLINDTXT)
box('Front valance', (2.2, 0.05, 0.08), (0, YF + 0.06, 0.40), material=UNDER)
# 뒷면 등화·번호판
for nm, x in (('offside', -0.85),):
plate('Tail lamp offside', (x, YR - 0.01, 1.05), *REAR, 0.14, 0.22, 0.05, 0.04, TAIL)
end_pane('Rear number plate', 1, YR + 0.005, -0.62, -0.20, 0.62, 0.74, mat_=BLIND, r=0.01, t=0.02)
text3d('Rear registration', 'WLT 615', 0.07, 0.002, (-0.41, YR + 0.017, 0.655), (math.pi / 2, 0, math.pi), BLINDTXT)
plate('Tail lamp platform', (PLAT_X + 0.08, YR - 0.20, 1.05), *NEAR, 0.10, 0.18, 0.04, 0.03, TAIL)
# ---------------------------------------------------------------- 승강장
plate('Platform ceiling', (PLAT_X + 0.55, PLAT_Y + 0.62, DECK - 0.006), *ZUP, 1.08, 1.22, 0.02, 0.008, INTERIOR)
plate('Saloon doorway', (PLAT_X + 0.62, PLAT_Y + 0.006, 1.26), *REAR, 0.78, 1.42, 0.06, 0.008, UNDER)
for k in range(4):
top = 0.50 + 0.21 * (k + 1)
depth = (4 - k) * 0.21
box(f'Staircase step {k + 1}', (depth, 0.62, top - 0.49), (PLAT_X - 0.01 + depth / 2, YR - 0.42, (top + 0.49) / 2),
material=FLOOR)
box(f'Staircase nosing {k + 1}', (depth, 0.63, 0.02), (PLAT_X - 0.01 + depth / 2, YR - 0.42, top + 0.005),
material=CHROME)
cylinder('Platform grab pole', r=0.022, h=DECK - 0.5, loc=(1.0, 3.93, 0.5), material=CHROME, seg=16)
curve_tube('Platform grab rail', [(1.0, 3.93, 1.75), (PLAT_X + 0.03, 3.93, 1.75)], radius=0.016, material=CHROME)
box('Platform step edge', (0.56, 0.04, 0.03), (PLAT_X + 0.29, YR - 0.03, 0.495), material=CHROME)
# ---------------------------------------------------------------- 백미러
for nm, (x, y) in (('offside', (-HX - 0.22, YF + 0.25)), ('nearside', (HX + 0.25, BULKHEAD - 0.6))):
s = 1 if x > 0 else -1
base = (s * HX, y + 0.15, 2.0) if s < 0 else (s * (HX - 0.01), BULKHEAD - 0.02, 1.95)
curve_tube(f'Mirror arm {nm}', [base, (x, y, 2.05), (x, y, 1.92)], radius=0.012, material=CHROME)
plate(f'Mirror {nm}', (x, y - 0.01, 1.84), *FRONT, 0.16, 0.24, 0.06, 0.03, UNDER)
# ---------------------------------------------------------------- 바퀴
def make_wheel(name, width):
w = width / 2
tyre = lathe(f'{name} tyre', [(0.33, -w), (0.44, -w), (0.485, -w + 0.03), (WHEEL_R, -w + 0.07),
(WHEEL_R, w - 0.07), (0.485, w - 0.03), (0.44, w), (0.33, w), (0.33, -w)],
material=TYRE, seg=48)
disc = lathe(f'{name} disc', [(0, -w + 0.06), (0.34, -w + 0.06), (0.34, w - 0.02), (0.30, w - 0.01),
(0.22, w - 0.05), (0, w - 0.05)], material=WHEEL, seg=36)
hub = lathe(f'{name} hub', [(0, 0), (0.20, 0), (0.20, 0.025), (0.17, 0.05), (0.12, 0.09), (0.05, 0.11), (0, 0.112)],
loc=(0, 0, w - 0.06), material=CHROME, seg=36)
nut = cylinder(f'{name} nut', r=0.018, h=0.03, loc=(0, 0, 0), material=CHROME, seg=8)
nuts = [nut]
for i in range(10):
a = 2 * math.pi * i / 10
n_ = nut if i == 0 else duplicate(nut)
n_.location = (0.25 * math.cos(a), 0.25 * math.sin(a), w - 0.055)
nuts.append(n_)
whl = join([tyre, disc, hub] + nuts[1:], name)
smooth(whl)
whl.data.set_sharp_from_angle(angle=math.radians(40))
return whl
front_wheel = make_wheel('Front wheel nearside', 0.30)
rear_wheel = make_wheel('Rear dual wheel nearside', 0.58)
for whl, y, w in ((front_wheel, FRONT_AXLE, 0.30), (rear_wheel, REAR_AXLE, 0.58)):
xo = HX - 0.035 - w / 2
whl.location = (xo, y, WHEEL_R)
whl.rotation_euler = (0, math.pi / 2, 0)
duplicate(whl, loc=(-xo, y, WHEEL_R), rot=(0, -math.pi / 2, 0),
name=whl.name.replace('nearside', 'offside'))
cylinder(f'Axle {"front" if w < 0.4 else "rear"}', r=0.07, h=2 * xo - 0.1, loc=(0, y, WHEEL_R),
rot=(0, math.pi / 2, 0), base=False, material=UNDER, seg=16)
# 위층 앞창 환기창 가로대와 지붕 앞 환기구
for i, (x0, x1) in enumerate(((-0.86, -0.045), (0.045, 0.86))):
end_pane(f'Upper front window vent bar {i + 1}', -1, YF + 0.02, x0 + 0.03, x1 - 0.03, 3.38, 3.41,
mat_=CHROME, r=0.01, t=0.012)
end_pane('Front roof ventilator', -1, YF + 0.03, -0.55, 0.55, 3.64, 3.72, mat_=UNDER, r=0.03, t=0.02)
# 방향지시등(호박색)과 운전석 문 손잡이
AMBER = mat('Indicator amber', '#d98a1c', rough=0.2, emit='#e0901f', emit_strength=0.4)
for nm, x in (('nearside', 0.92), ('offside', -0.92)):
end_pane(f'Front indicator {nm}', -1, YF + 0.03, x - 0.06, x + 0.06, 1.13, 1.20, mat_=AMBER, r=0.03, t=0.02)
for side in (1, -1):
tag = 'nearside' if side > 0 else 'offside'
side_pane(f'Side indicator front {tag}', side, BULKHEAD + 0.02, BULKHEAD + 0.12, 1.10, 1.17, x=HX + 0.004,
mat_=AMBER, r=0.03, t=0.012)
end_pane('Rear indicator offside', 1, YR - 0.01, -0.92, -0.78, 1.24, 1.32, mat_=AMBER, r=0.03, t=0.04)
side_pane('Cab door handle', -1, BULKHEAD - 0.32, BULKHEAD - 0.18, 1.32, 1.35, x=HX + 0.01, mat_=CHROME, r=0.01, t=0.02)
side_pane('Cab door seam front', -1, YF + 0.16, YF + 0.175, 1.20, 2.14, x=HX + 0.002, mat_=UNDER, r=0.003, t=0.004)
side_pane('Cab door seam rear', -1, BULKHEAD - 0.115, BULKHEAD - 0.10, 1.20, 2.14, x=HX + 0.002, mat_=UNDER, r=0.003, t=0.004)

다른 모형