
끌어서 회전, 휠·두 손가락으로 확대
이 모형에 대하여
아폴로 달 착륙선(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 randomrandom.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 obdef 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 obfoil_body()# 하강단 정면의 성조기와 'UNITED STATES' 표기fl = MB()FY = -H - 0.04fx, fz, fw, fh = -0.72, 2.95, 0.58, 0.36fl.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 = -outs0, s1 = 0.27, 1.03for 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.36cab = 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].yfor 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')






