본문으로 건너뛰기
3D 아틀리에
로터리 다이얼 전화기 3D 모형

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

이 모형에 대하여

로터리 다이얼 전화기는 숫자 구멍에 손가락을 넣어 멈춤쇠까지 돌렸다가 놓으면, 다이얼이 되돌아가며 숫자만큼 전기 펄스를 보내 번호를 거는 방식이다. 자동 교환기가 보급된 20세기 초부터 푸시버튼 전화기가 자리를 잡기 전까지 가정과 사무실의 표준 전화기였다.

이 모델은 1950년대 미국 벨 시스템이 보급한 Western Electric 500형 탁상 전화기의 구성을 본떴다. 앞쪽이 비스듬히 깎인 둥근 본체, 경사면의 다이얼, 윗면 양쪽 받침에 가로로 얹힌 수화기, 수화기와 본체를 잇는 꼬인 코드가 이 계열 전화기를 알아보게 하는 특징이다.

다이얼은 1이 2시 방향, 0이 5시 방향에 오도록 30도 간격으로 열 개의 구멍을 두었고, 4시 방향에 손가락 멈춤쇠를 달았다. 본체 색은 '핫라인' 전화기로 널리 알려진 붉은색으로 했다.

만든 방법

본체는 옆 단면 프로필을 좌우로 돌출한 뒤 위로 갈수록 좁아지게 기울이고, 모서리마다 베벨 가중치를 달리 주어 옆 모서리는 크게, 바닥은 각지게 둥글렸다. 다이얼 회전판의 구멍 열 개는 원기둥 배열을 불리언으로 뚫었고, 숫자는 3D 글자를 메시로 바꿔 번호판 위에 놓았다. 수화기 손잡이는 초타원 단면을 스플라인 경로를 따라 쓸어 만들었고 송화구·수화구 컵은 회전체다. 꼬인 코드는 Catmull-Rom 경로를 일정 간격으로 재표본화하고 평행 이동 프레임 위에 나선을 감아 만들었다. 내부 기계 장치, 다이얼 숫자 옆의 알파벳, 상표 표기는 생략했다.

build.py

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

