
끌어서 회전, 휠·두 손가락으로 확대
이 모형에 대하여
그랜드 피아노는 줄과 향판을 수평으로 눕힌 피아노다. 18세기 초 이탈리아의 바르톨로메오 크리스토포리가 해머로 줄을 치는 방식을 고안한 뒤, 19세기에 주철 프레임과 교차 현(overstringing)이 도입되면서 오늘날의 형태가 자리 잡았다. 길이 2.7m 안팎의 콘서트 그랜드는 대부분의 연주회장에서 쓰인다.
긴 저음 현과 짧은 고음 현의 길이 차이 때문에 케이스 한쪽은 곧고 다른 쪽은 안으로 휘어 들어가는 곡선(벤트사이드)을 이루며, 위에서 보면 날개 모양이 된다. 연주 때는 곧은 쪽에 경첩이 달린 뚜껑을 받침대로 열어 소리를 객석 쪽으로 반사시킨다.
이 모델은 뚜껑을 받침대로 연 연주 상태의 콘서트 그랜드를 실제 치수로 재현했다. 흑단색 도장 케이스, 52개의 흰 건반과 36개의 검은 건반, 보면대, 금색 철골 프레임과 그 아래의 향판, 고음부 세 줄과 저음부 동선 교차 현, 튜닝핀과 댐퍼, 세 개의 다리와 황동 캐스터, 리라와 세 개의 페달을 담았다.
만든 방법
케이스 윤곽은 벤트사이드 제어점 15개를 캐트멀-롬 곡선으로 보간한 다각형이며, 이를 안쪽으로 오프셋해 림 벽·향판·라이너·철골 프레임을, 바깥으로 오프셋해 뚜껑을 만들었다. 프레임의 큰 개구부는 같은 윤곽을 더 깊게 오프셋한 프리즘으로 불리언 차감했고, 방사형 지지대는 상자를 회전 배치했다. 건반은 A0 부터 음이름 규칙(A·C·D·F·G 다음에 검은 건반)으로 88개를 배열했고, 줄·튜닝핀·댐퍼는 메시를 공유하는 복제본으로 200여 개를 놓았다. 다리와 캐스터는 회전체, 리라는 곡선 관으로 만들었다. 실제 액션(해머 기구)과 프레임의 세부 주조 문양, 줄 수(실제 약 230개)는 단순화했다.
build.py
이 파일 하나를 Blender 에서 실행하면 위의 모형이 만들어집니다. 367줄.
# 콘서트 그랜드 피아노 (길이 약 2.74m급, 뚜껑을 받침대로 연 연주 상태)# - 날개 모양 케이스 윤곽은 제어점을 캐트멀-롬 곡선으로 보간해 얻고, 그 다각형을 안쪽으로 오프셋해# 림(옆판) 벽, 향판, 철골 프레임, 뚜껑을 모두 같은 윤곽에서 만들었다.# - 건반 88개(흰 건반 52 + 검은 건반 36)는 A0 부터 음이름 규칙으로 배열했고,# 줄·튜닝핀·댐퍼는 메시를 공유하는 복제본을 수학적으로 배치했다(고음 3줄, 저음 동선 교차).# - 다리·캐스터는 회전체(lathe), 리라와 페달은 상자·관으로 조립했다. 재질은 상수 색 Principled BSDF.import mathfrom atelier import *SHOT = {"azimuth": -48, "elevation": 26, "lens": 55, "light": "studio", "background": "#7d8894"}# ---------------------------------------------------------------- 재질EBONY = mat('Ebony_Lacquer', '#121215', rough=0.12, coat=1.0)IVORY = mat('Key_Ivory', '#ece6d6', rough=0.3)KEYBLK = mat('Key_Ebony', '#1b1a1c', rough=0.35)GOLD = mat('Iron_Plate_Gold', '#c4923a', rough=0.4, metal=0.85)SPRUCE = mat('Soundboard_Spruce', '#e6cb98', rough=0.5)MAPLE = mat('Bridge_Maple', '#9c6b3f', rough=0.5)STEEL = mat('String_Steel', '#d5d8dc', rough=0.18, metal=1.0)COPPER = mat('Bass_String_Copper', '#b4703c', rough=0.3, metal=1.0)BRASS = mat('Brass', '#c9a14c', rough=0.2, metal=1.0)FELT_R = mat('Felt_Red', '#7e1b22', rough=0.9)FELT_K = mat('Damper_Felt', '#2b2a2d', rough=0.85)# ---------------------------------------------------------------- 치수W = 1.57 # 케이스 폭HW = W / 2Z_BOT = 0.62 # 케이스 바닥(림 아래)Z_TOP = 0.98 # 림 윗면Y_FRONT = 0.30 # 림 앞벽 (건반 뒤)RIM_T = 0.045 # 림 두께CHEEK_X = 0.611 # 건반 양끝(치크 블록 안쪽 면)# 벤트사이드(오른쪽 곡선 옆판)~꼬리 제어점 (x, y)BENT = [(HW, 0.0), (HW, 0.55), (0.772, 0.78), (0.715, 1.00), (0.605, 1.22), (0.468, 1.45), (0.338, 1.68), (0.226, 1.92), (0.132, 2.15), (0.035, 2.38), (-0.10, 2.57), (-0.30, 2.70), (-0.52, 2.745), (-0.70, 2.73), (-HW, 2.67)]def catmull(pts, n=10): out = [] P = [pts[0]] + pts + [pts[-1]] for i in range(1, len(P) - 2): p0, p1, p2, p3 = P[i - 1], P[i], P[i + 1], P[i + 2] for k in range(n): t = k / n t2, t3 = t * t, t * t * t out.append(tuple(0.5 * ((2 * p1[j]) + (-p0[j] + p2[j]) * t + (2 * p0[j] - 5 * p1[j] + 4 * p2[j] - p3[j]) * t2나머지 319줄 펼치기접기
+ (-p0[j] + 3 * p1[j] - 3 * p2[j] + p3[j]) * t3) for j in range(2))) out.append(pts[-1]) return outBENT_DENSE = catmull(BENT, 12)def outline(front_y=Y_FRONT): """앞변이 front_y 인 반시계 방향 케이스 윤곽.""" pts = [(-HW, front_y), (HW, front_y)] for x, y in BENT_DENSE: if y > front_y + 0.02: pts.append((x, y)) pts.append((-HW, 2.55)) pts.append((-HW, 1.5)) pts.append((-HW, 0.9)) return ptsdef offset(pts, d): """반시계 다각형을 안쪽으로 d 만큼 (미터 접합).""" n = len(pts) out = [] for i in range(n): p0, p1, p2 = Vector((*pts[i - 1], 0)), Vector((*pts[i], 0)), Vector((*pts[(i + 1) % n], 0)) e1 = (p1 - p0).normalized() e2 = (p2 - p1).normalized() n1 = Vector((-e1.y, e1.x, 0)) n2 = Vector((-e2.y, e2.x, 0)) m = (n1 + n2).normalized() c = max(m.dot(n1), 0.3) q = p1 + m * (d / c) out.append((q.x, q.y)) return outdef sharpen(ob, deg=35): smooth(ob) ob.data.set_sharp_from_angle(angle=math.radians(deg)) return obdef ring_wall(name, outer, inner, z0, z1, material): bm = bmesh.new() n = len(outer) ob_ = [bm.verts.new((x, y, z0)) for x, y in outer] ot = [bm.verts.new((x, y, z1)) for x, y in outer] ib = [bm.verts.new((x, y, z0)) for x, y in inner] it = [bm.verts.new((x, y, z1)) for x, y in inner] for i in range(n): j = (i + 1) % n bm.faces.new((ob_[i], ob_[j], ot[j], ot[i])) bm.faces.new((it[i], it[j], ib[j], ib[i])) bm.faces.new((ot[i], ot[j], it[j], it[i])) bm.faces.new((ib[i], ib[j], ob_[j], ob_[i])) 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 sharpen(ob)def slab(name, pts, z0, z1, material): ob = prism(name, pts, z1 - z0, loc=(0, 0, z0), material=material) return sharpen(ob)def x_on_bent(y): """벤트사이드 바깥선의 y 에서의 x.""" for (x0, y0), (x1, y1) in zip(BENT_DENSE, BENT_DENSE[1:]): if y0 <= y <= y1 and y1 > y0: return x0 + (x1 - x0) * (y - y0) / (y1 - y0) return -HWdef y_on_bent(x): """벤트사이드 바깥선이 x 를 지나는 y (꼬리 쪽 단조 구간).""" best = None for (x0, y0), (x1, y1) in zip(BENT_DENSE, BENT_DENSE[1:]): if min(x0, x1) <= x <= max(x0, x1) and x0 != x1: y = y0 + (y1 - y0) * (x - x0) / (x1 - x0) best = y if best is None else min(best, y) return best if best is not None else 2.7OUT = outline()INN = offset(OUT, RIM_T)# ---------------------------------------------------------------- 케이스rim = ring_wall('Case_Rim', OUT, INN, Z_BOT, Z_TOP, EBONY)bottom_pts = offset([(-HW, -0.03), (HW, -0.03)] + OUT[2:], 0.008)case_bottom = slab('Case_Bottom', bottom_pts, Z_BOT - 0.025, Z_BOT + 0.001, EBONY)soundboard = slab('Soundboard', offset(OUT, RIM_T - 0.004), 0.785, 0.80, SPRUCE)liner = ring_wall('Rim_Liner', offset(OUT, RIM_T - 0.003), offset(OUT, RIM_T + 0.035), 0.80, 0.83, EBONY)# 치크 블록 (건반 양옆) — 측면 프로필을 X 방향으로 돌출cheek_prof = [(-0.03, Z_BOT), (Y_FRONT, Z_BOT), (Y_FRONT, Z_TOP), (0.262, Z_TOP), (0.232, 0.972), (0.205, 0.952), (0.182, 0.92), (0.162, 0.876), (0.142, 0.836), (0.118, 0.812), (0.085, 0.802), (0.0, 0.80), (-0.022, 0.796), (-0.03, 0.785)]cw = HW - CHEEK_Xfor side, sx in (('L', -1), ('R', 1)): ck = extrude_profile(f'Cheek_Block_{side}', cheek_prof, cw, loc=(sx * (CHEEK_X + cw / 2), 0, 0), material=EBONY, axis='X') sharpen(ck)# ---------------------------------------------------------------- 건반부key_frame = box('Key_Frame', (2 * CHEEK_X - 0.002, 0.215, 0.08), loc=(0, 0.0775, Z_BOT), material=EBONY, base=True)key_slip = box('Key_Slip', (2 * CHEEK_X - 0.002, 0.022, 0.078), loc=(0, -0.011, Z_BOT + 0.014), material=EBONY, base=True)felt_strip = box('Key_Felt_Strip', (2 * CHEEK_X - 0.004, 0.012, 0.004), loc=(0, 0.176, 0.722), material=FELT_R, base=True)NW = 52kw = 0.0235kx0 = -NW * kw / 2wkey = box('White_Key', (kw - 0.0013, 0.17, 0.022), loc=(0, 0.085, 0.70), material=IVORY, base=True)bevel(wkey, 0.0015, 1)bkey_proto = box('Black_Key', (0.0118, 0.095, 0.034), loc=(0, 0.1225, 0.701), material=KEYBLK, base=True)bevel(bkey_proto, 0.002, 1)names = 'ABCDEFG'nb = 0for i in range(NW): cx = kx0 + (i + 0.5) * kw k = wkey if i == 0 else duplicate(wkey, name=f'White_Key') k.location.x = cx if names[i % 7] in 'ACDFG' and i < NW - 1: b = bkey_proto if nb == 0 else duplicate(bkey_proto, name='Black_Key') b.location.x = kx0 + (i + 1) * kw nb += 1# 건반 뚜껑(열린 상태로 세워진 폴보드)과 네임보드 블록fallboard = box('Fallboard', (2 * CHEEK_X - 0.004, 0.03, 0.13), loc=(0, 0.198, 0.724), rot=(math.radians(-8), 0, 0), material=EBONY, base=True)bevel(fallboard, 0.006, 2)nameboard = box('Nameboard_Block', (2 * CHEEK_X - 0.002, 0.11, 0.25), loc=(0, 0.258, Z_BOT), material=EBONY, base=True)logo = box('Fallboard_Inlay', (0.16, 0.004, 0.012), loc=(0, 0.2015, 0.80), rot=(math.radians(-8), 0, 0), material=GOLD)# 보면대desk_angle = math.radians(-14)desk = box('Music_Desk', (0.86, 0.016, 0.30), loc=(0, 0.262, 0.868), rot=(desk_angle, 0, 0), material=EBONY, base=True)bevel(desk, 0.004, 2)desk_ledge = box('Music_Desk_Ledge', (0.86, 0.05, 0.016), loc=(0, 0.245, 0.869), material=EBONY, base=True)bevel(desk_ledge, 0.003, 1)desk_lip = box('Music_Desk_Lip', (0.86, 0.008, 0.026), loc=(0, 0.222, 0.869), material=EBONY, base=True)desk_back = box('Music_Desk_Support', (0.05, 0.12, 0.012), loc=(0, 0.33, 0.866), rot=(math.radians(28), 0, 0), material=EBONY)# ---------------------------------------------------------------- 철골 프레임(플레이트)PZ0, PZ1 = 0.83, 0.85plate = slab('Iron_Plate', offset(OUT, RIM_T + 0.012), PZ0, PZ1, GOLD)open_pts = offset(outline(0.56), RIM_T + 0.13)cut = prism('Plate_Opening_Cutter', open_pts, 0.1, loc=(0, 0, PZ0 - 0.03))boolean(plate, cut)# 앞쪽 바와 방사형 지지대struts = [((-0.66, 0.60), (-0.55, 2.45)), ((-0.30, 0.60), (-0.38, 2.50)), ((0.05, 0.60), (-0.08, 2.32)), ((0.38, 0.60), (0.17, 1.80)), ((0.64, 0.60), (0.50, 1.20))]for i, ((x0, y0), (x1, y1)) in enumerate(struts): L = math.hypot(x1 - x0, y1 - y0) ang = math.atan2(-(x1 - x0), y1 - y0) s = box(f'Plate_Strut_{i}', (0.06, L, 0.022), loc=((x0 + x1) / 2, (y0 + y1) / 2, PZ0 - 0.002), rot=(0, 0, ang), material=GOLD, base=True) bevel(s, 0.006, 2)# 플레이트 장식 구멍(앞바의 둥근 구멍)for i, x in enumerate([-0.52, -0.20, 0.12, 0.44]): cylinder(f'Plate_Hole_{i}', r=0.028, h=0.004, loc=(x, 0.545, PZ1 - 0.003), material=FELT_K, seg=24)# ---------------------------------------------------------------- 줄, 핀, 브리지, 댐퍼SZ = 0.858unit = box('String_Unit', (1, 1, 1), material=STEEL)unit_cu = box('String_Unit_Bass', (1, 1, 1), material=COPPER)for u_ in (unit, unit_cu): for v_ in u_.data.vertices: v_.co.y += 0.5 # 줄 원점을 앞끝(핀 쪽)으로pin = cylinder('Tuning_Pin', r=0.0035, h=0.03, loc=(0, 0, PZ1 - 0.002), material=STEEL, seg=8)damper = box('Damper', (0.012, 0.035, 0.03), loc=(0, 0, SZ + 0.0015), material=FELT_K, base=True)damper_top = box('Damper_Head', (0.010, 0.03, 0.012), loc=(0, 0, SZ + 0.031), material=MAPLE, base=True)unit.hide_render = unit_cu.hide_render = Trueunit.hide_viewport = unit_cu.hide_viewport = Trueunit['atelier_cutter'] = unit_cu['atelier_cutter'] = TrueY_PIN = 0.40first = Truedef string(proto, name, p0, p1, z, w): dx, dy = p1[0] - p0[0], p1[1] - p0[1] L = math.hypot(dx, dy) s = duplicate(proto, name=name) s.hide_render = s.hide_viewport = False del s['atelier_cutter'] s.location = (p0[0], p0[1], z) s.rotation_euler = (0, 0, math.atan2(-dx, dy)) s.scale = (w, L, w) return sbridge_pts = []N_TREBLE = 66tx0, tx1 = -0.40, 0.67for c in range(N_TREBLE): t = c / (N_TREBLE - 1) cx = tx0 + (tx1 - tx0) * t yend = y_on_bent(cx + 0.06) - 0.16 if cx > -0.30 else 2.30 - (cx + 0.40) * 0.6 yend = min(yend, 2.25) bridge_pts.append((cx, yend - 0.05)) for k in (-1, 0, 1): x = cx + k * 0.0042 string(unit, 'String_Steel', (x, Y_PIN + 0.01 * (k % 2)), (x, yend), SZ, 0.0016) p = duplicate(pin, name='Tuning_Pin', loc=(x, Y_PIN - 0.03 * (k % 2) - 0.015 * (k == 0), PZ1 - 0.002)) if cx < 0.45: duplicate(damper, name='Damper', loc=(cx, 0.585, SZ + 0.0015)) duplicate(damper_top, name='Damper_Head', loc=(cx, 0.585, SZ + 0.031))N_BASS = 20for c in range(N_BASS): t = c / (N_BASS - 1) fx = -0.735 + 0.30 * t rx = -0.62 + 0.52 * t ry = 2.42 - 0.08 * t for k in (0, 1): x = fx + k * 0.006 string(unit_cu, 'String_Bass_Copper', (x, Y_PIN), (rx + k * 0.006, ry), SZ + 0.014, 0.0028) duplicate(pin, name='Tuning_Pin', loc=(x, Y_PIN - 0.02 * k, PZ1 - 0.002)) # 댐퍼: 줄을 따라 y=0.585 지점 u = (0.585 - Y_PIN) / (ry - Y_PIN) dxp = fx + 0.003 + (rx - fx) * u duplicate(damper, name='Damper', loc=(dxp, 0.585, SZ + 0.015)) duplicate(damper_top, name='Damper_Head', loc=(dxp, 0.585, SZ + 0.045))pin.hide_render = pin.hide_viewport = Truedamper.hide_render = damper.hide_viewport = Truedamper_top.hide_render = damper_top.hide_viewport = Truefor o in (pin, damper, damper_top): o['atelier_cutter'] = True# 브리지: 향판 위의 곡선 받침bp = catmull(bridge_pts[::6] + [bridge_pts[-1]], 6)for j, z in enumerate((0.81, 0.828, 0.846)): curve_tube(f'Treble_Bridge_{j}', [(x, y, z) for x, y in bp], radius=0.0115, material=MAPLE)for j, z in enumerate((0.81, 0.83, 0.85, 0.858)): curve_tube(f'Bass_Bridge_{j}', [(-0.62 + 0.52 * t, 2.42 - 0.08 * t - 0.08, z) for t in (0, 0.5, 1)], radius=0.0125, material=MAPLE)# 아그라프 바 (줄 앞단 받침)box('Agraffe_Bar', (1.42, 0.02, 0.008), loc=(-0.02, Y_PIN + 0.03, PZ1), material=GOLD, base=True)# ---------------------------------------------------------------- 뚜껑LID_ANGLE = math.radians(39)HX = -HWlid_pts = [(x - HX, y) for x, y in offset(OUT, -0.004)]lid = prism('Lid', lid_pts, 0.024, material=EBONY)sharpen(lid)bevel(lid, 0.004, 2)flap = box('Lid_Front_Flap', (W - 0.01, 0.30, 0.02), loc=(W / 2, Y_FRONT + 0.15, 0.024), material=EBONY, base=True)bevel(flap, 0.004, 2)lid_pivot = bpy.data.objects.new('Lid_Hinge_Pivot', None)link(lid_pivot)lid_pivot.location = (HX, 0, Z_TOP)lid_pivot.rotation_euler = (0, -LID_ANGLE, 0)lid.parent = lid_pivotflap.parent = lid_pivotfor i, y in enumerate((0.55, 1.45, 2.30)): cylinder(f'Lid_Hinge_{i}', r=0.008, h=0.09, loc=(HX - 0.002, y - 0.045, Z_TOP + 0.002), rot=(-math.pi / 2, 0, 0), material=BRASS, seg=12)# 뚜껑 받침대: 벤트사이드 림 위 → 뚜껑 밑면sb_y = 1.10sb_x = x_on_bent(sb_y) - RIM_T / 2d_top = 1.40top = Vector((HX + d_top * math.cos(LID_ANGLE), sb_y, Z_TOP + d_top * math.sin(LID_ANGLE)))base_p = Vector((sb_x, sb_y, Z_TOP))v = top - base_pstick = cylinder('Lid_Prop_Stick', r=0.013, h=v.length, loc=base_p, material=EBONY, seg=16, r2=0.011)stick.rotation_euler = v.to_track_quat('Z', 'Y').to_euler()smooth(stick)cylinder('Prop_Stick_Cup', r=0.02, h=0.01, loc=base_p, material=BRASS, seg=16)# ---------------------------------------------------------------- 다리와 캐스터leg_prof = [(0.0, 0.035), (0.05, 0.035), (0.052, 0.045), (0.050, 0.10), (0.046, 0.11), (0.045, 0.12), (0.052, 0.13), (0.054, 0.14), (0.058, 0.30), (0.064, 0.46), (0.07, 0.49), (0.078, 0.50), (0.078, 0.53), (0.07, 0.54), (0.07, 0.56), (0.0, 0.56)]legs_xy = [(-0.68, 0.14), (0.68, 0.14), (-0.36, 2.38)]leg_proto = lathe('Leg', leg_prof, material=EBONY, seg=32)sharpen(leg_proto, 40)ferrule_proto = lathe('Leg_Ferrule', [(0.0, 0.036), (0.053, 0.036), (0.055, 0.045), (0.053, 0.115), (0.0, 0.115)], material=BRASS, seg=32)caster_wheel = cylinder('Caster_Wheel', r=0.034, h=0.022, loc=(0, -0.011, 0.034), rot=(-math.pi / 2, 0, 0), material=BRASS, seg=24, base=False)caster_wheel.location = (0, 0, 0.034)caster_fork = box('Caster_Fork', (0.05, 0.03, 0.02), loc=(0, 0, 0.034), material=BRASS, base=True)for i, (x, y) in enumerate(legs_xy): for proto in (leg_proto, ferrule_proto, caster_fork): o = proto if i == 0 else duplicate(proto) o.location.x, o.location.y = x, y cw_ = caster_wheel if i == 0 else duplicate(caster_wheel) cw_.location = (x, y, 0.034) blk = box(f'Leg_Block_{i}', (0.16, 0.16, 0.04), loc=(x, y, Z_BOT - 0.064), material=EBONY, base=True) bevel(blk, 0.01, 2)# ---------------------------------------------------------------- 리라와 페달LY = 0.20pbox = box('Pedal_Box', (0.36, 0.13, 0.07), loc=(0, LY, 0.065), material=EBONY, base=True)bevel(pbox, 0.01, 2)for side, sx in (('L', -1), ('R', 1)): pts = [(sx * 0.13, LY, 0.13), (sx * 0.16, LY, 0.22), (sx * 0.15, LY, 0.33), (sx * 0.10, LY, 0.43), (sx * 0.085, LY, 0.52), (sx * 0.10, LY, 0.59)] curve_tube(f'Lyre_Arm_{side}', pts, radius=0.022, material=EBONY, resolution=8)for i, x in enumerate((-0.035, 0.0, 0.035)): cylinder(f'Lyre_String_{i}', r=0.004, h=0.40, loc=(x, LY, 0.135), material=BRASS, seg=8)box('Lyre_Top_Block', (0.30, 0.10, 0.04), loc=(0, LY, Z_BOT - 0.064), material=EBONY, base=True)box('Lyre_Bottom_Rail', (0.30, 0.05, 0.02), loc=(0, LY, 0.133), material=EBONY, base=True)for side, sx in (('L', -1), ('R', 1)): curve_tube(f'Lyre_Brace_{side}', [(sx * 0.15, LY + 0.06, 0.10), (sx * 0.20, 0.62, Z_BOT - 0.03)], radius=0.009, material=EBONY)for i, x in enumerate((-0.085, 0.0, 0.085)): pd = box(f'Pedal_{i}', (0.035, 0.11, 0.012), loc=(x, LY - 0.11, 0.085), material=BRASS, base=True) bevel(pd, 0.004, 2) box(f'Pedal_Shank_{i}', (0.016, 0.06, 0.01), loc=(x, LY - 0.06, 0.086), material=BRASS, base=True)






