본문으로 건너뛰기
3D 아틀리에
폭스바겐 비틀 (1963) 3D 모형

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

이 모형에 대하여

폭스바겐 타입 1, 흔히 비틀이라 불리는 이 차는 페르디난트 포르셰의 설계를 바탕으로 1938년 처음 만들어졌고, 전후 독일 볼프스부르크 공장에서 대량 생산되었다. 공랭식 수평대향 4기통 엔진을 차체 뒤에 얹고 앞에 짐칸을 둔 구조로, 1972년 포드 모델 T의 누적 생산 기록을 넘어섰다.

1963년형은 작은 직사각형 뒷유리와 칼날형 범퍼, 뒤 엔진 덮개 위의 냉각 루버를 갖춘 시기의 비틀이다. 차체는 앞뒤로 둥글게 내려앉는 하나의 아치를 이루고, 네 개의 펜더는 차체와 따로 만들어 볼트로 붙였으며, 앞뒤 펜더 사이를 고무 발판이 잇는다.

이 모델은 루비 레드 도장에 크롬 범퍼와 화이트월 타이어를 단 수출형 모습을 표현했다. 펜더 위의 원형 전조등과 방향지시등, 후미등, 보닛의 크롬 몰딩, 삼각 환기창, 쌍둥이 배기관까지 비틀을 알아보게 하는 요소를 차례로 얹었다.

만든 방법

차체와 펜더는 길이 방향으로 초타원 단면을 이어 붙인 로프트 메시로, 단면의 높이·폭·지수를 단조 3차 보간(PCHIP)으로 이어 매끄러운 곡면을 얻었다. 같은 초타원 식을 음함수로 써서 표면에 광선을 쏘아 창유리·크롬 테·문틈·몰딩·루버를 표면에서 몇 밀리미터 띄워 정확히 얹었다. 바퀴 자리는 원통 불리언으로 펜더를 파냈고, 타이어·화이트월·휠·허브캡은 회전체이며 네 바퀴는 메시를 공유한다. 범퍼는 수평 경로를 따라 칼날형 단면을 쓸어 만들었다. 실내, 엔진 내부, 서스펜션 세부와 문자 엠블럼은 생략했고 유리는 불투명한 짙은 색으로 단순화했다.

build.py

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

