본문으로 건너뛰기
3D 아틀리에
아폴로 달 착륙선 3D 모형

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

이 모형에 대하여

아폴로 달 착륙선(LM)은 미국 그러먼사가 NASA의 아폴로 계획을 위해 만든 유인 착륙선이다. 대기가 없는 달에서만 쓰도록 설계되어 공기역학적 외형이 필요 없었고, 그 결과 각진 면과 드러난 탱크, 다리가 그대로 보이는 독특한 모습이 되었다. 1969년 7월 20일 아폴로 11호의 착륙선 '이글'이 고요의 바다에 내려앉았고, 1972년 아폴로 17호까지 여섯 차례 달 착륙에 쓰였다.

착륙선은 두 단으로 나뉜다. 아래의 하강단은 금색 캡톤 단열재로 감싼 팔각형 몸체에 착륙 엔진과 네 개의 접이식 다리를 갖추고, 위의 상승단은 두 사람이 타는 승무원실과 귀환용 엔진을 품는다. 달을 떠날 때는 하강단을 발사대로 삼아 상승단만 이륙했다.

이 모델은 다리를 펼친 착륙 상태를 표현했다. 구겨진 금박 단열재, 정면의 삼각 창과 해치, 앞 다리의 사다리와 현관, 네 모서리의 반동 추진기(RCS) 묶음, 좌우 크기가 다른 추진제 탱크 돌출부, 도킹 터널과 접시 안테나 같은 특징을 담았다.

만든 방법

하강단은 팔각형 각 면을 bmesh 격자로 만들고 내부 꼭짓점을 법선 방향으로 무작위(시드 고정) 변위시켜 구겨진 포일을 형상으로 표현했다. 착륙 다리는 두 점 사이에 원기둥을 놓는 막대 헬퍼로 주 스트럿·보조 스트럿·전개 트러스를 만들고, 발판은 회전체로 만든 뒤 한 다리를 메시 공유 복제로 90°씩 돌려 배치했다. 상승단은 육각 단면 네 개를 Y축으로 이어 붙인 로프트로 각진 승무원실을 만들었고, RCS 묶음은 하나를 만든 뒤 축 반전 복제로 네 모서리에 놓았다. 실제 상승단의 복잡한 다면체 외피와 패널 구획, 하강단 내부 구조, 착지 감지 프로브는 단순화하거나 생략했다. 치수는 공개된 전체 높이와 다리 폭에 맞춘 근사치다.

build.py

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

