
끌어서 회전, 휠·두 손가락으로 확대
이 모형에 대하여
로터리 다이얼 전화기는 숫자 구멍에 손가락을 넣어 멈춤쇠까지 돌렸다가 놓으면, 다이얼이 되돌아가며 숫자만큼 전기 펄스를 보내 번호를 거는 방식이다. 자동 교환기가 보급된 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 nobdef 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 outdef 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 outdef 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, Bdef 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 obbody = 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) / 2dial_root = bpy.data.objects.new('Dial_Assembly', None)link(dial_root)dial_root.location = dial_cdial_root.rotation_euler = (slope_ang, 0, 0)def on_dial(ob): ob.parent = dial_root return obR_PLATE = 0.0445R_WHEEL = 0.0405R_HOLE = 0.0300HOLE_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 # 수화기 중심선 yHORN_X = 0.072for 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.0042helix = []s = 0.0for 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)