# 폭스바겐 비틀 (1963) — 둥근 등껍질 차체와 따로 붙은 네 개의 펜더, 발판을 가진 클래식 비틀을 bpy 코드로만 만든 모델.
# 차체와 펜더는 길이 방향(Y)으로 단면을 이어 붙인 로프트 메시다. 단면은 위·아래 지수가 다른
# 초타원(superellipse)이고, 높이·폭·지수를 단조 3차 보간(PCHIP)으로 매끄럽게 이었다.
# 같은 초타원 식을 음함수로도 써서, 창유리·크롬 테·문틈·몰딩은 차체 표면에 광선을 쏘아
# 정확히 얹었다(표면에서 몇 mm 띄워 깜빡임이 없다). 바퀴 자리는 원통 불리언으로 펜더를 파냈고,
# 타이어·화이트월·휠·허브캡은 회전체(lathe)이며 네 바퀴는 메시를 공유하는 링크 복제본이다.
# 단위는 미터, 앞이 -Y. 전장 4.07 · 전폭 1.54 · 전고 1.50 · 휠베이스 2.40.
from atelier import *
SHOT = {"azimuth": -42, "elevation": 12, "lens": 55, "light": "studio", "background": "#36404c"}
# ── 재질 ───────────────────────────────────────────────
PAINT = mat("Paint_RubyRed", "#931f24", rough=0.28, metal=0.1, coat=1.0)
CHROME = mat("Chrome", "#d6d9dd", rough=0.1, metal=1.0)
RUBBER = mat("Rubber_Black", "#1f1f21", rough=0.7)
GLASS = mat("Glass_Tinted", "#1e2a32", rough=0.10, metal=0.2, coat=0.4)
TIRE = mat("Tire_Rubber", "#242426", rough=0.88)
WHITEWALL = mat("Tire_Whitewall", "#e2dfd5", rough=0.6)
WHEEL = mat("Wheel_PearlWhite", "#d8d4c8", rough=0.35, metal=0.1)
UNDER = mat("Underbody_Dark", "#151619", rough=0.9)
LENS = mat("Headlamp_Lens", "#dfe6ea", rough=0.05, metal=0.4)
RED = mat("Taillamp_Red", "#a3161b", rough=0.2)
AMBER = mat("Lamp_Amber", "#d98213", rough=0.2)
PLATE = mat("Plate_Cream", "#e6e2cf", rough=0.5)
AXLE_F, AXLE_R = -1.25, 1.15
TRACK_F, TRACK_R = 0.655, 0.675
WHEEL_R = 0.325
# ── 보간과 로프트 ────────────────────────────────────────
def pchip(xs, ys):
"""단조 3차 에르미트 보간(넘침 없음)."""
n = len(xs)
h = [xs[i + 1] - xs[i] for i in range(n - 1)]
d = [(ys[i + 1] - ys[i]) / h[i] for i in range(n - 1)]
m = [0.0] * n
m[0], m[-1] = d[0], d[-1]
for i in range(1, n - 1):
if d[i - 1] * d[i] > 0:
w1, w2 = 2 * h[i] + h[i - 1], h[i] + 2 * h[i - 1]
m[i] = (w1 + w2) / (w1 / d[i - 1] + w2 / d[i])
def f(x):
if x <= xs[0]:
return ys[0]
if x >= xs[-1]:
return ys[-1]
나머지 474줄 펼치기
lo, hi = 0, n - 1
while hi - lo > 1:
mid = (lo + hi) // 2
if xs[mid] <= x:
lo = mid
else:
hi = mid
i, hh = lo, h[lo]
t = (x - xs[i]) / hh
t2, t3 = t * t, t * t * t
return ((2 * t3 - 3 * t2 + 1) * ys[i] + (t3 - 2 * t2 + t) * hh * m[i]
+ (-2 * t3 + 3 * t2) * ys[i + 1] + (t3 - t2) * hh * m[i + 1])
return f
class Loft:
"""Y 를 따라 초타원 단면(xc, w, zm, top, bot, nu, nl)을 잇는 매끈한 덩어리."""
def __init__(self, spec, sign=1):
self.f = {k: pchip([p[0] for p in v], [p[1] for p in v]) for k, v in spec.items()}
self.y0 = max(v[0][0] for v in spec.values())
self.y1 = min(v[-1][0] for v in spec.values())
self.sign = sign
def P(self, y):
g = self.f
return (g['xc'](y) * self.sign, g['w'](y), g['zm'](y), g['top'](y), g['bot'](y), g['nu'](y), g['nl'](y))
def F(self, x, y, z):
if y <= self.y0 or y >= self.y1:
return 1.0
xc, w, zm, top, bot, nu, nl = self.P(y)
dz = z - zm
n, hg = (nu, max(top - zm, 1e-4)) if dz >= 0 else (nl, max(zm - bot, 1e-4))
return (abs(x - xc) / w) ** n + (abs(dz) / hg) ** n - 1.0
def ring(self, y, nseg):
xc, w, zm, top, bot, nu, nl = self.P(y)
out = []
for j in range(nseg):
a = 2 * math.pi * j / nseg
c, s = math.cos(a), math.sin(a)
n, hg = (nu, top - zm) if s >= 0 else (nl, zm - bot)
out.append((xc + w * math.copysign(abs(c) ** (2 / n), c), y,
zm + hg * math.copysign(abs(s) ** (2 / n), s)))
return out
def mesh(self, name, ny, nseg, material):
bm = bmesh.new()
mid, half = (self.y0 + self.y1) / 2, (self.y1 - self.y0) / 2
ys = [mid - half * math.cos(math.pi * i / ny) for i in range(ny + 1)]
rings = [[bm.verts.new(p) for p in self.ring(y, nseg)] for y in ys]
for a, b in zip(rings, rings[1:]):
for j in range(nseg):
bm.faces.new((a[j], a[(j + 1) % nseg], b[(j + 1) % nseg], b[j]))
for r in (rings[0], rings[-1]):
c = bm.verts.new(sum((v.co for v in r), Vector()) / len(r))
for j in range(nseg):
bm.faces.new((r[j], r[(j + 1) % nseg], c))
bmesh.ops.recalc_face_normals(bm, faces=bm.faces)
me = bpy.data.meshes.new(name)
bm.to_mesh(me)
bm.free()
ob = link(bpy.data.objects.new(name, me))
return smooth(assign(ob, material))
def hit(F, p0, d, tmax=1.6):
"""p0(안쪽)에서 d 방향으로 나가며 F=0 표면을 이분법으로 찾는다."""
lo, hi = 0.0, tmax
for _ in range(44):
mid = (lo + hi) / 2
if F(p0[0] + d[0] * mid, p0[1] + d[1] * mid, p0[2] + d[2] * mid) < 0:
lo = mid
else:
hi = mid
return Vector((p0[0] + d[0] * hi, p0[1] + d[1] * hi, p0[2] + d[2] * hi))
def normal_at(F, q, e=1e-4):
g = Vector((F(q.x + e, q.y, q.z) - F(q.x - e, q.y, q.z),
F(q.x, q.y + e, q.z) - F(q.x, q.y - e, q.z),
F(q.x, q.y, q.z + e) - F(q.x, q.y, q.z - e)))
return g.normalized()
def on_surf(F, mapper, a, b, offset=0.0):
p0, d = mapper(a, b)
q = hit(F, p0, d)
return q + normal_at(F, q) * offset
def patch(name, F, corners, mapper, material, res=(30, 14), sq=4.0, offset=0.006):
"""표면 위 둥근 사각 패치(창유리·테). corners=(아래앞, 아래뒤, 위앞, 위뒤) 2D 매개."""
P00, P10, P01, P11 = corners
bm = bmesh.new()
nu_, nv_ = res
grid = []
for j in range(nv_ + 1):
row = []
for i in range(nu_ + 1):
u, v = -1 + 2 * i / nu_, -1 + 2 * j / nv_
m = max(abs(u), abs(v))
if m > 1e-9:
r = (abs(u) ** sq + abs(v) ** sq) ** (1 / sq)
u, v = u * m / r, v * m / r
s, t = (u + 1) / 2, (v + 1) / 2
a = (1 - s) * (1 - t) * P00[0] + s * (1 - t) * P10[0] + (1 - s) * t * P01[0] + s * t * P11[0]
b = (1 - s) * (1 - t) * P00[1] + s * (1 - t) * P10[1] + (1 - s) * t * P01[1] + s * t * P11[1]
row.append(bm.verts.new(on_surf(F, mapper, a, b, offset)))
grid.append(row)
for j in range(nv_):
for i in range(nu_):
bm.faces.new((grid[j][i], grid[j][i + 1], grid[j + 1][i + 1], grid[j + 1][i]))
bm.normal_update()
bm.faces.ensure_lookup_table()
f0 = bm.faces[len(bm.faces) // 2]
if f0.normal.dot(normal_at(F, f0.calc_center_median())) < 0:
bmesh.ops.reverse_faces(bm, faces=bm.faces)
me = bpy.data.meshes.new(name)
bm.to_mesh(me)
bm.free()
ob = link(bpy.data.objects.new(name, me))
return smooth(assign(ob, material))
def grow(corners, e):
"""창 모서리 매개를 바깥으로 e 만큼 키운다(테 두르기)."""
(a0, b0), (a1, b1), (a2, b2), (a3, b3) = corners
s0 = -1 if a0 < a1 else 1
return ((a0 + s0 * e, b0 - e), (a1 - s0 * e, b1 - e), (a2 + s0 * e, b2 + e), (a3 - s0 * e, b3 + e))
def tube(name, pts, radius, material, seg=8, closed=False):
"""점 목록을 따라가는 메시 관(회전 최소 프레임). 몰딩·문틈·와이퍼."""
pts = [Vector(p) for p in pts]
n = len(pts)
tans = []
for i in range(n):
a = pts[(i - 1) % n] if (closed or i > 0) else pts[i]
b = pts[(i + 1) % n] if (closed or i < n - 1) else pts[i]
tans.append((b - a).normalized())
ref = Vector((0, 0, 1)) if abs(tans[0].z) < 0.9 else Vector((1, 0, 0))
nrm = (ref - tans[0] * ref.dot(tans[0])).normalized()
bm = bmesh.new()
rings = []
for p, t in zip(pts, tans):
nrm = (nrm - t * nrm.dot(t)).normalized()
bi = t.cross(nrm)
rings.append([bm.verts.new(p + (nrm * math.cos(2 * math.pi * k / seg) + bi * math.sin(2 * math.pi * k / seg)) * radius)
for k in range(seg)])
pairs = list(zip(rings, rings[1:])) + ([(rings[-1], rings[0])] if closed else [])
for r0, r1 in pairs:
for k in range(seg):
bm.faces.new((r0[k], r0[(k + 1) % seg], r1[(k + 1) % seg], r1[k]))
if not closed:
bm.faces.new(rings[0])
bm.faces.new(list(reversed(rings[-1])))
bmesh.ops.recalc_face_normals(bm, faces=bm.faces)
me = bpy.data.meshes.new(name)
bm.to_mesh(me)
bm.free()
return smooth(assign(link(bpy.data.objects.new(name, me)), material))
def grow2(c, e=0.022, eb=0.075):
"""(y, b) 매개 창의 테: y 로 e, 상대높이로 eb 만큼 키운다."""
(a0, b0), (a1, b1), (a2, b2), (a3, b3) = c
return ((a0 - e, b0 - eb), (a1 + e, b1 - eb), (a2 - e, b2 + eb), (a3 + e, b3 + eb))
def surf_tube(name, F, mapper, pts2d, radius, material, offset=0.0, closed=False):
pts = [on_surf(F, mapper, a, b, offset) for a, b in pts2d]
return tube(name, pts, radius, material, closed=closed)
def lerp_pts(a, b, n):
return [(a[0] + (b[0] - a[0]) * i / n, a[1] + (b[1] - a[1]) * i / n) for i in range(n + 1)]
def side(F_side, s):
return lambda y, z: ((0.0, y, z), (s, 0.0, 0.0))
# ── 1. 차체 본체(등껍질) ───────────────────────────────────
BODY_SPEC = {
'xc': [(-2.045, 0.0), (2.045, 0.0)],
'top': [(-2.045, 0.55), (-2.00, 0.72), (-1.93, 0.815), (-1.80, 0.885), (-1.50, 0.965), (-1.20, 1.02),
(-1.02, 1.055), (-0.95, 1.12), (-0.85, 1.25), (-0.72, 1.39), (-0.55, 1.47), (-0.25, 1.505),
(0.05, 1.50), (0.30, 1.465), (0.55, 1.38), (0.78, 1.25), (0.98, 1.12), (1.20, 1.01),
(1.45, 0.90), (1.68, 0.78), (1.85, 0.66), (1.96, 0.55), (2.02, 0.46), (2.045, 0.42)],
'bot': [(-2.045, 0.44), (-1.98, 0.38), (-1.85, 0.32), (-1.50, 0.29), (0.0, 0.28), (1.50, 0.29),
(1.85, 0.32), (1.98, 0.37), (2.045, 0.40)],
'zm': [(-2.045, 0.48), (-1.90, 0.60), (-1.50, 0.69), (-1.00, 0.78), (-0.60, 0.84), (0.0, 0.85), (0.60, 0.84),
(1.00, 0.78), (1.60, 0.67), (1.90, 0.56), (2.045, 0.41)],
'w': [(-2.045, 0.10), (-2.02, 0.27), (-1.95, 0.37), (-1.80, 0.43), (-1.50, 0.48), (-1.20, 0.545),
(-1.00, 0.61), (-0.80, 0.645), (-0.50, 0.66), (0.40, 0.66), (0.80, 0.645), (1.05, 0.61),
(1.35, 0.56), (1.65, 0.51), (1.85, 0.46), (1.97, 0.38), (2.03, 0.26), (2.045, 0.10)],
'nu': [(-2.045, 2.0), (-1.20, 2.0), (-0.90, 2.1), (-0.60, 2.25), (0.50, 2.25), (0.90, 2.1),
(1.20, 2.05), (2.045, 2.0)],
'nl': [(-2.045, 3.5), (2.045, 3.5)],
}
BODY = Loft(BODY_SPEC)
BF = BODY.F
body = BODY.mesh("Body_Shell", 170, 112, PAINT)
# ── 2. 펜더 네 개(불리언으로 바퀴 자리) ──────────────────────
def rows_to_spec(rows, nu=2.3, nl=3.0):
keys = ('xc', 'w', 'zm', 'top', 'bot')
spec = {k: [(r[0], r[i + 1]) for r in rows] for i, k in enumerate(keys)}
spec['nu'] = [(rows[0][0], nu), (rows[-1][0], nu)]
spec['nl'] = [(rows[0][0], nl), (rows[-1][0], nl)]
return spec
FRONT_FENDER = [ # y, xc, w, zm, top, bot
(-2.000, 0.36, 0.04, 0.50, 0.53, 0.47),
(-1.970, 0.42, 0.10, 0.56, 0.66, 0.43),
(-1.900, 0.48, 0.14, 0.60, 0.76, 0.40),
(-1.750, 0.54, 0.18, 0.62, 0.84, 0.38),
(-1.550, 0.580, 0.188, 0.62, 0.875, 0.36),
(-1.250, 0.592, 0.182, 0.60, 0.885, 0.33),
(-0.980, 0.600, 0.175, 0.54, 0.82, 0.31),
(-0.800, 0.605, 0.170, 0.46, 0.66, 0.30),
(-0.680, 0.610, 0.165, 0.40, 0.50, 0.30),
(-0.600, 0.610, 0.150, 0.37, 0.40, 0.30),
(-0.570, 0.610, 0.080, 0.34, 0.35, 0.31),
]
REAR_FENDER = [
(0.500, 0.620, 0.080, 0.34, 0.35, 0.31),
(0.530, 0.620, 0.150, 0.37, 0.41, 0.30),
(0.620, 0.620, 0.165, 0.42, 0.53, 0.30),
(0.780, 0.615, 0.175, 0.50, 0.72, 0.30),
(0.950, 0.610, 0.180, 0.56, 0.84, 0.31),
(1.200, 0.600, 0.180, 0.60, 0.91, 0.33),
(1.420, 0.590, 0.175, 0.62, 0.90, 0.36),
(1.620, 0.570, 0.162, 0.63, 0.86, 0.40),
(1.780, 0.530, 0.135, 0.63, 0.79, 0.45),
(1.890, 0.470, 0.085, 0.62, 0.70, 0.51),
(1.935, 0.410, 0.030, 0.60, 0.62, 0.56),
]
FENDERS = {}
for s, sn in ((1, 'R'), (-1, 'L')):
for rows, axle, tag in ((FRONT_FENDER, AXLE_F, 'Front'), (REAR_FENDER, AXLE_R, 'Rear')):
lf = Loft(rows_to_spec(rows), sign=s)
ob = lf.mesh(f"Fender_{tag}_{sn}", 90, 64, PAINT)
cut = cylinder(f"WheelWell_Cutter_{tag}_{sn}", r=0.372, h=0.70, loc=(s * 0.66, axle, 0.33),
rot=(0, math.pi / 2, 0), seg=64, base=False, material=UNDER)
boolean(ob, cut)
ob.modifiers[-1].material_mode = 'TRANSFER'
apply_all(ob)
FENDERS[(tag, sn)] = lf
# ── 3. 발판, 바닥, 차축 ───────────────────────────────────
for s, sn in ((1, 'R'), (-1, 'L')):
rb = box(f"RunningBoard_{sn}", size=(0.31, 1.19, 0.04), loc=(s * 0.62, -0.045, 0.295), base=True, material=RUBBER)
bevel(rb, 0.008, 2)
box(f"RunningBoard_Trim_{sn}", size=(0.008, 1.17, 0.018), loc=(s * 0.776, -0.045, 0.306), base=True, material=CHROME)
box("Floor_Pan", size=(0.86, 3.30, 0.12), loc=(0, 0, 0.19), base=True, material=UNDER)
box("Engine_Transaxle", size=(0.74, 0.70, 0.30), loc=(0, 1.50, 0.22), base=True, material=UNDER)
for z in (0.30, 0.40):
cylinder("Front_Axle_Tube", r=0.04, h=1.20, loc=(0, AXLE_F, z), rot=(0, math.pi / 2, 0), base=False, material=UNDER, seg=16)
cylinder("Rear_Swing_Axle", r=0.04, h=1.26, loc=(0, AXLE_R, WHEEL_R), rot=(0, math.pi / 2, 0), base=False, material=UNDER, seg=16)
box("Muffler", size=(0.90, 0.16, 0.14), loc=(0, 1.86, 0.27), base=True, material=UNDER)
for s in (1, -1):
cylinder("Tailpipe", r=0.021, h=0.24, loc=(s * 0.30, 1.95, 0.31), rot=(math.pi / 2, 0, 0), base=False, material=CHROME, seg=16)
# ── 4. 바퀴 ───────────────────────────────────────────────
tire = lathe("Tire", [(0.19, -0.066), (0.235, -0.074), (0.28, -0.076), (0.31, -0.068), (0.322, -0.045),
(0.325, 0.0), (0.322, 0.045), (0.31, 0.068), (0.28, 0.076), (0.235, 0.074), (0.19, 0.066)],
material=TIRE, seg=64)
ww = lathe("Whitewall", [(0.232, 0.072), (0.234, 0.0778), (0.282, 0.0778), (0.284, 0.073)], material=WHITEWALL, seg=64)
rim = lathe("Rim", [(0.0, -0.05), (0.19, -0.05), (0.19, 0.06), (0.198, 0.066), (0.196, 0.071), (0.184, 0.06),
(0.16, 0.042), (0.12, 0.034), (0.0, 0.034)], material=WHEEL, seg=64)
cap = lathe("Hubcap", [(0.0, 0.094), (0.03, 0.093), (0.06, 0.087), (0.085, 0.074), (0.10, 0.057),
(0.108, 0.042), (0.109, 0.037), (0.0, 0.037)], material=CHROME, seg=64)
wheel = join([tire, ww, rim, cap], "Wheel_FR")
wheel.location = (TRACK_F, AXLE_F, WHEEL_R)
wheel.rotation_euler = (0, math.pi / 2, 0)
duplicate(wheel, loc=(-TRACK_F, AXLE_F, WHEEL_R), rot=(0, -math.pi / 2, 0), name="Wheel_FL")
duplicate(wheel, loc=(TRACK_R, AXLE_R, WHEEL_R), rot=(0, math.pi / 2, 0), name="Wheel_RR")
duplicate(wheel, loc=(-TRACK_R, AXLE_R, WHEEL_R), rot=(0, -math.pi / 2, 0), name="Wheel_RL")
# ── 5. 창유리와 크롬 테 ────────────────────────────────────
def front_ray(x, z):
return (x, -0.30, z), (0.0, -1.0, 0.0)
def rear_ray(x, z):
return (x, 0.30, z), (0.0, 1.0, 0.0)
WINDSHIELD = ((-0.47, 1.07), (0.47, 1.07), (-0.41, 1.37), (0.41, 1.37))
REAR_WIN = ((-0.30, 1.18), (0.30, 1.18), (-0.25, 1.37), (0.25, 1.37))
patch("Windshield_Frame", BF, grow(WINDSHIELD, 0.022), front_ray, CHROME, res=(36, 14), sq=5, offset=0.004)
patch("Windshield_Glass", BF, WINDSHIELD, front_ray, GLASS, res=(36, 14), sq=5, offset=0.008)
patch("RearWindow_Frame", BF, grow(REAR_WIN, 0.02), rear_ray, CHROME, res=(30, 12), sq=4, offset=0.004)
patch("RearWindow_Glass", BF, REAR_WIN, rear_ray, GLASS, res=(30, 12), sq=4, offset=0.008)
# 옆창은 (y, 상대높이 b) 매개: 창 윗변이 지붕선을 따라 휘어진다.
SILL = 1.07
def win_top(y):
return BODY.f['top'](y) - 0.118
def side_rel(s):
return lambda y, b: ((0.0, y, SILL + b * (win_top(y) - SILL)), (s, 0.0, 0.0))
DOOR_WIN = ((-0.80, 0.0), (0.02, 0.0), (-0.585, 1.0), (0.02, 1.0))
QUARTER_WIN = ((0.15, 0.0), (0.61, 0.0), (0.15, 1.0), (0.45, 1.0))
for s, sn in ((1, 'R'), (-1, 'L')):
mp = side(BF, s)
mr = side_rel(s)
patch(f"DoorWindow_Frame_{sn}", BF, grow2(DOOR_WIN), mr, CHROME, res=(30, 12), sq=6, offset=0.004)
patch(f"DoorWindow_Glass_{sn}", BF, DOOR_WIN, mr, GLASS, res=(30, 12), sq=6, offset=0.008)
patch(f"QuarterWindow_Frame_{sn}", BF, grow2(QUARTER_WIN), mr, CHROME, res=(26, 12), sq=2.6, offset=0.004)
patch(f"QuarterWindow_Glass_{sn}", BF, QUARTER_WIN, mr, GLASS, res=(26, 12), sq=2.6, offset=0.008)
# 삼각 환기창 칸막이
surf_tube(f"VentWing_Divider_{sn}", BF, mp, [(y, SILL + b * (win_top(y) - SILL)) for y, b in lerp_pts((-0.645, -0.05), (-0.525, 1.05), 6)], 0.007, CHROME, offset=0.010)
# 빗물받이(드립 레일)
surf_tube(f"Rain_Gutter_{sn}", BF, mp, [(y, win_top(y) + 0.036) for y, _ in lerp_pts((-0.58, 0), (0.50, 0), 24)], 0.006, CHROME, offset=0.003)
# 옆 크롬 몰딩
surf_tube(f"Side_Molding_{sn}", BF, mp, lerp_pts((-0.84, 0.80), (0.74, 0.80), 30), 0.0055, CHROME, offset=0.001)
# 문틈
surf_tube(f"Door_Seam_Front_{sn}", BF, mp, lerp_pts((-0.845, 0.36), (-0.835, 1.04), 14), 0.0035, UNDER)
surf_tube(f"Door_Seam_Rear_{sn}", BF, mp, lerp_pts((0.085, 0.36), (0.085, 1.04), 14), 0.0035, UNDER)
surf_tube(f"Door_Seam_Bottom_{sn}", BF, mp, lerp_pts((-0.845, 0.365), (0.085, 0.365), 20), 0.0035, UNDER)
# 문손잡이
q = on_surf(BF, mp, -0.03, 1.00)
n = normal_at(BF, q)
box(f"Door_Handle_{sn}", size=(0.022, 0.15, 0.024), loc=q + n * 0.008,
rot=Vector((1, 0, 0)).rotation_difference(n).to_euler(), material=CHROME)
bevel(bpy.data.objects[f"Door_Handle_{sn}"], 0.008, 2)
# ── 6. 범퍼(블레이드형) ────────────────────────────────────
def sweep(name, path, section, material):
"""수평 경로를 따라 단면(s=바깥 수평, t=위)을 쓸어 만든다."""
bm = bmesh.new()
rings = []
for i, p in enumerate(path):
a, b = Vector(path[max(i - 1, 0)]), Vector(path[min(i + 1, len(path) - 1)])
tg = b - a
tg.z = 0
tg.normalize()
nr = Vector((tg.y, -tg.x, 0))
rings.append([bm.verts.new(Vector(p) + nr * s_ + Vector((0, 0, t_))) for s_, t_ in section])
k = len(section)
for r0, r1 in zip(rings, rings[1:]):
for j in range(k):
bm.faces.new((r0[j], r0[(j + 1) % k], r1[(j + 1) % k], r1[j]))
bm.faces.new(rings[0])
bm.faces.new(list(reversed(rings[-1])))
bmesh.ops.recalc_face_normals(bm, faces=bm.faces)
me = bpy.data.meshes.new(name)
bm.to_mesh(me)
bm.free()
return smooth(assign(link(bpy.data.objects.new(name, me)), material))
BLADE = [(-0.006, -0.050), (0.018, -0.052), (0.031, -0.032), (0.035, 0.0), (0.033, 0.030),
(0.023, 0.050), (0.008, 0.058), (-0.006, 0.054)]
def bumper_path(y0, z, sgn):
half = [(x, y0 + 0.05 * (x / 0.66) ** 2) for x in [0.66 * i / 10 for i in range(11)]]
half += [(0.705, y0 + 0.066), (0.740, y0 + 0.095), (0.762, y0 + 0.138), (0.772, y0 + 0.185)]
full = [(-x, y) for x, y in reversed(half[1:])] + half
pts = [(x, y if sgn < 0 else -y, z) for x, y in full]
return pts if sgn < 0 else list(reversed(pts))
for y0, z, sgn, tag in ((-2.035, 0.43, -1, 'Front'), (-1.995, 0.45, 1, 'Rear')):
sweep(f"Bumper_{tag}", bumper_path(y0, z, sgn), BLADE, CHROME)
yb = -y0 if sgn > 0 else y0
for s in (1, -1):
g = box(f"Bumper_Guard_{tag}", size=(0.045, 0.05, 0.17), loc=(s * 0.36, yb + sgn * (0.035 + 0.006), z + 0.02),
material=CHROME)
bevel(g, 0.018, 3)
box(f"Bumper_Bracket_{tag}", size=(0.045, 0.26, 0.05), loc=(s * 0.42, yb - sgn * 0.13, z), material=UNDER)
# ── 7. 전조등·방향지시등·후미등 ────────────────────────────
def lamp_rot(n):
return Vector((0, 0, 1)).rotation_difference(n).to_euler()
bucket = lathe("Headlamp_Bucket", [(0.0, -0.13), (0.104, -0.13), (0.112, -0.02), (0.108, 0.0)], material=PAINT, seg=40)
hrim = torus("Headlamp_Rim", R=0.100, r=0.012, seg=40, rseg=10, material=CHROME)
hlens = lathe("Headlamp_Lens", [(0.0, 0.032), (0.04, 0.029), (0.07, 0.020), (0.095, 0.006), (0.099, -0.004)], material=LENS, seg=40)
headlamp = join([bucket, hrim, hlens], "Headlamp_R")
tl_body = sphere("Taillamp_Housing", r=1.0, scale=(0.040, 0.060, 0.018), material=CHROME, seg=24, rings=12)
tl_lens = sphere("Taillamp_Lens", r=1.0, scale=(0.034, 0.053, 0.020), loc=(0, 0.004, 0.006), material=RED, seg=24, rings=12)
taillamp = join([tl_body, tl_lens], "Taillamp_R")
ts_body = sphere("TurnSignal_Body", r=1.0, scale=(0.022, 0.055, 0.022), material=CHROME, seg=16, rings=8)
ts_lens = sphere("TurnSignal_Lens", r=1.0, scale=(0.017, 0.022, 0.017), loc=(0, -0.045, 0), material=AMBER, seg=16, rings=8)
tsig = join([ts_body, ts_lens], "TurnSignal_R")
for s, sn in ((1, 'R'), (-1, 'L')):
ff = FENDERS[('Front', sn)].F
q = hit(ff, (s * 0.570, -1.55, 0.76), (0, -1, 0))
d = Vector((0, -1, 0.10)).normalized()
hl = headlamp if s > 0 else duplicate(headlamp, name="Headlamp_L")
hl.location = q + d * 0.015
hl.rotation_euler = lamp_rot(d)
q = hit(ff, (s * 0.62, -1.42, 0.5), (0, 0, 1))
ts = tsig if s > 0 else duplicate(tsig, name="TurnSignal_L")
ts.location = q + Vector((0, 0, 0.012))
rf = FENDERS[('Rear', sn)].F
q = hit(rf, (s * 0.56, 1.55, 0.73), (0, 1, 0))
n = normal_at(rf, q)
tl = taillamp if s > 0 else duplicate(taillamp, name="Taillamp_L")
tl.location = q + n * 0.004
tl.rotation_euler = Vector((0, 0, 1)).rotation_difference(n).to_euler()
# ── 8. 보닛·엔진 덮개 디테일 ─────────────────────────────
def up_ray(x, y):
return (x, y, 0.45), (0.0, 0.0, 1.0)
def loop_pts(c0, c1, hw0, hw1, hl, n=4.0, k=48):
"""평면 위 둥근 사다리꼴 고리. c0~c1 축 방향, 폭은 hw0→hw1 로 변한다."""
out = []
for i in range(k):
a = 2 * math.pi * i / k
c, s_ = math.cos(a), math.sin(a)
v = math.copysign(abs(s_) ** (2 / n), s_)
t = (v + 1) / 2
hw = hw0 + (hw1 - hw0) * t
out.append((math.copysign(abs(c) ** (2 / n), c) * hw, c0 + (c1 - c0) * t))
return out
surf_tube("Hood_Seam", BF, up_ray, loop_pts(-1.975, -1.09, 0.24, 0.40, 0, 3.0), 0.0035, UNDER, closed=True)
surf_tube("Hood_Trim_Strip", BF, up_ray, lerp_pts((0, -1.93), (0, -1.14), 20), 0.007, CHROME, offset=0.001)
q = on_surf(BF, up_ray, 0.0, -1.945)
n = normal_at(BF, q)
cylinder("Hood_Emblem_VW", r=0.042, h=0.008, loc=q + n * 0.002, rot=lamp_rot(n), material=CHROME, seg=32)
box("Hood_Handle", size=(0.10, 0.022, 0.018), loc=on_surf(BF, up_ray, 0.0, -1.885, 0.012), rot=lamp_rot(n), material=CHROME)
# 엔진 덮개 테두리, 냉각 루버, 번호판
surf_tube("EngineLid_Seam", BF, rear_ray, [(x, z) for x, z in loop_pts(0.50, 1.00, 0.36, 0.30, 0, 3.0)], 0.0035, UNDER, closed=True)
for s in (1, -1):
for k in range(4):
z = 1.035 + k * 0.024
q = on_surf(BF, rear_ray, s * 0.155, z)
n = normal_at(BF, q)
box("Engine_Louver", size=(0.19, 0.012, 0.009), loc=q, rot=Vector((0, 1, 0)).rotation_difference(n).to_euler(),
material=UNDER)
q = on_surf(BF, rear_ray, 0.0, 0.60)
n = normal_at(BF, q)
r = Vector((0, 1, 0)).rotation_difference(n).to_euler()
box("License_Plate", size=(0.31, 0.008, 0.155), loc=q + n * 0.012, rot=r, material=PLATE)
q = on_surf(BF, rear_ray, 0.0, 0.715)
n = normal_at(BF, q)
sphere("Plate_Light_Housing", r=1.0, scale=(0.09, 0.035, 0.035), loc=q, rot=Vector((0, 1, 0)).rotation_difference(n).to_euler(),
material=CHROME, seg=24, rings=12)
# 와이퍼
for x0, x1 in ((-0.42, -0.10), (0.06, 0.38)):
surf_tube("Wiper", BF, front_ray, lerp_pts((x0, 1.09), (x1, 1.11), 8), 0.006, RUBBER, offset=0.016)

다른 모형