# 아폴로 달 착륙선(Apollo Lunar Module, LM) — 1969년 아폴로 11호 '이글'형.
# 하강단: 모서리를 깎은 팔각기둥에 bmesh 격자를 만들고 꼭짓점을 법선 방향으로 무작위(시드 고정)로 밀어
# 구겨진 금박 단열재(캡톤 포일)를 형상으로 표현했다. 착륙 다리 4개는 막대 헬퍼로 만든 주·보조 스트럿,
# 회전체(lathe) 접시형 발판으로 만들고, 한 개를 만든 뒤 메시를 공유하는 복제로 90°씩 돌려 배치했다.
# 상승단: 육각 단면 네 개를 Y축으로 이어 붙인(loft) 각진 승무원실에 삼각 창, 정면 해치, 좌우 비대칭 추진제 탱크,
# 네 모서리 RCS 추력기 묶음(노즐 4개씩), 도킹 터널, 랑데부 레이더·S밴드 접시 안테나를 더했다.
# 단위 m, 실제 치수 근사: 전체 높이 약 7m, 발판 대각 폭 약 9.4m. 정면(해치·사다리)은 -Y 쪽.
from atelier import *
import random
random.seed(1969)
SHOT = {"azimuth": -58, "elevation": 15, "lens": 50, "light": "day", "background": "#1a1f2c"}
# ---------------------------------------------------------------- 재질
M_GOLD = mat('Foil_Kapton_Amber', '#c08a2e', rough=0.32, metal=0.85)
M_GOLD2 = mat('Foil_Kapton_Light', '#d6b04e', rough=0.28, metal=0.85)
M_BLACK = mat('Thermal_Blanket_Black', '#262628', rough=0.65, metal=0.0)
M_SKIN = mat('Ascent_Skin_Aluminum', '#8e908c', rough=0.5, metal=0.55)
M_DARK = mat('Ascent_Panel_Dark', '#3b3c3f', rough=0.55, metal=0.2)
M_SILVER = mat('Strut_Silver', '#b8bbbe', rough=0.3, metal=0.9)
M_NOZZLE = mat('Engine_Nozzle', '#4a443d', rough=0.5, metal=0.6)
M_GLASS = mat('Window_Glass', '#2b4a5c', rough=0.05, metal=0.6)
M_WHITE = mat('Antenna_Paint', '#d8d4c8', rough=0.45, metal=0.0)
M_MYLAR = mat('Foil_Mylar_Silver', '#c4c3bc', rough=0.25, metal=0.9)
M_RED = mat('Flag_Red', '#a3232e', rough=0.6)
M_FWHITE = mat('Flag_White', '#e4e1d8', rough=0.6)
M_BLUE = mat('Flag_Blue', '#24305e', rough=0.6)
# ---------------------------------------------------------------- 메시 빌더(여러 재질 슬롯을 가진 한 오브젝트)
class MB:
def __init__(self):
self.bm = bmesh.new()
self.mats = []
def mi(self, m):
if m not in self.mats:
self.mats.append(m)
return self.mats.index(m)
def _paint(self, verts, m):
idx = self.mi(m)
fs = set()
for v in verts:
fs.update(v.link_faces)
for f in fs:
f.material_index = idx
나머지 312줄 펼치기
def rod(self, p0, p1, r, m, seg=12, r1=None):
p0, p1 = Vector(p0), Vector(p1)
d = p1 - p0
L = d.length
rot = Vector((0, 0, 1)).rotation_difference(d.normalized()).to_matrix().to_4x4()
M = Matrix.Translation(p0) @ rot @ Matrix.Translation((0, 0, L / 2))
res = bmesh.ops.create_cone(self.bm, cap_ends=True, segments=seg, radius1=r,
radius2=r if r1 is None else r1, depth=L, matrix=M)
self._paint(res['verts'], m)
def box(self, center, size, m, rotz=0.0):
M = Matrix.Translation(Vector(center)) @ Matrix.Rotation(rotz, 4, 'Z') @ Matrix.Diagonal((*size, 1))
res = bmesh.ops.create_cube(self.bm, size=1.0, matrix=M)
self._paint(res['verts'], m)
def ball(self, c, r, m, seg=12, rings=8, scale=(1, 1, 1)):
M = Matrix.Translation(Vector(c)) @ Matrix.Diagonal((*scale, 1))
res = bmesh.ops.create_uvsphere(self.bm, u_segments=seg, v_segments=rings, radius=r, matrix=M)
self._paint(res['verts'], m)
def lathe(self, profile, m, M=Matrix(), seg=32):
rings = []
for i in range(seg):
a = 2 * math.pi * i / seg
rings.append([self.bm.verts.new(M @ Vector((r * math.cos(a), r * math.sin(a), z))) for r, z in profile])
new = []
for i in range(seg):
r0, r1 = rings[i], rings[(i + 1) % seg]
for j in range(len(profile) - 1):
new.append(self.bm.faces.new((r0[j], r1[j], r1[j + 1], r0[j + 1])))
idx = self.mi(m)
for f in new:
f.material_index = idx
def poly(self, pts, m):
f = self.bm.faces.new([self.bm.verts.new(Vector(p)) for p in pts])
f.material_index = self.mi(m)
return f
def build(self, name, smooth_shade=False):
bmesh.ops.remove_doubles(self.bm, verts=self.bm.verts, dist=1e-6)
bmesh.ops.recalc_face_normals(self.bm, faces=self.bm.faces)
me = bpy.data.meshes.new(name)
self.bm.to_mesh(me)
self.bm.free()
for m in self.mats:
me.materials.append(m)
if smooth_shade:
for p in me.polygons:
p.use_smooth = True
ob = bpy.data.objects.new(name, me)
link(ob)
return ob
def lerp(a, b, t):
return Vector(a).lerp(Vector(b), t)
# ================================================================ 하강단(Descent Stage)
Z0, Z1 = 1.72, 3.48 # 하강단 몸체 바닥/윗면 높이
H, C = 2.11, 1.0 # 팔각형 반폭, 모서리 깎은 길이
OCT = [(H - C, -H), (H, -H + C), (H, H - C), (H - C, H), (-H + C, H), (-H, H - C), (-H, -H + C), (-H + C, -H)]
def foil_body():
mb = MB()
bm = mb.bm
n = len(OCT)
for i in range(n):
a, b = Vector((*OCT[i], 0)), Vector((*OCT[(i + 1) % n], 0))
e = b - a
nrm = Vector((e.y, -e.x, 0)).normalized()
main = (i % 2 == 1)
nu = 40 if main else 26
nv = 30
grid = []
for jv in range(nv + 1):
row = []
for ju in range(nu + 1):
u, v = ju / nu, jv / nv
p = a.lerp(b, u)
p.z = Z0 + (Z1 - Z0) * v
if 0 < ju < nu and 0 < jv < nv:
p += nrm * random.uniform(-0.006, 0.026)
row.append(bm.verts.new(p))
grid.append(row)
for jv in range(nv):
v = (jv + 0.5) / nv
if main:
m = M_BLACK if v < 0.16 else M_GOLD
else:
m = M_BLACK if v > 0.58 else (M_BLACK if v < 0.16 else M_GOLD2)
idx = mb.mi(m)
for ju in range(nu):
f = bm.faces.new((grid[jv][ju], grid[jv][ju + 1], grid[jv + 1][ju + 1], grid[jv + 1][ju]))
f.material_index = idx
# 윗면(갑판)과 바닥면
mb.poly([(x, y, Z1) for x, y in OCT], M_DARK)
mb.poly([(x, y, Z0) for x, y in reversed(OCT)], M_BLACK)
ob = mb.build('DescentStage_FoilBody')
return ob
foil_body()
# 하강단 정면의 성조기와 'UNITED STATES' 표기
fl = MB()
FY = -H - 0.04
fx, fz, fw, fh = -0.72, 2.95, 0.58, 0.36
fl.box((fx, FY + 0.01, fz), (fw, 0.02, fh), M_FWHITE)
for k in range(7):
h = fh / 13
fl.box((fx, FY, fz - fh / 2 + h * (2 * k + 0.5)), (fw, 0.01, h), M_RED)
fl.box((fx - fw * 0.3, FY - 0.006, fz + fh * 0.23), (fw * 0.4, 0.01, fh * 7 / 13), M_BLUE)
fl.build('Descent_Flag')
txt = text3d('Descent_Label_UNITED_STATES', 'UNITED STATES', size=0.11, depth=0.004,
loc=(fx, FY + 0.005, 2.62), rot=(math.pi / 2, 0, 0), material=M_BLACK)
# 하강 엔진 노즐(회전체, 안쪽 목까지 보이게 두께 있는 종 모양)
eng = MB()
eng.lathe([(0.0, 1.47), (0.30, 1.47), (0.55, 1.18), (0.76, 0.97), (0.80, 0.95),
(0.82, 0.97), (0.64, 1.24), (0.50, 1.56)], M_NOZZLE, seg=48)
eng.build('DescentEngine_Nozzle', smooth_shade=True)
# 하강단 바닥 열차폐 링(노즐 주위)
hs = MB()
hs.lathe([(0.52, Z0 - 0.06), (1.25, Z0 - 0.06), (1.35, Z0 + 0.01)], M_MYLAR, seg=32)
hs.build('DescentStage_HeatShield')
# ---------------------------------------------------------------- 착륙 다리(+X 방향 하나를 만들고 회전 복제)
RF, ZF = 4.55, 0.25 # 발판 위 관절
RT, ZT = 2.25, Z1 - 0.2 # 주 스트럿 위 고정점
F = Vector((RF, 0, ZF))
T = Vector((RT, 0, ZT))
legmb = MB()
# 발판: 얕은 접시
legmb.lathe([(0.0, 0.0), (0.34, 0.0), (0.45, 0.06), (0.47, 0.15), (0.43, 0.18), (0.14, 0.23), (0.0, 0.25)],
M_GOLD2, M=Matrix.Translation((RF, 0, 0)), seg=32)
legmb.ball(F + Vector((0, 0, 0.03)), 0.09, M_SILVER)
# 주 스트럿: 아래 피스톤(은색) + 위 실린더(금박)
legmb.rod(F, lerp(F, T, 0.46), 0.065, M_SILVER)
legmb.rod(lerp(F, T, 0.42), T, 0.125, M_GOLD, seg=14)
legmb.rod(lerp(F, T, 0.40), lerp(F, T, 0.43), 0.14, M_BLACK, seg=14)
# 아웃리거(몸체 위쪽 받침)
legmb.box((2.25, 0, Z1 - 0.17), (0.38, 0.34, 0.34), M_GOLD)
# 보조 스트럿 2개와 전개 트러스
S = lerp(F, T, 0.20)
for s in (-1, 1):
base = Vector((2.09, s * 0.95, Z0 + 0.12))
legmb.rod(S, base, 0.05, M_SILVER, seg=10)
mid = lerp(S, base, 0.45)
legmb.rod(mid, Vector((2.09, s * 0.35, Z0 + 0.95)), 0.035, M_SILVER, seg=8)
legmb.ball(base, 0.07, M_SILVER, seg=10, rings=6)
legmb.ball(S, 0.08, M_SILVER, seg=10, rings=6)
leg0 = legmb.build('LandingLeg_+X')
legs = [leg0]
for k, nm in ((1, 'LandingLeg_+Y'), (2, 'LandingLeg_-X'), (3, 'LandingLeg_-Y_Front')):
legs.append(duplicate(leg0, rot=(0, 0, k * math.pi / 2), name=nm))
# ---------------------------------------------------------------- 앞 다리 사다리와 현관(porch)
FF = Vector((0, -RF, ZF))
TF = Vector((0, -RT, ZT))
lad = MB()
axis = (TF - FF).normalized()
out = Vector((0, -axis.z, axis.y)).normalized() # 스트럿 바깥 방향(위·앞)
if out.y > 0:
out = -out
s0, s1 = 0.27, 1.03
for sx in (-0.24, 0.24):
a = lerp(FF, TF, s0) + out * 0.16 + Vector((sx, 0, 0))
b = lerp(FF, TF, s1) + out * 0.16 + Vector((sx, 0, 0))
lad.rod(a, b, 0.022, M_SILVER, seg=8)
# 사다리를 스트럿에 잡아 주는 브래킷
for s in (0.35, 0.62, 0.9):
p = lerp(FF, TF, s)
lad.rod(p + out * 0.05 + Vector((sx * 0.4, 0, 0)), p + out * 0.16 + Vector((sx, 0, 0)), 0.015, M_SILVER, seg=6)
for k in range(10):
s = s0 + 0.03 + (s1 - s0 - 0.06) * k / 9
p = lerp(FF, TF, s) + out * 0.16
lad.rod(p + Vector((-0.24, 0, 0)), p + Vector((0.24, 0, 0)), 0.018, M_SILVER, seg=8)
# 현관 발판
lad.box((0, -1.95, Z1 + 0.025), (0.92, 1.30, 0.06), M_GOLD2)
for sx in (-0.46, 0.46):
pts = [Vector((sx, -2.58, Z1 + 0.05)), Vector((sx, -2.58, Z1 + 0.78)), Vector((sx, -1.45, Z1 + 0.78)),
Vector((sx, -1.38, Z1 + 0.30))]
for p, q in zip(pts[:-1], pts[1:]):
lad.rod(p, q, 0.02, M_SILVER, seg=8)
lad.build('FrontLadder_Porch')
# ---------------------------------------------------------------- 하강단 위 RCS 배기 차폐판(plume deflector)
pd = MB()
for sx in (-1, 1):
for sy in (-1, 1):
c = Vector((sx * 1.45, sy * 1.05, Z1))
pd.rod(c, c + Vector((0, 0, 0.42)), 0.03, M_SILVER, seg=8)
pd.box(c + Vector((0, 0, 0.44)), (0.36, 0.36, 0.02), M_MYLAR, rotz=math.pi / 4)
pd.build('RCS_PlumeDeflectors')
# ================================================================ 상승단(Ascent Stage)
A = Z1 - 0.01 # 상승단 바닥 높이(갑판에 살짝 묻힘)
def hexsec(y, wb, zb, wm, zm, wt, zt):
return [Vector((x, y, A + z)) for x, z in ((-wb, zb), (wb, zb), (wm, zm), (wt, zt), (-wt, zt), (-wm, zm))]
SECS = [hexsec(-1.34, 0.50, 0.10, 0.58, 1.02, 0.36, 2.02),
hexsec(-0.98, 0.95, 0.02, 1.22, 1.30, 0.82, 2.36),
hexsec(0.95, 0.95, 0.02, 1.22, 1.30, 0.82, 2.36),
hexsec(1.22, 0.85, 0.20, 1.05, 1.30, 0.70, 2.18)]
ZT_A = A + 2.36
cab = MB()
rings = [[cab.bm.verts.new(p) for p in s] for s in SECS]
for i in range(len(rings) - 1):
for j in range(6):
f = cab.bm.faces.new((rings[i][j], rings[i][(j + 1) % 6], rings[i + 1][(j + 1) % 6], rings[i + 1][j]))
f.material_index = cab.mi(M_DARK if (j == 0 or (i == 0 and j in (1, 5))) else M_SKIN)
f = cab.bm.faces.new(list(reversed(rings[0])))
f.material_index = cab.mi(M_SKIN)
f = cab.bm.faces.new(rings[-1])
f.material_index = cab.mi(M_SKIN)
cab.build('AscentStage_Cabin')
# 삼각형 창 2개(정면 위쪽, 안쪽 아래로 기운 삼각형)
win = MB()
YF = SECS[0][0].y
for sx in (-1, 1):
tri = [Vector((sx * x, YF, A + z)) for x, z in ((0.07, 1.18), (0.50, 1.08), (0.33, 1.88))]
c = sum(tri, Vector()) / 3
frame = [c.lerp(p, 1.17) + Vector((0, -0.006, 0)) for p in tri]
glass_tri = [p + Vector((0, -0.014, 0)) for p in tri]
if sx < 0:
frame.reverse()
glass_tri.reverse()
win.poly(frame, M_BLACK)
win.poly(glass_tri, M_GLASS)
win.build('Cabin_Windows')
# 정면 해치와 손잡이, 해치 위 패널
fr = MB()
fr.box((0, -1.355, A + 0.52), (0.80, 0.04, 0.80), M_SKIN)
fr.box((0, -1.38, A + 0.52), (0.70, 0.02, 0.70), M_MYLAR)
fr.rod((-0.12, -1.40, A + 0.36), (0.12, -1.40, A + 0.36), 0.015, M_SILVER, seg=8)
fr.build('Cabin_FrontHatch')
# 도킹 터널·드로그 링·윗면 도킹창
top = MB()
top.rod((0, -0.05, ZT_A - 0.02), (0, -0.05, ZT_A + 0.36), 0.42, M_SKIN, seg=32)
top.rod((0, -0.05, ZT_A + 0.36), (0, -0.05, ZT_A + 0.42), 0.46, M_SILVER, seg=32)
top.rod((0, -0.05, ZT_A + 0.40), (0, -0.05, ZT_A + 0.43), 0.30, M_DARK, seg=32)
top.box((0.45, -0.70, ZT_A + 0.01), (0.34, 0.24, 0.04), M_GLASS)
# 도킹 표적
top.rod((-0.55, 0.45, ZT_A), (-0.55, 0.45, ZT_A + 0.28), 0.02, M_SILVER, seg=8)
top.box((-0.55, 0.45, ZT_A + 0.30), (0.20, 0.03, 0.03), M_WHITE)
top.box((-0.55, 0.45, ZT_A + 0.30), (0.03, 0.03, 0.20), M_WHITE)
top.build('Cabin_DockingTunnel')
# 좌우 비대칭 추진제 탱크(산화제 좌, 연료 우) — 각진 피복
tk = MB()
tk.ball((-1.13, 0.20, A + 1.02), 0.62, M_SKIN, seg=14, rings=9, scale=(0.72, 1.0, 1.0))
tk.ball((1.10, 0.30, A + 0.98), 0.55, M_SKIN, seg=14, rings=9, scale=(0.72, 1.0, 1.0))
tk.build('Ascent_PropellantTanks')
# 뒤쪽 장비 베이
aft = MB()
aft.box((0, 1.50, A + 1.18), (2.10, 0.62, 1.20), M_DARK)
aft.box((0, 1.82, A + 1.18), (1.90, 0.04, 1.02), M_BLACK)
for sx in (-1, 1):
aft.box((sx * 0.55, 1.86, A + 1.18), (0.62, 0.04, 0.86), M_SKIN)
aft.box((0, 1.30, A + 0.42), (1.5, 0.5, 0.40), M_BLACK)
aft.build('Ascent_AftEquipmentBay')
# ---------------------------------------------------------------- RCS 추력기 묶음 4개(각 노즐 4개)
rcs0 = MB()
q = Vector((1.45, 1.05, A + 1.66))
rcs0.box(q, (0.26, 0.26, 0.30), M_GOLD2)
rcs0.box((1.22, 1.05, A + 1.66), (0.42, 0.14, 0.14), M_SKIN) # 지지대
for d in (Vector((0, 0, 1)), Vector((0, 0, -1)), Vector((1, 0, 0)), Vector((0, 1, 0))):
p0 = q + d * 0.12
rcs0.rod(p0, p0 + d * 0.06, 0.035, M_NOZZLE, seg=10)
rcs0.rod(p0 + d * 0.06, p0 + d * 0.26, 0.04, M_NOZZLE, seg=12, r1=0.075)
rcs = rcs0.build('RCS_Quad_+X+Y')
duplicate(rcs, scale=(1, -1, 1), name='RCS_Quad_+X-Y')
duplicate(rcs, scale=(-1, 1, 1), name='RCS_Quad_-X+Y')
duplicate(rcs, scale=(-1, -1, 1), name='RCS_Quad_-X-Y')
# ---------------------------------------------------------------- 안테나
ant = MB()
# 랑데부 레이더(앞쪽 위)
rr = Vector((-0.30, -0.78, ZT_A))
ant.rod(rr, rr + Vector((0, 0, 0.22)), 0.06, M_SKIN, seg=10)
ant.box(rr + Vector((0, 0, 0.26)), (0.30, 0.12, 0.12), M_GOLD)
Mrr = Matrix.Translation(rr + Vector((0, -0.12, 0.42))) @ Matrix.Rotation(math.radians(-70), 4, 'X')
ant.lathe([(0.0, 0.0), (0.12, 0.012), (0.24, 0.05), (0.30, 0.08), (0.29, 0.06), (0.0, 0.02)], M_WHITE, M=Mrr, seg=24)
ant.rod(Mrr @ Vector((0, 0, 0)), Mrr @ Vector((0, 0, 0.22)), 0.015, M_SILVER, seg=6)
# S밴드 조향 안테나(오른쪽 붐)
sb = Vector((1.00, 0.55, A + 1.95))
ant.rod(sb, sb + Vector((0.25, 0, 0.55)), 0.03, M_SILVER, seg=8)
Msb = Matrix.Translation(sb + Vector((0.32, 0, 0.62))) @ Matrix.Rotation(math.radians(35), 4, 'Y') @ Matrix.Rotation(math.radians(-25), 4, 'X')
ant.lathe([(0.0, 0.0), (0.14, 0.015), (0.27, 0.06), (0.33, 0.10), (0.32, 0.08), (0.0, 0.025)], M_WHITE, M=Msb, seg=24)
ant.rod(Msb @ Vector((0, 0, 0)), Msb @ Vector((0, 0, 0.20)), 0.012, M_SILVER, seg=6)
# VHF 안테나 2개
for base, d in (((-0.55, 0.90, ZT_A - 0.1), (-0.45, 0.35, 1.0)), ((0.55, 1.05, ZT_A - 0.15), (0.35, 0.65, 0.75))):
b = Vector(base)
ant.rod(b, b + Vector(d).normalized() * 0.75, 0.012, M_SILVER, seg=6)
ant.ball(b + Vector(d).normalized() * 0.75, 0.025, M_SILVER, seg=8, rings=5)
ant.build('Antennas')

다른 모형