import { Vec2, v2 } from '../geometry/Vec2'; import { polygonArea, roundConvex, roundPolygon } from '../geometry/Polygon'; import { getSlowFoodProfile, slowCutPositions, slowOutline, SLOW_SLICE_WIDTH, type SlowFoodProfile } from './SlowFood'; import { gummyBearOutline } from './GummyBear'; export type JellyType = | 'watermelon' | 'orange' | 'rainbow' | 'bear' | 'robot' | 'keyboard' | 'cassette' | 'watergun' | 'hairdryer' | 'guitar' | 'mouse' | 'pan' | 'hammer' | 'key' | StampType | FruitVegType | SlowFoodType; /** 틀 찍기 평평한 사물(하위 프로젝트 F1): 단단한 실물 껍질 + 굽은 외곽 SDF. 40~45레벨이 쓴다 */ export type StampType = 'coaster' | 'biscuit' | 'phone' | 'comic' | 'tray' | 'encyclopedia'; const STAMP_KINDS: readonly StampType[] = ['coaster', 'biscuit', 'phone', 'comic', 'tray', 'encyclopedia']; /** 틀 찍기 사물 종류(이 순서). 다른 젤리 종류와 섞어 `includes` 로 판정할 수 있게 JellyType 배열로 내보낸다 */ export const STAMP_TYPES: readonly JellyType[] = STAMP_KINDS; /** 과일·채소 젤리(하위 프로젝트 C1): 반투명 젤리 표면 + 굽은 외곽 SDF */ export type FruitVegType = 'lemon' | 'kiwi' | 'strawberry' | 'apple' | 'dragonfruit' | 'banana' | 'pear' | 'avocado' | 'tomato' | 'cucumber' | 'carrot' | 'pepper' | 'mushroom'; export const FRUIT_VEG_TYPES: readonly FruitVegType[] = ['lemon', 'kiwi', 'strawberry', 'apple', 'dragonfruit', 'banana', 'pear', 'avocado', 'tomato', 'cucumber', 'carrot', 'pepper', 'mushroom']; /** * 천천히 썰기 음식(하위 프로젝트 G, 레벨 5~10): 반투명 젤리 표면 + 굽은 외곽 SDF. 긴 축이 x 이고, * 단면(z, y)에는 맑은 젤리 블록 속에 동그란 과육 원판이 박혀 보인다(G 스펙 5절). */ export type SlowFoodType = 'longcucumber' | 'zucchini' | 'mango' | 'papaya' | 'melon' | 'eggplant'; const SLOW_KINDS: readonly SlowFoodType[] = ['longcucumber', 'zucchini', 'mango', 'papaya', 'melon', 'eggplant']; /** 천천히 썰기 음식 종류(이 순서). `includes` 로 판정할 수 있게 JellyType 배열로 내보낸다 */ export const SLOW_TYPES: readonly JellyType[] = SLOW_KINDS; /** 천천히 썰기 음식의 대표 과육색(선형 RGB 0..1). 확 밀면 튀는 파편의 색이다(G 스펙 6.3절) */ export const SLOW_TINT: Record = { // 긴 오이: 셰이더의 진한 과육 띠(0.3, 0.62, 0.16)와 연두 과육(0.6, 0.86, 0.34) 사이. 밝은 크림 테이블 위에서 초록으로 읽히게 진하게 둔다 longcucumber: [0.4, 0.7, 0.22], // 나머지 음식: 단면 과육색보다 조금 진하게(크림 과육은 껍질 쪽 색을 섞어 테이블 위에서 보이게) zucchini: [0.62, 0.74, 0.32], mango: [0.98, 0.52, 0.08], papaya: [0.95, 0.42, 0.2], melon: [0.58, 0.82, 0.36], eggplant: [0.78, 0.7, 0.5], }; /** 셰이더 색상장에서 쓰는 원본 형상 파라미터(젤리 좌표). */ export interface JellyShapeParams { /** 수박: 부채꼴 중심(꼭짓점)과 반경, 반각 */ wedgeCenter: Vec2; wedgeRadius: number; wedgeHalfAngle: number; /** 오렌지: 원 반경 */ circleRadius: number; /** 무지개: 사각형 반폭 */ boxHalf: Vec2; cornerRadius: number; /** baked 형상의 설계 좌표 변환: 젤리 p = R(rot)·(scale·d) − center */ design: { center: Vec2; rot: number; scale: number }; } export interface JellyShape { type: JellyType; /** 원본 외곽(CCW 단순 다각형, 오목 모서리 필릿 ≥ 0.12) */ outline: Vec2[]; thickness: number; area: number; shader: JellyShapeParams; /** 겉면 표현: jelly=반투명 젤리, solid=바깥면은 불투명 실물(jSkin), 절단면만 젤리 */ surface: 'jelly' | 'solid'; /** 원본 외곽 SDF: analytic=셰이더 해석식(jShapeSd), baked=외곽 SDF 텍스처 */ outlineSdf: 'analytic' | 'baked'; /** 천천히 썰기의 실제 둥근 음식 경로. 다른 모드의 압출 메시에는 없다. */ slowProfile?: SlowFoodProfile; slowCuts?: readonly number[]; } const EMPTY_PARAMS: JellyShapeParams = { wedgeCenter: v2(0, 0), wedgeRadius: 0, wedgeHalfAngle: 0, circleRadius: 0, boxHalf: v2(0, 0), cornerRadius: 0, design: { center: v2(0, 0), rot: 0, scale: 1 }, }; function watermelon(): JellyShape { // 꼭짓점이 화면 위쪽(-z, 먼 쪽), 껍질 호가 화면 아래쪽(+z, 카메라 쪽)을 향하는 쐐기 const radius = 1.75; const half = (38 * Math.PI) / 180; const apex = v2(0, -0.85); const pts: Vec2[] = [apex]; const n = 72; for (let i = 0; i <= n; i++) { const a = Math.PI / 2 - half + (2 * half * i) / n; pts.push(v2(apex.x + Math.cos(a) * radius, apex.y + Math.sin(a) * radius)); } // 바운딩 박스 중앙 정렬 const ys = pts.map((p) => p.y); const cy = (Math.min(...ys) + Math.max(...ys)) / 2; const shifted = pts.map((p) => v2(p.x, p.y - cy)); const corner = 0.16; const outline = roundConvex(shifted, corner, Math.PI / 30); return { type: 'watermelon', outline, thickness: 0.5, area: polygonArea(outline), surface: 'jelly', outlineSdf: 'analytic', shader: { ...EMPTY_PARAMS, wedgeCenter: v2(apex.x, apex.y - cy), wedgeRadius: radius, wedgeHalfAngle: half, cornerRadius: corner }, }; } function orange(): JellyShape { const r = 0.95; const n = 128; const outline = Array.from({ length: n }, (_, i) => v2(Math.cos((i / n) * Math.PI * 2) * r, Math.sin((i / n) * Math.PI * 2) * r)); return { type: 'orange', outline, thickness: 0.45, area: polygonArea(outline), surface: 'jelly', outlineSdf: 'analytic', shader: { ...EMPTY_PARAMS, circleRadius: r, cornerRadius: r }, }; } /** 둥근 사각형 젤리(무지개·사물 공용). 셰이더는 같은 반폭·모서리로 SDF를 만든다. */ function roundedBox( type: JellyType, hx: number, hy: number, corner: number, thickness: number, surface: JellyShape['surface'], ): JellyShape { const outline = roundConvex([v2(-hx, -hy), v2(hx, -hy), v2(hx, hy), v2(-hx, hy)], corner, Math.PI / 30); return { type, outline, thickness, area: polygonArea(outline), surface, outlineSdf: 'analytic', shader: { ...EMPTY_PARAMS, boxHalf: v2(hx, hy), cornerRadius: corner } }; } /** 설계 좌표 제어점 + 꼭짓점별 필릿 반경. 회전(도)·축척 후 필릿하고 외접 사각형 중심을 원점으로 옮긴다. */ interface DesignDef { pts: [number, number][]; radii: number[]; rotDeg: number; scale: number; thickness: number; surface: JellyShape['surface']; } /** * 설계 제어점으로 만드는 형상(오목 사물·과일·채소, 외곽 SDF 는 텍스처로 굽는다). 젤리 좌표 p = R(rot)·(scale·d) − center 이고, * 셰이더 jDesignOf 가 역변환해 부품을 설계 좌표 d 로 적는다. 필릿 반경은 축척하지 않는다(오목 0.12 하한 유지). */ function designed(type: JellyType, def: DesignDef): JellyShape { const rot = (def.rotDeg * Math.PI) / 180; const c = Math.cos(rot); const s = Math.sin(rot); const placed = def.pts.map(([x, y]) => v2((x * c - y * s) * def.scale, (x * s + y * c) * def.scale)); const raw = roundPolygon(placed, def.radii, Math.PI / 30); const xs = raw.map((p) => p.x); const ys = raw.map((p) => p.y); const center = v2((Math.min(...xs) + Math.max(...xs)) / 2, (Math.min(...ys) + Math.max(...ys)) / 2); const outline = raw.map((p) => v2(p.x - center.x, p.y - center.y)); return { type, outline, thickness: def.thickness, area: polygonArea(outline), surface: def.surface, outlineSdf: 'baked', shader: { ...EMPTY_PARAMS, design: { center, rot, scale: def.scale } }, }; } // 스크래치 검증을 마친 시작값(2026-10-13): 크기·필릿·메시 코어·풀이 가능성·눈대중 함정 기준을 모두 통과한다 const WATERGUN: DesignDef = { // 왼쪽 노즐 → 총신 → 위 물탱크 → 총신 뒤 → 오른쪽 아래 손잡이 // 수박 외접 사각형(반폭 1.0077) 안에 들도록 노즐 끝을 −1.0, 손잡이 아래 끝을 1.0 으로 줄이고, // 필릿 하한(오목 0.12·볼록 0.05)을 맞추려 노즐 아랫변을 −0.07 로 내렸다(총신 아래 볼록 0.05 + 오목 0.12 = 변 길이 0.17) pts: [[-1.0, -0.34], [-0.72, -0.34], [-0.72, -0.52], [-0.18, -0.52], [-0.18, -0.84], [0.5, -0.84], [0.5, -0.52], [0.95, -0.52], [0.95, 0.04], [1.0, 0.8], [0.58, 0.82], [0.36, 0.1], [-0.72, 0.1], [-0.72, -0.07], [-1.0, -0.07]], radii: [0.05, 0.12, 0.05, 0.12, 0.16, 0.16, 0.12, 0.1, 0.12, 0.1, 0.1, 0.16, 0.05, 0.12, 0.05], rotDeg: 0, scale: 1, thickness: 0.42, surface: 'solid', }; const HAIRDRYER: DesignDef = { // 왼쪽으로 가늘어지는 노즐, 오른쪽 위 둥근 머리, 아래로 기울어진 손잡이 // 수박 외접 사각형(반폭 1.0077) 안에 들도록 노즐 끝을 x = −0.98 에서 자른다(노즐 위아래 변의 기울기는 그대로) pts: [[-0.98, -0.458], [-0.1, -0.56], [-0.1, -0.86], [1.02, -0.86], [1.02, 0.25], [0.56, 0.25], [0.7, 0.86], [0.28, 0.86], [0.18, 0.25], [-0.1, 0.25], [-0.1, -0.02], [-0.98, -0.122]], radii: [0.06, 0.12, 0.18, 0.45, 0.3, 0.12, 0.1, 0.1, 0.13, 0.13, 0.12, 0.06], rotDeg: 0, scale: 1, thickness: 0.45, surface: 'solid', }; const GUITAR: DesignDef = { // 왼쪽 헤드 → 넥(폭 0.28) → 오른쪽 비대칭 더블 컷어웨이 바디. 전체 −10° 기울임, 0.94배 pts: [[-1.07, -0.34], [-0.78, -0.34], [-0.78, -0.14], [0.1, -0.14], [-0.02, -0.48], [0.32, -0.36], [0.75, -0.5], [1.06, -0.18], [1.0, 0.36], [0.68, 0.52], [0.4, 0.4], [0.12, 0.42], [0.12, 0.14], [-0.78, 0.14], [-0.78, 0.34], [-1.07, 0.34]], radii: [0.06, 0.06, 0.12, 0.12, 0.08, 0.15, 0.25, 0.25, 0.22, 0.22, 0.15, 0.08, 0.12, 0.12, 0.06, 0.06], rotDeg: -10, scale: 0.94, thickness: 0.36, surface: 'solid', }; const MOUSE: DesignDef = { // 위가 좁은 물방울형, 왼쪽 아래 엄지 받침과 그 위 잘록한 허리. 전체 +10° 기울임 pts: [[-0.3, -0.85], [0.3, -0.85], [0.56, -0.1], [0.5, 0.82], [-0.4, 0.86], [-0.66, 0.42], [-0.42, 0.06], [-0.46, -0.4]], radii: [0.25, 0.25, 0.5, 0.25, 0.22, 0.18, 0.3, 0.4], rotDeg: 10, scale: 1, thickness: 0.5, surface: 'solid', }; const PAN: DesignDef = { // 원 팬(중심 (−0.36, 0), 반지름 0.62) + 오른쪽 나무 손잡이(중심선 z = −0.07, 폭 0.26). 원 둘레는 20등분(반경은 인접 변에 맞춘 원호), // 대칭을 깨려고 손잡이를 팬 중심에서 0.07 위로 비켜 달고, 같은 쪽 위(235°)에 따르는 주둥이를 낸다. 전체 −12° 기울임. // 스크래치 검증(2026-10-15): 외접 사각형 중심선 V 37.9·H 69.6·주축 53.9, 손잡이 중심선 84.2, 팬 중심 손잡이 방향 81.0 pts: [[0.233, 0.18], [0.163, 0.333], [0.054, 0.461], [-0.085, 0.556], [-0.245, 0.609], [-0.413, 0.618], [-0.577, 0.581], [-0.725, 0.501], [-0.847, 0.384], [-0.932, 0.239], [-0.975, 0.077], [-0.973, -0.092], [-0.926, -0.253], [-0.88, -0.338], [-0.83, -0.672], [-0.499, -0.604], [-0.398, -0.619], [-0.23, -0.606], [-0.072, -0.549], [0.065, -0.451], [0.171, -0.32], [0.307, -0.2], [1.0, -0.22], [1.0, 0.08], [0.337, 0.06]], radii: [0.12, 0.58, 0.603, 0.574, 0.603, 0.582, 0.586, 0.597, 0.581, 0.591, 0.589, 0.588, 0.436, 0.14, 0.07, 0.14, 0.434, 0.596, 0.583, 0.596, 0.12, 0.12, 0.12, 0.12, 0.12], rotDeg: -12, scale: 1, thickness: 0.34, surface: 'solid', }; const HAMMER: DesignDef = { // 옆에서 본 장도리: 왼쪽 자루(중심선 z = 0, 폭 0.28) → 오른쪽 머리(x 0.46~1.0). 위쪽은 넓은 때리는 쪽, 아래쪽은 바깥이 휜 못뽑이(끝은 뭉툭하게 둥금). // 못뽑이 안쪽 면은 곧게 내려 자루 아래에 오목 주머니를 만들지 않는다(반 가르기 직선이 못뽑이 끝을 부스러기로 떼어 내 절단이 거부되지 않도록). // 오목 필릿 하한(0.12) 때문에 못뽑이 V 홈은 외곽으로 내지 않고 셰이더 틈 무늬로 그린다. 때리는 쪽을 무겁게 해 자루 축 대칭을 깬다. // 자루-머리 이음의 오목 반경을 0.28로 크게 둔다. 0.12이면 이음 모서리를 비스듬히 지나는 반 가르기 직선이 3조각·부스러기가 되어 // 정답 각도 36개 중 8개가 튕겼다(0.28이면 0개). // 스크래치 검증(2026-10-16): 외접 사각형 중심선 V 28.6·H 65.4·주축 59.7, 자루 중심선 77.1. 0.96배, −6° 기울임 pts: [[-1.0, -0.14], [0.5, -0.14], [0.46, -0.92], [1.0, -0.92], [0.98, 0.1], [0.94, 0.46], [0.84, 0.72], [0.62, 0.86], [0.54, 0.76], [0.56, 0.14], [-1.0, 0.14]], radii: [0.1, 0.28, 0.07, 0.07, 0.15, 0.3, 0.12, 0.07, 0.06, 0.28, 0.1], rotDeg: -6, scale: 0.96, thickness: 0.36, surface: 'solid', }; const KEY: DesignDef = { // 왼쪽 둥근 머리(중심 (−0.54, 0), 반지름 0.44, 둘레 14등분) → 오른쪽 몸통(중심선 z = 0, 폭 0.3) → 끝. 아래쪽 날에만 톱니 2개(평평한 골). // 톱니가 한쪽에만 있어 몸통 축 대칭이 깨진다. 전체 −8° 기울임. // 스크래치 검증(2026-10-15): 외접 사각형 중심선 V 73.6·H 71.9·주축 70.0, 몸통 중심선 83.2 pts: [[-0.178, -0.25], [-0.29, -0.362], [-0.435, -0.427], [-0.593, -0.437], [-0.745, -0.39], [-0.869, -0.292], [-0.951, -0.156], [-0.98, 0], [-0.951, 0.156], [-0.869, 0.292], [-0.745, 0.39], [-0.593, 0.437], [-0.435, 0.427], [-0.29, 0.362], [-0.178, 0.25], [0, 0.15], [0.12, 0.44], [0.3, 0.44], [0.38, 0.19], [0.56, 0.19], [0.64, 0.44], [0.76, 0.44], [0.83, 0.15], [1.0, 0.15], [1.0, -0.15]], radii: [0.12, 0.2, 0.2, 0.2, 0.2, 0.2, 0.2, 0.2, 0.2, 0.2, 0.2, 0.2, 0.2, 0.2, 0.12, 0.12, 0.06, 0.06, 0.12, 0.12, 0.06, 0.06, 0.12, 0.06, 0.06], rotDeg: -8, scale: 1, thickness: 0.3, surface: 'solid', }; /** * 타원 제어점 n개(각 0부터 반시계), warp 로 변형하고 소수 셋째 자리로 반올림한다. * 반경은 꼭짓점마다 인접 변의 짧은 쪽 길이 × 0.48 만큼 필릿 접점이 들어가게 정해, 직선 구간 없이 원호로 이어진 매끄러운 외곽이 된다. * minR 은 반경 하한(볼록 필릿 ≥ 0.05)이다. */ function oval(rx: number, ry: number, n: number, minR: number, warp: (x: number, y: number) => [number, number] = (x, y) => [x, y]): { pts: [number, number][]; radii: number[] } { const r3 = (x: number) => Math.round(x * 1000) / 1000; const pts: [number, number][] = []; for (let i = 0; i < n; i++) { const a = (i / n) * Math.PI * 2; const [x, y] = warp(Math.cos(a) * rx, Math.sin(a) * ry); pts.push([r3(x), r3(y)]); } const len = (i: number, j: number) => Math.hypot(pts[j][0] - pts[i][0], pts[j][1] - pts[i][1]); const radii = pts.map((b, i) => { const a = pts[(i - 1 + n) % n]; const c = pts[(i + 1) % n]; const ux = a[0] - b[0], uy = a[1] - b[1], wx = c[0] - b[0], wy = c[1] - b[1]; const half = Math.acos(Math.max(-1, Math.min(1, (ux * wx + uy * wy) / (Math.hypot(ux, uy) * Math.hypot(wx, wy))))) / 2; const tan = 0.48 * Math.min(len((i - 1 + n) % n, i), len(i, (i + 1) % n)); return Math.max(minR, r3(tan * Math.tan(half))); }); return { pts, radii }; } /** 반투명 젤리 과일·채소 정의(눕힘·축척 없음) */ function fruit(shape: { pts: [number, number][]; radii: number[] }, thickness: number): DesignDef { return { pts: shape.pts, radii: shape.radii, rotDeg: 0, scale: 1, thickness, surface: 'jelly' }; } /** 반경 일부를 바꾼 사본 */ function withRadii(shape: { pts: [number, number][]; radii: number[] }, over: Record) { return { pts: shape.pts, radii: shape.radii.map((r, i) => over[i] ?? r) }; } // 과일·채소 시작값(스크래치 검증 2026-10-15): 형상 규칙(필릿·국소 폭·외접 사각형·면적)과 3~4·11~21레벨 가이드 조건을 통과한다. // 좌표는 설계 좌표(+x 오른쪽, +y 화면 아래)다. 길쭉한 종류는 긴 축을 가로로 눕힌다. const FRUIT_VEG: Record = { // 가로 타원의 양 끝을 늘려 뾰족하게 lemon: fruit(withRadii(oval(0.86, 0.6, 24, 0.05, (x, y) => [x * (1 + 0.16 * Math.pow(Math.abs(x) / 0.86, 6)), y]), { 0: 0.06, 12: 0.06 }), 0.42), kiwi: fruit(oval(0.92, 0.72, 24, 0.05), 0.4), // 위가 넓고 아래(+y)가 뾰족한 둥근 역삼각형 strawberry: fruit(withRadii(oval(0.8, 0.85, 24, 0.05, (x, y) => [x * (1 - (0.45 * Math.max(0, y)) / 0.85) * (1 + (0.08 * Math.max(0, -y)) / 0.85), y]), { 6: 0.12 }), 0.45), // 위가 조금 넓은 원 apple: fruit(oval(0.9, 0.85, 24, 0.05, (x, y) => [x * (1 + (0.06 * -y) / 0.85), y]), 0.45), dragonfruit: fruit(oval(0.95, 0.68, 24, 0.05), 0.42), // 오른쪽이 좁은 달걀형 avocado: fruit(oval(0.95, 0.68, 24, 0.05, (x, y) => [x, y * (1 - (0.28 * x) / 0.95)]), 0.45), tomato: fruit(oval(0.92, 0.86, 24, 0.05), 0.45), cucumber: fruit({ pts: [[-1, -0.36], [1, -0.36], [1, 0.36], [-1, 0.36]], radii: [0.3, 0.3, 0.3, 0.3] }, 0.4), // 왼쪽 굵은 머리 → 오른쪽 가는 끝 carrot: fruit({ pts: [[-1, -0.5], [0.95, -0.1], [1, 0], [0.95, 0.1], [-1, 0.5]], radii: [0.2, 0.08, 0.05, 0.08, 0.2] }, 0.4), // 위가 평평하고 아래가 둥글게 모이는 파프리카 pepper: fruit({ pts: [[-0.85, -0.7], [0.85, -0.7], [0.92, 0.55], [0, 0.88], [-0.92, 0.55]], radii: [0.35, 0.35, 0.4, 0.45, 0.4] }, 0.45), // 오목: 양 끝이 위로 휜 초승달, 왼쪽 끝 꼭지 banana: fruit({ pts: [[-1, -0.62], [-0.86, -0.74], [-0.4, -0.04], [0.4, -0.04], [0.86, -0.66], [1, -0.52], [0.52, 0.44], [-0.52, 0.44]], radii: [0.05, 0.06, 0.6, 0.6, 0.08, 0.1, 0.55, 0.55] }, 0.38), // 오목: 위가 좁고 아래가 넓은 오뚝이, 잘록한 허리 pear: fruit({ pts: [[-0.3, -0.92], [0.3, -0.92], [0.42, -0.35], [0.82, 0.25], [0.7, 0.88], [-0.7, 0.88], [-0.82, 0.25], [-0.42, -0.35]], radii: [0.25, 0.25, 0.3, 0.4, 0.35, 0.35, 0.4, 0.3] }, 0.45), // 오목: 위 넓은 갓 + 아래 기둥 mushroom: fruit({ pts: [[-1, -0.1], [-0.75, -0.75], [0.75, -0.75], [1, -0.1], [0.32, -0.02], [0.36, 0.92], [-0.36, 0.92], [-0.32, -0.02]], radii: [0.18, 0.45, 0.45, 0.18, 0.12, 0.12, 0.12, 0.12] }, 0.45), }; /** 반폭 hx × hy 사각형에 같은 모서리 반경 r 을 준 설계 제어점 */ function rect(hx: number, hy: number, r: number): { pts: [number, number][]; radii: number[] } { return { pts: [[-hx, -hy], [hx, -hy], [hx, hy], [-hx, hy]], radii: [r, r, r, r] }; } /** 단단한 평평한 사물 정의(눕힘·축척 없음) */ function flat(shape: { pts: [number, number][]; radii: number[] }, thickness: number): DesignDef { return { pts: shape.pts, radii: shape.radii, rotDeg: 0, scale: 1, thickness, surface: 'solid' }; } // 틀 찍기 평평한 사물 시작값(스크래치 검증 2026-10-17): 형상 규칙(필릿·국소 폭·외접 사각형)을 통과한다. // 틀로 찍어 내는 판이라 모두 얇고(0.18~0.3) 위에서 본 윗면이 주인공이다. 좌표는 설계 좌표(+x 오른쪽, +y 화면 아래)다. const STAMP: Record = { // 둥근 코르크 컵받침 coaster: flat(oval(0.88, 0.88, 24, 0.05), 0.22), // 직사각 비스킷 biscuit: flat(rect(1.0, 0.7, 0.16), 0.24), // 가로로 눕힌 스마트폰 phone: flat(rect(1.0, 0.52, 0.2), 0.18), // 세로로 선 코믹북. 세로 반폭은 수박 외곽의 실제 세로 반폭(0.8749932, 원호 근사) 안에 들도록 0.8749 comic: flat(rect(0.8, 0.8749, 0.06), 0.2), // 양옆 손잡이가 튀어나온 쟁반(손잡이 이음은 오목 0.12) tray: flat( { pts: [[-0.82, -0.62], [0.82, -0.62], [0.82, -0.18], [1.0, -0.18], [1.0, 0.18], [0.82, 0.18], [0.82, 0.62], [-0.82, 0.62], [-0.82, 0.18], [-1.0, 0.18], [-1.0, -0.18], [-0.82, -0.18]], radii: [0.28, 0.28, 0.12, 0.06, 0.06, 0.12, 0.28, 0.28, 0.12, 0.06, 0.06, 0.12], }, 0.2, ), // 두꺼운 백과사전(위에서 본 표지). 세로 반폭 0.8749 는 코믹북과 같은 이유 encyclopedia: flat(rect(0.95, 0.8749, 0.05), 0.3), }; const BUILDERS: Record JellyShape> = { watermelon, orange, rainbow: () => roundedBox('rainbow', 1.0, 0.72, 0.22, 0.55, 'jelly'), // 위쪽 귀·둥근 머리, 양옆 팔과 아래쪽 두 발이 한 외곽으로 읽히는 곰 젤리. bear: () => { const raw=gummyBearOutline().map(p=>v2(p.x*0.85,p.y*0.85)); const ys=raw.map(p=>p.y),center=v2(0,(Math.min(...ys)+Math.max(...ys))/2); const outline=raw.map(p=>v2(p.x,p.y-center.y)); return {type:'bear',outline,area:polygonArea(outline),thickness:0.36,surface:'jelly',outlineSdf:'baked',shader:{...EMPTY_PARAMS,design:{center,rot:0,scale:0.85}}}; }, robot: () => roundedBox('robot', 0.9, 0.8, 0.3, 0.6, 'solid'), // 사물은 세로 모바일에서 가장 넓은 과일(화면 폭 약 88%)보다 넓어지지 않게 한다 keyboard: () => roundedBox('keyboard', 1.0, 0.465, 0.125, 0.36, 'solid'), cassette: () => roundedBox('cassette', 0.92, 0.6, 0.14, 0.42, 'solid'), // 오목 실루엣 사물: 외곽 SDF 를 텍스처로 굽는다 watergun: () => designed('watergun', WATERGUN), hairdryer: () => designed('hairdryer', HAIRDRYER), guitar: () => designed('guitar', GUITAR), mouse: () => designed('mouse', MOUSE), pan: () => designed('pan', PAN), hammer: () => designed('hammer', HAMMER), key: () => designed('key', KEY), // 틀 찍기 평평한 사물(F1) ...(Object.fromEntries(STAMP_KINDS.map((t) => [t, () => designed(t, STAMP[t])])) as Record JellyShape>), ...(Object.fromEntries(FRUIT_VEG_TYPES.map((t) => [t, () => designed(t, FRUIT_VEG[t])])) as Record JellyShape>), // 천천히 썰기 음식(G) ...(Object.fromEntries(SLOW_KINDS.map((t) => [t, () => slowFood(t)])) as Record JellyShape>), }; function slowFood(type: SlowFoodType): JellyShape { const p = getSlowFoodProfile(type); const cuts = slowCutPositions(p, SLOW_SLICE_WIDTH, 5); const outline = slowOutline(p, cuts); return { type, outline, area: polygonArea(outline), thickness: 2 * p.radiusY, surface: 'jelly', outlineSdf: 'baked', shader: { ...EMPTY_PARAMS }, slowProfile: p, slowCuts: cuts }; } const cache = new Map(); /** 모든 젤리 종류(`BUILDERS` 순서). 테스트가 종류 목록의 누락을 잡는 데 쓴다. */ export const JELLY_TYPES = Object.keys(BUILDERS) as readonly JellyType[]; export function getJellyShape(type: JellyType): JellyShape { let s = cache.get(type); if (!s) { s = BUILDERS[type](); cache.set(type, s); } return s; }