# 로터리 다이얼 전화기 (1950년대 Western Electric 500형 탁상 전화기를 본뜬 모델)
# - 본체: 옆 단면(앞쪽이 비스듬히 깎인 프로필)을 X 방향으로 돌출하고, 위로 갈수록 좁아지게
# 선형으로 기울인 뒤 큰 베벨로 모서리를 둥글린 셸이다. 아래에 금속 바닥판을 둔다.
# - 다이얼: 경사면 위에 기울인 빈 오브젝트를 기준으로 번호판·숫자(3D 글자)·손가락 구멍 10개를
# 불리언으로 뚫은 회전판·크롬 베젤·손가락 멈춤쇠를 원형 배치했다.
# - 수화기: 손잡이는 타원 단면을 곡선 경로로 쓸어 만든 관(sweep), 송화구·수화구 컵은 회전체(lathe).
# - 꼬인 코드: 부드러운 스플라인 경로를 따라 평행 이동 프레임으로 나선을 감아 만든 관이다.
# 실제 크기(폭 약 21cm, 깊이 약 22cm, 높이 약 16cm), 단위 미터.
from atelier import *
SHOT = {"azimuth": -58, "elevation": 22, "lens": 60, "light": "studio", "background": "#2a323d"}
# ---------------------------------------------------------------- 재질
RED = mat('Bakelite_Red', '#9b1b20', rough=0.22, coat=0.6)
CREAM = mat('Number_Plate_Cream', '#ebe3cf', rough=0.45)
INK = mat('Printed_Ink', '#26262a', rough=0.6)
CHROME = mat('Chrome', '#cfd2d6', rough=0.12, metal=1.0)
BASE = mat('Base_Plate_Steel', '#3b3d42', rough=0.5, metal=0.6)
RUBBER = mat('Grommet_Rubber', '#2e2a2a', rough=0.7)
PLUNGER = mat('Plunger_Ivory', '#d8d0bd', rough=0.35)
GRILLE = mat('Receiver_Grille', '#5a1014', rough=0.5)
# ---------------------------------------------------------------- 공용 함수
def to_mesh(ob):
"""커브/글자 오브젝트를 같은 이름의 메시로 바꾼다(GLB 호환)."""
dg = bpy.context.evaluated_depsgraph_get()
me = bpy.data.meshes.new_from_object(ob.evaluated_get(dg))
nob = bpy.data.objects.new(ob.name, me)
link(nob)
nob.matrix_world = ob.matrix_world.copy()
nob.parent = ob.parent
if ob.parent:
nob.matrix_parent_inverse = ob.matrix_parent_inverse.copy()
name = ob.name
bpy.data.objects.remove(ob, do_unlink=True)
nob.name = name
return nob
def sharp_smooth(ob, angle=35):
"""부드러운 음영 + 각진 모서리만 날카롭게."""
for p in ob.data.polygons:
p.use_smooth = True
ob.data.set_sharp_from_angle(angle=math.radians(angle))
return ob
나머지 283줄 펼치기
def catmull(points, samples=12):
"""Catmull-Rom 스플라인으로 점열을 부드럽게."""
P = [Vector(p) for p in points]
P = [P[0] * 2 - P[1]] + P + [P[-1] * 2 - P[-2]]
out = []
for i in range(1, len(P) - 2):
p0, p1, p2, p3 = P[i - 1], P[i], P[i + 1], P[i + 2]
for s in range(samples):
t = s / samples
out.append(0.5 * ((2 * p1) + (-p0 + p2) * t + (2 * p0 - 5 * p1 + 4 * p2 - p3) * t * t
+ (-p0 + 3 * p1 - 3 * p2 + p3) * t * t * t))
out.append(P[-2])
return out
def resample(pts, step):
"""호 길이 기준으로 일정 간격 재표본화."""
out = [pts[0]]
acc = 0.0
for a, b in zip(pts, pts[1:]):
seg = (b - a).length
while acc + seg >= step:
t = (step - acc) / seg
a = a.lerp(b, t)
out.append(a)
seg = (b - a).length
acc = 0.0
acc += seg
if (out[-1] - pts[-1]).length > step * 0.3:
out.append(pts[-1])
return out
def frames(pts):
"""평행 이동 프레임(접선, 법선, 종법선)."""
T = []
for i in range(len(pts)):
a = pts[max(i - 1, 0)]
b = pts[min(i + 1, len(pts) - 1)]
T.append((b - a).normalized())
ref = Vector((0, 0, 1)) if abs(T[0].z) < 0.9 else Vector((1, 0, 0))
N = [(ref - T[0] * ref.dot(T[0])).normalized()]
for i in range(1, len(pts)):
n = N[-1] - T[i] * N[-1].dot(T[i])
N.append(n.normalized())
B = [t.cross(n) for t, n in zip(T, N)]
return T, N, B
def sweep(name, path, section, material=None, cap=True):
"""path(Vector 목록)를 따라 단면 함수 section(i, angle)->(u, v) 를 쓸어 관을 만든다."""
T, N, B = frames(path)
bm = bmesh.new()
rings = []
k = len(section(0, 0.0, probe=True))
for i, p in enumerate(path):
sec = section(i, 0.0)
rings.append([bm.verts.new(p + N[i] * u + B[i] * v) for u, v in sec])
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]))
if cap:
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)
return smooth(ob)
def ellipse_section(a, b, k=20, power=2.6, taper=None):
def f(i, _a, probe=False):
s = 1.0 if taper is None or probe else taper(i)
out = []
for j in range(k):
t = 2 * math.pi * j / k
c, si = math.cos(t), math.sin(t)
# 초타원(약간 각진 둥근 단면)
u = a * s * math.copysign(abs(c) ** (2 / power), c)
v = b * s * math.copysign(abs(si) ** (2 / power), si)
out.append((u, v))
return out
return f
# ---------------------------------------------------------------- 본체 셸
Z0 = 0.005 # 셸 아랫면(바닥판 위)
TOP = 0.090 # 윗면 높이
PROFILE = [ # (y, z) 옆 단면: 앞(-Y)이 다이얼 경사면
(-0.113, Z0), (0.110, Z0), (0.100, 0.068), (0.078, TOP),
(-0.020, TOP), (-0.110, 0.020),
]
def half_w(z):
return 0.105 - 0.24 * (z - Z0)
def body_shell():
bm = bmesh.new()
L = [bm.verts.new((-half_w(z), y, z)) for y, z in PROFILE]
R = [bm.verts.new((half_w(z), y, z)) for y, z in PROFILE]
n = len(PROFILE)
bm.faces.new(L)
bm.faces.new(list(reversed(R)))
for i in range(n):
bm.faces.new((L[i], L[(i + 1) % n], R[(i + 1) % n], R[i]))
bmesh.ops.recalc_face_normals(bm, faces=bm.faces)
# 모서리마다 둥글림 크기를 달리한다(베벨 가중치): 옆 모서리는 크게, 바닥은 각지게
bw = bm.edges.layers.float.new('bevel_weight_edge')
xw = [0.0, 0.0, 0.40, 0.55, 0.50, 0.28] # 단면 꼭짓점(=X 방향 모서리)별
for i in range(n):
bm.edges.get((L[i], R[i]))[bw] = xw[i]
for S in (L, R):
bm.edges.get((S[i], S[(i + 1) % n]))[bw] = 0.0 if i == 0 else 1.0
me = bpy.data.meshes.new('Body_Shell')
bm.to_mesh(me)
bm.free()
ob = bpy.data.objects.new('Body_Shell', me)
link(ob)
assign(ob, RED)
m = ob.modifiers.new('Bevel', 'BEVEL')
m.width = 0.030
m.segments = 10
m.limit_method = 'WEIGHT'
m.use_clamp_overlap = False
m.profile = 0.55
m.harden_normals = True # 넓은 평면은 평평하게, 둥근 모서리만 부드럽게
smooth(ob)
apply_all(ob)
return ob
body = body_shell()
# 바닥판: 셸보다 약간 안쪽으로 들어간 강철판
plate = box('Base_Plate', (0.200, 0.214, 0.0062), loc=(0, -0.0015, 0), material=BASE, base=True)
bevel(plate, 0.0018, 2)
# 고무 발 4개
FLOOR = -0.0015 # 고무 발 바닥 = 탁자면
foot = cylinder('Rubber_Foot', r=0.007, h=0.0015, loc=(0.085, 0.088, FLOOR), material=RUBBER, seg=20)
for sx, sy in ((-1, 1), (1, -1), (-1, -1)):
duplicate(foot, loc=(0.085 * sx, 0.088 * sy, FLOOR))
# ---------------------------------------------------------------- 다이얼(경사면 위)
SLOPE_A = Vector((0, -0.020, TOP))
SLOPE_B = Vector((0, -0.110, 0.020))
slope_ang = math.atan2(SLOPE_A.z - SLOPE_B.z, SLOPE_A.y - SLOPE_B.y)
dial_c = (SLOPE_A + SLOPE_B) / 2
dial_root = bpy.data.objects.new('Dial_Assembly', None)
link(dial_root)
dial_root.location = dial_c
dial_root.rotation_euler = (slope_ang, 0, 0)
def on_dial(ob):
ob.parent = dial_root
return ob
R_PLATE = 0.0445
R_WHEEL = 0.0405
R_HOLE = 0.0300
HOLE_R = 0.0072
# 크롬 베젤 링(경사면에 살짝 묻힘)
on_dial(lathe('Dial_Bezel', [(R_PLATE - 0.001, -0.002), (R_PLATE + 0.0035, -0.002), (R_PLATE + 0.0042, 0.0012),
(R_PLATE + 0.0025, 0.0032), (R_PLATE - 0.0002, 0.0030), (R_PLATE - 0.001, -0.002)],
material=CHROME, seg=72))
# 번호판(크림색 원판)
on_dial(cylinder('Dial_Number_Plate', r=R_PLATE, h=0.0032, loc=(0, 0, -0.001), material=CREAM, seg=72))
DIGITS = '1234567890'
for i, d in enumerate(DIGITS):
a = math.radians(32 + 30 * i) # 1 은 2시 방향, 반시계로 0 은 5시 방향
t = text3d(f'Dial_Digit_{d}', d, size=0.0085, depth=0.00025,
loc=(R_HOLE * math.cos(a), R_HOLE * math.sin(a), 0.00245), material=INK)
t.data.align_y = 'CENTER'
on_dial(to_mesh(t))
# 회전판(손가락 구멍 10개)
wheel = cylinder('Dial_Finger_Wheel', r=R_WHEEL, h=0.0042, loc=(0, 0, 0.0030), material=RED, seg=96)
holes = []
for i in range(10):
a = math.radians(32 + 30 * i)
holes.append(cylinder(f'hole_{i}', r=HOLE_R, h=0.02, loc=(R_HOLE * math.cos(a), R_HOLE * math.sin(a), -0.005), seg=32))
cutter = join(holes, 'Dial_Hole_Cutter')
boolean(wheel, cutter)
bevel(wheel, 0.0008, 2, 40)
apply_all(wheel)
bpy.data.objects.remove(cutter, do_unlink=True)
sharp_smooth(wheel, 40)
on_dial(wheel)
# 중앙 캡(크롬 테 + 크림 카드)
on_dial(lathe('Dial_Center_Cap', [(0, 0.0074), (0.0115, 0.0074), (0.0128, 0.0068), (0.0130, 0.0055), (0.0124, 0.0040),
(0, 0.0040)], material=CHROME, seg=48))
on_dial(cylinder('Dial_Center_Card', r=0.0098, h=0.0004, loc=(0, 0, 0.0073), material=CREAM, seg=48))
for k, (w, yy) in enumerate(((0.012, 0.0028), (0.009, -0.0004), (0.006, -0.0032))):
on_dial(box(f'Dial_Card_Print_{k}', (w, 0.0011, 0.0002), loc=(0, yy, 0.0078), material=INK))
# 손가락 멈춤쇠(4시 방향, 크롬 갈고리)
fs_a = math.radians(-32)
cdir = Vector((math.cos(fs_a), math.sin(fs_a), 0))
fs_pts = [cdir * (R_PLATE + 0.0005) + Vector((0, 0, 0.0018)), cdir * (R_PLATE + 0.0008) + Vector((0, 0, 0.0085)),
cdir * (R_WHEEL - 0.0005) + Vector((0, 0, 0.0098)), cdir * (R_WHEEL - 0.0060) + Vector((0, 0, 0.0090))]
stop = curve_tube('Dial_Finger_Stop', [tuple(p) for p in fs_pts], radius=0.0016, material=CHROME, resolution=8)
on_dial(to_mesh(stop))
# ---------------------------------------------------------------- 받침(크래들) 뿔과 후크 버튼
HY = 0.040 # 수화기 중심선 y
HORN_X = 0.072
for sx in (-1, 1):
side = 'L' if sx < 0 else 'R'
for sy, tag in ((-1, 'Front'), (1, 'Back')):
hb = box(f'Cradle_Horn_{side}_{tag}', (0.034, 0.013, 0.052),
loc=(sx * HORN_X, HY + sy * 0.0255, TOP - 0.020), material=RED, base=True)
bevel(hb, 0.0062, 4, 30)
apply_all(hb)
sharp_smooth(hb, 40)
pl = box(f'Hook_Plunger_{side}', (0.014, 0.016, 0.040), loc=(sx * HORN_X, HY, TOP - 0.004),
material=PLUNGER, base=True)
bevel(pl, 0.003, 3, 30)
# ---------------------------------------------------------------- 수화기
H_PATH = catmull([(-0.104, HY, 0.110), (-0.092, HY, 0.127), (-0.064, HY, 0.137), (0, HY, 0.139),
(0.064, HY, 0.137), (0.092, HY, 0.127), (0.104, HY, 0.110)], samples=10)
H_PATH = resample(H_PATH, 0.003)
handle = sweep('Handset_Handle', H_PATH, ellipse_section(0.0105, 0.0165, k=24), material=RED)
# (법선=위쪽 평면 방향 → u 는 두께, v 는 폭)
for sx, name in ((-1, 'Handset_Mouthpiece'), (1, 'Handset_Earpiece')):
end = H_PATH[0] if sx < 0 else H_PATH[-1]
prof = [(0.0, -0.032), (0.021, -0.032), (0.0262, -0.0306), (0.0285, -0.0275), (0.0292, -0.022),
(0.0290, -0.014), (0.0278, -0.009), (0.0240, -0.004), (0.0170, 0.001), (0.0115, 0.006),
(0.0, 0.008)]
cup = lathe(name, prof, loc=end + Vector((sx * 0.004, 0, 0.004)), rot=(0, -sx * math.radians(24), 0),
material=RED, seg=48)
grille = cylinder(name + '_Grille', r=0.0185, h=0.0006, loc=(0, 0, -0.0323), material=GRILLE, seg=40)
grille.parent = cup
# ---------------------------------------------------------------- 꼬인 수화기 코드
ZC = FLOOR + 0.0055 + 0.0016 # 바닥에 놓인 부분의 나선 중심 높이
cord_ctrl = [(-0.118, HY, 0.092), (-0.134, HY - 0.006, 0.070), (-0.148, HY - 0.020, 0.045),
(-0.160, 0.000, 0.014), (-0.178, -0.045, ZC), (-0.208, -0.030, ZC), (-0.214, 0.030, ZC),
(-0.190, 0.080, ZC), (-0.140, 0.092, ZC), (-0.112, 0.078, 0.012), (-0.098, 0.072, 0.016)]
cpath = resample(catmull(cord_ctrl, samples=20), 0.0004)
T, N, B = frames(cpath)
coil_r, pitch = 0.0055, 0.0042
helix = []
s = 0.0
for i, p in enumerate(cpath):
if i:
s += (cpath[i] - cpath[i - 1]).length
ang = 2 * math.pi * s / pitch
# 끝 부분은 나선 반지름을 줄여 곧은 코드로 이어지게
fade = min(1.0, s / 0.012, (len(cpath) - 1 - i) * 0.0004 / 0.012)
helix.append(p + (N[i] * math.cos(ang) + B[i] * math.sin(ang)) * coil_r * fade)
helix = helix[::2]
cord = curve_tube('Handset_Coiled_Cord', [tuple(p) for p in helix], radius=0.0016, material=RED,
resolution=1, bevel_res=1)
cord = to_mesh(cord)
smooth(cord)
grom = cylinder('Cord_Grommet_Side', r=0.0045, h=0.006, loc=(-0.097, 0.072, 0.016), rot=(0, -math.pi / 2, 0),
material=RUBBER, seg=20)
# ---------------------------------------------------------------- 벽 연결선(뒤쪽)
line_pts = catmull([(0.040, 0.100, 0.014), (0.042, 0.118, 0.006), (0.050, 0.140, FLOOR + 0.0022),
(0.075, 0.175, FLOOR + 0.0022), (0.110, 0.190, FLOOR + 0.0022)], samples=10)
lc = to_mesh(curve_tube('Line_Cord', [tuple(p) for p in line_pts], radius=0.0022, material=RUBBER,
resolution=2, bevel_res=2))
smooth(lc)
plug = box('Line_Cord_Plug', (0.016, 0.011, 0.009), loc=(0.118, 0.193, FLOOR), rot=(0, 0, math.radians(20)),
material=PLUNGER, base=True)
bevel(plug, 0.0015, 2)

다른 모형