/* ===================================================================== 리노컷 렌더러 기하 — 결정문 §8.4 P1 리노컷 렌더러 명세의 정본 구현 (2단계-B-1b 1차 반려 후 개정본). React·DOM에 의존하지 않는 순수 함수만 둔다. 입력은 (rig, ChannelVector), 출력은 각 부위의 path d·transform·불투명도다. 렌더러(LinocutAvatar.tsx)가 rAF에서 이 함수를 호출해 SVG 속성을 직접 쓴다(DebugFace와 같은 방식). 좌우 부호는 DebugFace와 같다: 오른쪽 채널의 가로(X) 성분만 부호를 반전한다. ===================================================================== */ import type { ChannelVector } from "../engine/channels"; import type { LinocutRig, RigBrow, RigEye, RigLayer, RigPoint } from "./linocutRig"; type Point = readonly [number, number]; /* ── 공용 수학 ─────────────────────────────────────────────────────── */ function clamp(v: number, lo = 0, hi = 1): number { return v < lo ? lo : v > hi ? hi : v; } function lerp(a: number, b: number, t: number): number { return a + (b - a) * t; } function smoothstep(edge0: number, edge1: number, x: number): number { const t = clamp((x - edge0) / (edge1 - edge0), 0, 1); return t * t * (3 - 2 * t); } /** O–I 직선 위 x 지점의 y(직선 보간). */ function lineY(p0: Point, p1: Point, x: number): number { if (p1[0] === p0[0]) return (p0[1] + p1[1]) / 2; const t = (x - p0[0]) / (p1[0] - p0[0]); return p0[1] + (p1[1] - p0[1]) * t; } /** 세 점 P0·Mid·P2를 지나는 2차 베지어 제어점(t=0.5에서 Mid를 지나도록 역산). */ function quadControlThrough(p0: Point, mid: Point, p2: Point): Point { return [2 * mid[0] - 0.5 * (p0[0] + p2[0]), 2 * mid[1] - 0.5 * (p0[1] + p2[1])]; } function quadPointAt(p0: Point, control: Point, p2: Point, t: number): Point { const mt = 1 - t; return [ mt * mt * p0[0] + 2 * mt * t * control[0] + t * t * p2[0], mt * mt * p0[1] + 2 * mt * t * control[1] + t * t * p2[1], ]; } function pathQ(p0: Point, control: Point, p2: Point): string { return `M ${p0[0]} ${p0[1]} Q ${control[0]} ${control[1]} ${p2[0]} ${p2[1]}`; } function sampleQuad(p0: Point, control: Point, p2: Point, tFrom: number, tTo: number, steps: number): Point[] { const out: Point[] = []; for (let i = 0; i < steps; i++) { const t = lerp(tFrom, tTo, steps === 1 ? 0 : i / (steps - 1)); out.push(quadPointAt(p0, control, p2, t)); } return out; } /** 결정적 의사난수(0..1) — "시드 고정" 텍스처 획 배치용. Math.random 미사용. */ function hash(n: number): number { const x = Math.sin(n * 12.9898) * 43758.5453; return x - Math.floor(x); } function toPoint(p: RigPoint): Point { return [p[0], p[1]]; } /** 16진 색 두 개를 weightA:1-weightA로 섞는다(R9 윗니 색 등). */ function mixHex(hexA: string, hexB: string, weightA: number): string { const na = parseInt(hexA.replace("#", ""), 16); const nb = parseInt(hexB.replace("#", ""), 16); const ra = (na >> 16) & 0xff, ga = (na >> 8) & 0xff, ba = na & 0xff; const rb = (nb >> 16) & 0xff, gb = (nb >> 8) & 0xff, bb = nb & 0xff; const mix = (a: number, b: number) => Math.round(a * weightA + b * (1 - weightA)); return `rgb(${mix(ra, rb)}, ${mix(ga, gb)}, ${mix(ba, bb)})`; } /** p0→p1 진행 방향의 왼쪽 법선(부호는 방향에 종속, 리본 두께 방향에만 쓴다). */ function normal(p0: Point, p1: Point): Point { const dx = p1[0] - p0[0]; const dy = p1[1] - p0[1]; const len = Math.hypot(dx, dy) || 1; return [-dy / len, dx / len]; } /* ── Catmull-Rom(클램프) → 3차 베지어 ───────────────────────────────── 눈썹·입술 바깥선 등 "곡선(꺾인 직선 금지)" 요구를 만족하는 공용 도구다. 양끝은 이웃 점을 복제해(clamped) 자연스러운 끝 접선을 만든다. */ type CubicSeg = readonly [Point, Point, Point, Point]; function catmullRomSegments(points: Point[]): CubicSeg[] { const n = points.length; const get = (i: number): Point => points[Math.min(n - 1, Math.max(0, i))]; const segs: CubicSeg[] = []; for (let i = 0; i < n - 1; i++) { const p0 = get(i - 1); const p1 = get(i); const p2 = get(i + 1); const p3 = get(i + 2); const c1: Point = [p1[0] + (p2[0] - p0[0]) / 6, p1[1] + (p2[1] - p0[1]) / 6]; const c2: Point = [p2[0] - (p3[0] - p1[0]) / 6, p2[1] - (p3[1] - p1[1]) / 6]; segs.push([p1, c1, c2, p2]); } return segs; } function cubicPointAt(p0: Point, c1: Point, c2: Point, p3: Point, t: number): Point { const mt = 1 - t; const a = mt * mt * mt; const b = 3 * mt * mt * t; const c = 3 * mt * t * t; const d = t * t * t; return [ a * p0[0] + b * c1[0] + c * c2[0] + d * p3[0], a * p0[1] + b * c1[1] + c * c2[1] + d * p3[1], ]; } /** 선두 M 없이 이어붙일 수 있는 "C ..." 명령만. */ function catmullRomCommands(points: Point[]): string { return catmullRomSegments(points) .map(([, c1, c2, p3]) => `C ${c1[0]} ${c1[1]} ${c2[0]} ${c2[1]} ${p3[0]} ${p3[1]}`) .join(" "); } function catmullRomPathD(points: Point[]): string { return `M ${points[0][0]} ${points[0][1]} ${catmullRomCommands(points)}`; } /** 전역 매개변수 t(0..1)를 함께 내는 표본. 리본 반두께 보간에 쓴다. */ function sampleCatmullRomWithT(points: Point[], samplesPerSeg: number): Array<{ point: Point; t: number }> { const segs = catmullRomSegments(points); const segCount = segs.length; const out: Array<{ point: Point; t: number }> = []; segs.forEach(([p0, c1, c2, p3], segIndex) => { const start = segIndex === 0 ? 0 : 1; for (let i = start; i <= samplesPerSeg; i++) { const localT = i / samplesPerSeg; out.push({ point: cubicPointAt(p0, c1, c2, p3, localT), t: (segIndex + localT) / segCount }); } }); return out; } /** t에서의 구간 선형 보간값(정지점 배열 [t, value]). 리본 반두께·주름 두께에 쓴다. */ function valueAtStops(t: number, stops: Array): number { for (let i = 0; i < stops.length - 1; i++) { const [t0, v0] = stops[i]; const [t1, v1] = stops[i + 1]; if (t <= t1 || i === stops.length - 2) { const localT = t1 === t0 ? 0 : clamp((t - t0) / (t1 - t0)); return lerp(v0, v1, localT); } } return stops[stops.length - 1][1]; } /** 곡선(3점 이상)을 표본한 뒤 정지점 기반 반두께를 붙인 리본용 데이터. */ function curveRibbonData(anchors: Point[], stops: Array, samplesPerSeg: number) { const withT = sampleCatmullRomWithT(anchors, samplesPerSeg); return { samples: withT.map((s) => s.point), halfWidths: withT.map((s) => valueAtStops(s.t, stops)) }; } /** 정점별 반두께로 폴리라인(또는 곡선 표본)을 채운 리본(다각형 path)으로 만든다. */ function polylineRibbon(points: Point[], halfWidths: number[]): string { const n = points.length; const normals: Point[] = points.map((p, i) => { const a = i > 0 ? normal(points[i - 1], p) : normal(p, points[i + 1]); const b = i < n - 1 ? normal(p, points[i + 1]) : normal(points[i - 1], p); return [(a[0] + b[0]) / 2, (a[1] + b[1]) / 2]; }); const top = points.map((p, i) => [p[0] + normals[i][0] * halfWidths[i], p[1] + normals[i][1] * halfWidths[i]] as Point); const bot = points.map((p, i) => [p[0] - normals[i][0] * halfWidths[i], p[1] - normals[i][1] * halfWidths[i]] as Point); const topStr = top.map((p, i) => `${i === 0 ? "M" : "L"} ${p[0]} ${p[1]}`).join(" "); const botStr = [...bot].reverse().map((p) => `L ${p[0]} ${p[1]}`).join(" "); return `${topStr} ${botStr} Z`; } /** 폴리라인 위 매개변수 t(0..1, 구간마다 등분)의 점·법선·보간값. */ function alongPolyline(points: Point[], values: number[] | null, t: number): { point: Point; normal: Point; value: number } { const segCount = points.length - 1; const scaled = clamp(t, 0, 1) * segCount; const segIndex = Math.min(segCount - 1, Math.floor(scaled)); const localT = scaled - segIndex; const p0 = points[segIndex]; const p1 = points[segIndex + 1]; const point: Point = [lerp(p0[0], p1[0], localT), lerp(p0[1], p1[1], localT)]; const value = values ? lerp(values[segIndex], values[segIndex + 1], localT) : 0; return { point, normal: normal(p0, p1), value }; } /** 접선과, 화면 기준 위쪽을 향하도록 부호를 맞춘 법선(눈썹 털 획 방향용). */ function tangentAndUpNormal(points: Point[], t: number): { tangent: Point; up: Point } { const segCount = points.length - 1; const scaled = clamp(t, 0, 1) * segCount; const segIndex = Math.min(segCount - 1, Math.floor(scaled)); const p0 = points[segIndex]; const p1 = points[segIndex + 1]; const dx = p1[0] - p0[0]; const dy = p1[1] - p0[1]; const len = Math.hypot(dx, dy) || 1; const tangent: Point = [dx / len, dy / len]; const raw: Point = [-tangent[1], tangent[0]]; const up: Point = raw[1] > 0 ? [-raw[0], -raw[1]] : raw; return { tangent, up }; } /** 눈썹 몸체 위로 삐져나오는 털 획 n개(잉크, 뿌리는 리본 폭 ±60% 안에서 흩뿌림). 작은 화면 보정(§8.4)으로 길이에 sizeScale을 곱한다. */ function browHairStrokes(samples: Point[], halfWidths: number[], count: number, seedBase: number, sizeScale: number): string { const segments: string[] = []; for (let i = 0; i < count; i++) { const t = (i + 0.5) / count; const { point, value: halfW } = alongPolyline(samples, halfWidths, t); const { tangent, up } = tangentAndUpNormal(samples, t); const rootFrac = (hash(seedBase + i * 3) * 2 - 1) * 0.6; const root: Point = [point[0] + up[0] * rootFrac * halfW, point[1] + up[1] * rootFrac * halfW]; const angleDeg = t < 1 / 3 ? 35 : 15; const angleRad = (angleDeg * Math.PI) / 180; const dirX = up[0] * Math.cos(angleRad) + tangent[0] * Math.sin(angleRad); const dirY = up[1] * Math.cos(angleRad) + tangent[1] * Math.sin(angleRad); const len = (9 + hash(seedBase + i * 3 + 1) * 5) * sizeScale; const tip: Point = [root[0] + dirX * len, root[1] + dirY * len]; segments.push(`M ${root[0]} ${root[1]} L ${tip[0]} ${tip[1]}`); } return segments.join(" "); } /** 타원 안에 짧게 굽은 획 n개(턱 오돌토돌함). 시드 고정, 위치·각도·굽음 모두 결정적. */ function ellipseCurvedStrokes(center: Point, rx: number, ry: number, count: number, seedBase: number): string[] { const out: string[] = []; for (let i = 0; i < count; i++) { const angle = hash(seedBase + i * 4) * Math.PI * 2; const radiusFrac = Math.sqrt(hash(seedBase + i * 4 + 1)) * 0.7; const cx = center[0] + Math.cos(angle) * rx * radiusFrac; const cy = center[1] + Math.sin(angle) * ry * radiusFrac; const strokeAngle = hash(seedBase + i * 4 + 2) * Math.PI * 2; const len = 8 + hash(seedBase + i * 4 + 3) * 4; const dx = (Math.cos(strokeAngle) * len) / 2; const dy = (Math.sin(strokeAngle) * len) / 2; const p0: Point = [cx - dx, cy - dy]; const p2: Point = [cx + dx, cy + dy]; const half = len / 2 || 1; const bendNormal: Point = [-dy / half, dx / half]; const bend = 2.5; const mid: Point = [cx + bendNormal[0] * bend, cy + bendNormal[1] * bend]; out.push(pathQ(p0, quadControlThrough(p0, mid, p2), p2)); } return out; } /** 사인 물결(§8.4 이마 주름: 진폭 1.5px, 획당 1.5주기, 시드 고정 위상)을 얹은 표본점. t는 anchors 곡선 전체 기준 0..1(토막으로 끊어도 재정규화하지 않는다 — 끊긴 자리를 지우기 전의 연속된 리본처럼 두께·물결이 이어지게 한다). */ function wavyRibbonPoints( anchors: Point[], samplesPerSeg: number, seed: number, amplitude: number, cycles: number, ): Array<{ point: Point; t: number }> { const withT = sampleCatmullRomWithT(anchors, samplesPerSeg); const phase = hash(seed) * Math.PI * 2; const n = withT.length; return withT.map((s, i) => { const prevPoint = i > 0 ? withT[i - 1].point : s.point; const nextPoint = i < n - 1 ? withT[i + 1].point : s.point; const nrm = normal(prevPoint, nextPoint); const off = Math.sin(s.t * Math.PI * 2 * cycles + phase) * amplitude; return { point: [s.point[0] + nrm[0] * off, s.point[1] + nrm[1] * off] as Point, t: s.t }; }); } function ribbonFromWavyPoints(pts: Array<{ point: Point; t: number }>, stops: Array): string { if (pts.length < 2) return ""; return polylineRibbon(pts.map((p) => p.point), pts.map((p) => valueAtStops(p.t, stops))); } /** 긴 호를 2~3토막으로 끊는다(틈 6~12%, 시드 고정). 원래 t(0..1) 구간을 그대로 쓴다. */ function seededSegmentRanges(seed: number): Array<[number, number]> { const segCount = 2 + Math.round(hash(seed)); const gaps: number[] = []; for (let i = 0; i < segCount - 1; i++) gaps.push(lerp(0.06, 0.12, hash(seed + i + 1))); const gapTotal = gaps.reduce((sum, g) => sum + g, 0); const segLen = (1 - gapTotal) / segCount; const ranges: Array<[number, number]> = []; let cursor = 0; for (let i = 0; i < segCount; i++) { ranges.push([cursor, cursor + segLen]); cursor += segLen + (gaps[i] ?? 0); } return ranges; } /* ── 그룹 변환(§8.4 변환 표, 2단계-B-1b 개정) ──────────────────────── */ export interface LinocutTransforms { figure: string; body: string; head: string; face: string; hairFront: string; } function pivotWrap(cx: number, cy: number, inner: string): string { return `translate(${cx} ${cy}) ${inner} translate(${-cx} ${-cy})`; } function computeTransforms(rig: LinocutRig, v: ChannelVector): LinocutTransforms { const bodyPivot = toPoint(rig.pivots.body); const neckPivot = toPoint(rig.pivots.neck); const facePivot = toPoint(rig.pivots.face); const figureScale = 1 + v.bodyLean * 0.03; const figure = [ `translate(${v.bodyYaw * 8} ${-v.bodyLean * 6})`, `rotate(${v.bodyRoll * 3} ${bodyPivot[0]} ${bodyPivot[1]})`, pivotWrap(bodyPivot[0], bodyPivot[1], `scale(${figureScale} ${figureScale})`), ].join(" "); const body = [ `translate(0 ${-v.shoulderRaise * 10})`, pivotWrap(bodyPivot[0], bodyPivot[1], `scale(1 ${1 + v.breath * 0.006})`), ].join(" "); const pitchCoef = v.headPitch < 0 ? 20 : 14; const head = [ `translate(${v.headYaw * 14} ${-v.headPitch * pitchCoef - v.shoulderRaise * 4 - v.breath * 2.5})`, `rotate(${v.headRoll * 7} ${neckPivot[0]} ${neckPivot[1]})`, ].join(" "); const faceSx = 1 - Math.abs(v.headYaw) * 0.08; const faceSy = 1 + v.headPitch * 0.03; const face = [ `translate(${v.headYaw * 6} ${-v.headPitch * 4})`, pivotWrap(facePivot[0], facePivot[1], `scale(${faceSx} ${faceSy})`), ].join(" "); const hairFront = `translate(${v.headYaw * 10} ${-v.headPitch * 3})`; return { figure, body, head, face, hairFront }; } /* ── 눈 ────────────────────────────────────────────────────────────── */ export interface EyeGeometry { outlineD: string; upperLidD: string; creaseD: string; creaseOpacity: number; lowerLidD: string; shadowD: string; irisCx: number; irisCy: number; irisRadius: number; irisRingWidth: number; pupilRadius: number; highlightCx: number; highlightCy: number; highlightRadius: number; radialStrokesD: string; tearPoolD: string; tearPoolOpacity: number; tearDropCx: number; tearDropCy: number; tearDropR: number; tearFlowD: string; tearFlowOpacity: number; tearFlowDropCx: number; tearFlowDropCy: number; tearFlowDropR: number; } interface EyeChannels { open: number; smile: number; lidTension: number; gazeX: number; gazeY: number; pupilSize: number; tear: number; } const CRESCENT_SAMPLES = 14; const CREASE_SAMPLES = 8; const LOWER_LID_SAMPLES = 10; function buildEye(eye: RigEye, ch: EyeChannels, isScreenRight: boolean, sizeScale: number): EyeGeometry { const O = toPoint(eye.outer); const I = toPoint(eye.inner); const U = toPoint(eye.upperLidTop); const D = toPoint(eye.lowerLidBottom); const C = toPoint(eye.iris.center); const r = eye.iris.radius; /* 크게 뜬 눈(eyeOpen>1) 증폭: 그대로 두면 놀람이 잘 안 읽힌다(1차 반려 "표정 간 차이가 작다"와 연결). e는 eyeOpen<=1이면 그대로, 넘으면 1.8배 증폭(최대 1.5)한다. */ const e = ch.open <= 1 ? Math.max(0, ch.open) : Math.min(1.5, 1 + (ch.open - 1) * 1.8); const openD = (1 - 0.75 * ch.smile) * (1 - 0.3 * ch.lidTension) * Math.min(1, 0.4 + 0.6 * clamp(ch.open, 0, 1)); const openU = e * (1 - 0.25 * ch.lidTension) * (1 - 0.15 * ch.smile); const midD = lineY(O, I, D[0]); const hD = D[1] - midD; const yD = midD + hD * openD; /* yU는 "닫힘(openU=0)일 때 yD와 만나고, 열림(openU=1)일 때 랜드마크 U를 정확히 지난다"는 두 경계 조건을 만족해야 한다(중립 채널 눈꺼풀 곡선이 랜드마크를 ≤0.5px로 지나야 한다는 요구, 2단계-B-1b 증거 요구 a). O-I 직선을 U·D 각각의 x에서 따로 보간해 빼는 이전 방식은 U.x≠D.x일 때 오차가 생겨 이 조건을 만족하지 못했다. yD를 축으로 직접 선형보간하면 항상 정확하다. */ const yU = yD + (U[1] - yD) * openU; const upperApex: Point = [U[0], yU]; const lowerApex: Point = [D[0], yD]; const upperControl = quadControlThrough(O, upperApex, I); const lowerControl = quadControlThrough(O, lowerApex, I); const outlineD = `${pathQ(O, upperControl, I)} Q ${lowerControl[0]} ${lowerControl[1]} ${O[0]} ${O[1]} Z`; /* 윗눈꺼풀 선: s=0(바깥 O)→1(안쪽 I), t(s) = 1.5 + 4·(1 − smoothstep(0.45,1,s)). */ const crescentTop = sampleQuad(O, upperControl, I, 0, 1, CRESCENT_SAMPLES); const crescentBottom = crescentTop.map((p, i) => { const s = i / (CRESCENT_SAMPLES - 1); const t = sizeScale * (1.5 + 4 * (1 - smoothstep(0.45, 1, s))); return [p[0], p[1] + t] as Point; }); const tailDir = (() => { const dx = O[0] - upperControl[0]; const dy = O[1] - upperControl[1]; const len = Math.hypot(dx, dy) || 1; return [dx / len, dy / len] as Point; })(); const tailTip: Point = [O[0] + tailDir[0] * 8, O[1] + tailDir[1] * 8]; const upperLidD = [ `M ${tailTip[0]} ${tailTip[1]}`, `L ${O[0]} ${O[1]}`, ...crescentTop.slice(1).map((p) => `L ${p[0]} ${p[1]}`), ...[...crescentBottom].reverse().map((p) => `L ${p[0]} ${p[1]}`), `L ${tailTip[0]} ${tailTip[1] + 1}`, "Z", ].join(" "); /* 쌍꺼풀 주름: s 0.15~0.85, 윗곡선 위로 9 + 5·max(0, openU−1)만큼, 불투명도 0.6 기준. */ const creaseOffset = 9 + 5 * Math.max(0, openU - 1); const creasePts = sampleQuad(O, upperControl, I, 0.15, 0.85, CREASE_SAMPLES).map((p) => [p[0], p[1] - creaseOffset] as Point); const creaseD = creasePts.map((p, i) => `${i === 0 ? "M" : "L"} ${p[0]} ${p[1]}`).join(" "); const creaseOpacity = (openU < 0.35 ? clamp(openU / 0.35) : 1) * 0.6; /* 아랫눈꺼풀: s 0~0.8 가는 선. */ const lowerPts = sampleQuad(O, lowerControl, I, 0, 0.8, LOWER_LID_SAMPLES); const lowerLidD = lowerPts.map((p, i) => `${i === 0 ? "M" : "L"} ${p[0]} ${p[1]}`).join(" "); /* 윗눈꺼풀 아래 3.5px 그늘 띠. */ const shadowD = [ ...crescentBottom.map((p, i) => `${i === 0 ? "M" : "L"} ${p[0]} ${p[1]}`), ...[...crescentBottom].reverse().map((p) => `L ${p[0]} ${p[1] + 3.5}`), "Z", ].join(" "); const irisCx = C[0] + ch.gazeX * 0.85 * r; const irisCy = C[1] + ch.gazeY * 0.5 * r; const pupilRadius = 0.42 * r * (1 + 0.3 * ch.pupilSize); const highlightCx = irisCx - 0.35 * r; const highlightCy = irisCy - 0.4 * r; const radialSegments: string[] = []; const innerR = pupilRadius + 1; const outerR = r * 0.85; for (let k = 0; k < 8; k++) { const angle = (k / 8) * Math.PI * 2; const cos = Math.cos(angle); const sin = Math.sin(angle); radialSegments.push( `M ${irisCx + cos * innerR} ${irisCy + sin * innerR} L ${irisCx + cos * outerR} ${irisCy + sin * outerR}`, ); } const pool = clamp(ch.tear / 0.3); const flow = clamp((ch.tear - 0.3) / 0.7); const tearPoolPts = sampleQuad(O, lowerControl, I, 0.6, 1, 6); const tearPoolD = tearPoolPts.map((p, i) => `${i === 0 ? "M" : "L"} ${p[0]} ${p[1]}`).join(" "); const tearDropCx = I[0]; const tearDropCy = I[1] + 2; const tearDropR = 2.5 * pool; const flowStart = quadPointAt(O, lowerControl, I, 0.65); const flowLen = flow * 120 * (isScreenRight ? 0.7 : 1); const flowEnd: Point = [flowStart[0] + 6, flowStart[1] + flowLen]; const flowControl: Point = [flowStart[0] + 3, flowStart[1] + flowLen * 0.5]; const flowHalfTop = 1.5; const flowHalfBottom = 1; const flowNormal = normal(flowStart, flowEnd); const tearFlowD = flowLen > 0.5 ? [ `M ${flowStart[0] + flowNormal[0] * flowHalfTop} ${flowStart[1] + flowNormal[1] * flowHalfTop}`, `Q ${flowControl[0] + flowNormal[0] * flowHalfBottom} ${flowControl[1] + flowNormal[1] * flowHalfBottom} ${flowEnd[0] + flowNormal[0] * flowHalfBottom} ${flowEnd[1] + flowNormal[1] * flowHalfBottom}`, `L ${flowEnd[0] - flowNormal[0] * flowHalfBottom} ${flowEnd[1] - flowNormal[1] * flowHalfBottom}`, `Q ${flowControl[0] - flowNormal[0] * flowHalfBottom} ${flowControl[1] - flowNormal[1] * flowHalfBottom} ${flowStart[0] - flowNormal[0] * flowHalfTop} ${flowStart[1] - flowNormal[1] * flowHalfTop}`, "Z", ].join(" ") : ""; return { outlineD, upperLidD, creaseD, creaseOpacity, lowerLidD, shadowD, irisCx, irisCy, irisRadius: r, irisRingWidth: 0.14 * r, pupilRadius, highlightCx, highlightCy, highlightRadius: 0.13 * r, radialStrokesD: radialSegments.join(" "), tearPoolD, tearPoolOpacity: pool, tearDropCx, tearDropCy, tearDropR, tearFlowD, tearFlowOpacity: flow, tearFlowDropCx: flowEnd[0], tearFlowDropCy: flowEnd[1], tearFlowDropR: flowLen > 0.5 ? 2.2 : 0, }; } /* ── 눈썹(2단계-B-1b 개정: 곡선·리본·삐져나온 털 획) ──────────────── */ export interface BrowGeometry { bodyD: string; hairStrokesD: string; hairStrokeWidth: number; /** 채널 반영 후 실제 안쪽 끝·꼭짓점(이마 주름·그늘이 따라간다). */ innerP: Point; peakP: Point; shadowCx: number; shadowCy: number; shadowOpacity: number; } interface BrowChannels { y: number; angle: number; x: number; form: number; } const BROW_STOPS: Array = [ [0, 4.5], [0.5, 3.2], [1, 1.0], ]; function buildBrow(brow: RigBrow, ch: BrowChannels, side: "left" | "right", sizeScale: number): BrowGeometry { const inner = toPoint(brow.inner); const peak = toPoint(brow.peak); const outer = toPoint(brow.outer); /* browAngle a는 부호에 따라 다른 계수를 쓴다(AU1 안쪽 올림은 바깥이 따라 오르지 않는다, §8.4 2단계-B-1d 개정): a>0이면 안쪽 −60a·꼭짓점 −4a·바깥 +18a, a<0(AU4)이면 안쪽 −50a·꼭짓점 −12a·바깥 +14a. */ const dyAll = -ch.y * 45; const a = ch.angle; const innerCoef = a > 0 ? -60 : -50; const peakCoef = a > 0 ? -4 : -12; const outerCoef = a > 0 ? 18 : 14; const innerDy = dyAll + innerCoef * a; const outerDy = dyAll + outerCoef * a; const peakDy = dyAll + peakCoef * a - ch.form * 10; const innerDx = side === "left" ? -ch.x * 16 : ch.x * 16; const innerP: Point = [inner[0] + innerDx, inner[1] + innerDy]; const peakP: Point = [peak[0], peak[1] + peakDy]; const outerP: Point = [outer[0], outer[1] + outerDy]; /* "세 점을 지나는 곡선(12점 이상 표본)", 꺾인 직선 금지(1차 반려 사유). 작은 화면 보정(§8.4 2단계-B-1d)으로 반두께·털 획 길이·굵기에 sizeScale을 곱한다. */ const scaledStops = BROW_STOPS.map(([t, hw]) => [t, hw * sizeScale] as const); const { samples, halfWidths } = curveRibbonData([innerP, peakP, outerP], scaledStops, 6); const bodyD = polylineRibbon(samples, halfWidths); const seedBase = side === "left" ? 0 : 100; const hairStrokesD = browHairStrokes(samples, halfWidths, 14, seedBase, sizeScale); const hairStrokeWidth = 1.3 * sizeScale; /* 안쪽 올림 그늘(§8.4): a>0.3이면 눈썹 머리 위에 흐린 잉크 그늘(가우시안 흐림은 렌더러의 SVG 필터가 맡는다, 여기서는 위치·불투명도만). */ const shadowOpacity = 0.18 * clamp((a - 0.3) / 0.7); const shadowCx = innerP[0]; const shadowCy = innerP[1] - 18; return { bodyD, hairStrokesD, hairStrokeWidth, innerP, peakP, shadowCx, shadowCy, shadowOpacity }; } /* ── 보조 주름 ─────────────────────────────────────────────────────── */ export interface WrinkleSet { paths: string[]; opacity: number; /** 순수 stroke(리본이 아닌) 주름만 쓴다(미간·턱). 작은 화면 보정(sizeScale)이 반영된 값. */ strokeWidth?: number; } /* ── 입(2단계-B-1b 전면 교체: U/∩ 곡선·Catmull-Rom 바깥선) ──────────── */ export interface MouthGeometry { lineD: string; /** R8(2026-10-01, §8.4 입 "질감" 재개정, lipTexture 있는 리그 한정): 벡터 입 선은 이제 작은 화면 보조일 뿐이다 — 0.95·clamp((sizeScale−1)/0.5, 0, 1). face 300px(sizeScale=1)에서는 0. lipTexture가 없는 리그는 기존대로 0.95 고정. */ lineOpacity: number; innerD: string; innerOpacity: number; teethD: string; /** R9: 윗니 색(teeth·mouthInner 3:1 혼합, 새하얀 막대로 안 튀게). */ teethFillColor: string; /** R9: 윗니 위 35% 잉크 그늘(불투명도 0.35 고정, 렌더러가 적용). teethD가 비면 같이 빈다. */ teethShadowD: string; /** 혀 그늘(§5.4): open>0.45에서 나타나는 입 안 아래쪽 반달. */ tongueShadowD: string; tongueShadowOpacity: number; /** rig.lipTexture가 없는 리그의 대체 경로 전용(R8: lipTexture 있으면 스프라이트 알파가 입 모양을 정하므로 이 벡터 채움·그늘·쐐기를 안 쓴다). */ vectorLipFallback?: VectorLipFallback; /** 입술 결 띠 변형(§8.4 입 "질감" 개정, 2단계-B-1d-R7). rig.lipTexture가 없으면 빈 배열. */ lipStripsUpper: StripMatrix[]; lipStripsLower: StripMatrix[]; /** 채널 반영 후 실제 입꼬리 좌표(팔자 주름이 따라간다). */ cornerLeft: Point; cornerRight: Point; /** 틈 높이 G(§5.4 R6b). §8.4 하관 띠 변형의 "현재 입 틈 높이"로 그대로 쓴다. */ gapHeight: number; /** 볼 불룩(§8.4, 2026-10-01 추가): 입꼬리마다 바깥 잉크 타원 + 종이색 하이라이트. */ cheekPuffLeft: CheekPuff; cheekPuffRight: CheekPuff; } /** 볼 불룩 타원 한 쪽(§8.4): 흐린 잉크 타원(반지름 16×24, 가우시안 stdDev 6) + 종이색 하이라이트. 반지름은 렌더러 상수(고정 16×24)라 여기서는 중심·불투명도만 낸다. */ export interface CheekPuff { cx: number; cy: number; highlightCx: number; highlightCy: number; opacity: number; } /** 입술 결 띠 하나의 아핀 변환(회전·기울임 없음: matrix(a,0,0,d,e,f)). */ export interface StripMatrix { a: number; d: number; e: number; f: number; } /** R8(2026-10-01, §8.4 입 "질감" 재개정): 16 → 24로 늘려 곡선 추종 오차를 줄인다 (verify_r7_tracking.mjs 실측: joy 3.85→2.60px, grief 4.86→3.19px, U 6.90→4.55px). */ export const LIP_STRIP_COUNT = 24; const MOUTH_LINE_STOPS: Array = [ [0, 1.4], [0.5, 2.6], [1, 1.4], ]; /** 입술 결 띠 변형(§8.4 입 "질감" 개정, 2단계-B-1d-R7)의 중립 기준값. rig당 1회만 계산하면 되므로 캐시한다: W0·tU0·tL0(채널 반영 전 랜드마크 값)와 중립 Cu0(x). */ interface NeutralMouthRef { M0x: number; W0: number; tU0: number; tL0: number; cuAt0: (x: number) => number; } const neutralMouthRefCache = new WeakMap(); function neutralMouthRef(rig: LinocutRig): NeutralMouthRef { const cached = neutralMouthRefCache.get(rig); if (cached) return cached; const Lc0 = toPoint(rig.landmarks.mouthCornerLeft); const Rc0 = toPoint(rig.landmarks.mouthCornerRight); const T = toPoint(rig.landmarks.upperLipTop); const B = toPoint(rig.landmarks.lowerLipBottom); const M0 = toPoint(rig.landmarks.mouthCenter); const control0 = quadControlThrough(Lc0, M0, Rc0); const xToT0 = (x: number) => clamp((x - Lc0[0]) / (Rc0[0] - Lc0[0] || 1)); const cuAt0 = (x: number) => quadPointAt(Lc0, control0, Rc0, xToT0(x))[1]; const ref: NeutralMouthRef = { M0x: M0[0], W0: Rc0[0] - Lc0[0], tU0: M0[1] - T[1], tL0: B[1] - M0[1], cuAt0 }; neutralMouthRefCache.set(rig, ref); return ref; } /** 입 모양의 채널 반영 핵심값(입술 결 띠 변형·바깥선 계산이 함께 쓴다). */ interface MouthShape { Lc: Point; Rc: Point; Mc: Point; /** 닫힌 입(틈 없음) 기준 곡선의 2차 베지어 제어점 — 모서리 tilt(미소·찡그림)만 반영하고 벌림·오므림과는 무관하다. R6b 틈 타원(mouthGapCurves)이 이 위에 얹힌다. */ seamControl: Point; W: number; tU: number; tL: number; /** 틈 반폭 a(§5.4 렌더러 입 모양, R6b: a = (W/2)(1−0.65r)). */ gapHalfWidth: number; /** 틈 높이 G(§5.4 렌더러 입 모양, R6b: G = g(1+0.8r)). §8.4 하관 띠 변형의 "현재 입 틈 높이"가 바로 이 값이다. */ gapHeight: number; } function computeMouthShape(rig: LinocutRig, v: ChannelVector): MouthShape { const Lc0 = toPoint(rig.landmarks.mouthCornerLeft); const Rc0 = toPoint(rig.landmarks.mouthCornerRight); const T = toPoint(rig.landmarks.upperLipTop); const B = toPoint(rig.landmarks.lowerLipBottom); const M0 = toPoint(rig.landmarks.mouthCenter); const w = 26 * v.mouthStretch + 12 * Math.max(0, v.mouthForm) - 10 * Math.max(0, -v.mouthForm); const roundInset = 32 * v.mouthRound; const Lc: Point = [Lc0[0] - w + roundInset, Lc0[1] - 36 * v.mouthForm - 22 * v.mouthAsym + 6 * v.chinRaise]; const Rc: Point = [Rc0[0] + w - roundInset, Rc0[1] - 36 * v.mouthForm + 22 * v.mouthAsym + 6 * v.chinRaise]; const Mc: Point = [M0[0], M0[1] - 6 * v.mouthForm - 4 * v.chinRaise]; /* 닫힌 입 기준 곡선: apex를 Mc에 정확히 두어(g 오프셋 없음) 미소·찡그림의 모서리↔중앙 tilt만 담는다. mouthOpen=0이면 이것이 바로 예전 Cu=Cl이다. */ const seamControl = quadControlThrough(Lc, Mc, Rc); const r = clamp(v.mouthRound); const gapBase = v.mouthOpen * 44 * (1 - 0.3 * v.mouthStretch); const gapHeight = gapBase * (1 + 0.8 * r); const gapHalfWidth = ((Rc[0] - Lc[0]) / 2) * (1 - 0.65 * r); const roundThicken = 1 + 0.3 * r; const tU = (M0[1] - T[1]) * (1 - 0.6 * v.mouthPress) * (1 - 0.25 * Math.max(0, v.mouthForm)) * roundThicken; const tL = (B[1] - M0[1]) * (1 - 0.55 * v.mouthPress) * (1 - 0.2 * Math.max(0, v.mouthForm)) * (1 - 0.3 * v.chinRaise) * roundThicken; const W = Rc[0] - Lc[0]; return { Lc, Rc, Mc, seamControl, W, tU, tL, gapHalfWidth, gapHeight }; } /** 입 틈의 기준 곡선 Cu·Cl(§5.4 렌더러 입 모양 R6b 개정, 2단계-B-1d-R6b). 닫힌 입 베이스라인(seamControl) 위에 가운데 타원 틈(반폭 a, 높이 G)을 얹는다: Mc 중심으로 위 0.25G·아래 0.75G인 타원이며, gapFrac이 틈 경계(|x−Mc.x|=a)에서 정확히 0으로 수렴해 이음매 없이 베이스라인과 만난다 — "틈 밖의 입 선 구간은 닫힌 채 입 선만 그린다", "틈 밖에서는 Cl=Cu다"를 만족한다. r=0이면 a=W/2가 모서리(Lc·Rc)에 정확히 닿아 기존 렌즈(모서리~모서리)와 사실상 같아진다. */ function mouthGapCurves(shape: MouthShape): { CuAt: (x: number) => number; ClAt: (x: number) => number } { const xToT = (x: number) => clamp((x - shape.Lc[0]) / (shape.Rc[0] - shape.Lc[0] || 1)); const seamAt = (x: number) => quadPointAt(shape.Lc, shape.seamControl, shape.Rc, xToT(x))[1]; const gapFrac = (x: number) => { if (shape.gapHalfWidth <= 0) return 0; const nx = clamp((x - shape.Mc[0]) / shape.gapHalfWidth, -1, 1); return Math.sqrt(Math.max(0, 1 - nx * nx)); }; return { CuAt: (x: number) => seamAt(x) - 0.25 * shape.gapHeight * gapFrac(x), ClAt: (x: number) => seamAt(x) + 0.75 * shape.gapHeight * gapFrac(x), }; } /** 검증용 공개 API(R6b·R7 수치 검증 스크립트 전용): 주어진 채널의 입 틈 기준 곡선 Cu·Cl을 직접 낸다. 렌더러(buildMouth)는 이 함수를 안 거치고 내부에서 같은 계산을 한다 — 중복 계산이 아니라 외부 검증이 내부 비공개 함수(mouthGapCurves)에 닿을 길이 없어 만든 얇은 래퍼다. */ export function mouthCurvesAt(rig: LinocutRig, v: ChannelVector): { CuAt: (x: number) => number; ClAt: (x: number) => number } { return mouthGapCurves(computeMouthShape(rig, v)); } /** xFrom..xTo를 count개로 균등 표본한 곡선 점(§5.4 R6b: CuAt/ClAt은 x를 받는 함수라 quadPointAt/sampleQuad의 t 표본을 대체한다). */ function sampleCurveX(xFrom: number, xTo: number, count: number, fn: (x: number) => number): Point[] { const out: Point[] = []; for (let i = 0; i < count; i++) { const x = lerp(xFrom, xTo, count === 1 ? 0 : i / (count - 1)); out.push([x, fn(x)]); } return out; } /** 입술 결 띠 변형(§8.4 2단계-B-1d-R7, 순수 함수라 채널 벡터만으로 테스트할 수 있다). 스프라이트를 가로 stripCount개 세로 띠로 나누고(스프라이트 폭 기준 등분, 이웃 띠와 1px 겹침), 띠 중심 x' = Mc.x + (x−M0.x)·W/W0, 윗입술 띠 y' = Cu(x')+(y−Cu0(x))·tU/tU0, 아랫입술 띠 y' = Cl(x')+(y−Cu0(x))·tL/tL0(스펙대로 아래도 Cu0 기준 — 중립에서 Cu0=Cl0). */ export function computeLipStripTransforms( rig: LinocutRig, v: ChannelVector, stripCount: number = LIP_STRIP_COUNT, ): { upper: StripMatrix[]; lower: StripMatrix[] } | null { if (!rig.lipTexture) return null; const shape = computeMouthShape(rig, v); const neutral = neutralMouthRef(rig); const scaleX = shape.W / (neutral.W0 || 1); const translateX = shape.Mc[0] - neutral.M0x * scaleX; /* R7(§8.4 "입술 띠 변형의 기준 곡선(Cu·Cl)도 새 틈을 쓴다"): 띠 변형 기준도 R6b의 타원 틈 곡선을 그대로 쓴다. 틈 밖(mouthOpen=0 포함)에서는 Cl=Cu라 위·아래 입술 띠가 같은 닫힌 곡선을 따라간다. */ const { CuAt, ClAt } = mouthGapCurves(shape); function stripsFor(sprite: RigLayer, scaleY: number, curveAt: (x: number) => number): StripMatrix[] { const out: StripMatrix[] = []; const stripW = sprite.w / stripCount; for (let i = 0; i < stripCount; i++) { const cx = sprite.x + (i + 0.5) * stripW; const cxPrime = shape.Mc[0] + (cx - neutral.M0x) * scaleX; const f = curveAt(cxPrime) - neutral.cuAt0(cx) * scaleY; out.push({ a: scaleX, d: scaleY, e: translateX, f }); } return out; } const scaleYUpper = shape.tU / (neutral.tU0 || 1); const scaleYLower = shape.tL / (neutral.tL0 || 1); return { upper: stripsFor(rig.lipTexture.upper, scaleYUpper, CuAt), lower: stripsFor(rig.lipTexture.lower, scaleYLower, ClAt), }; } /** 띠 사각형 clip의 정적 기하(스프라이트 폭 기준 등분, 이웃과 1px 겹침). 채널과 무관해 렌더러가 마운트 시 한 번만 계산해 에 쓴다. */ export function lipStripClipRect(sprite: RigLayer, index: number, stripCount: number = LIP_STRIP_COUNT) { const stripW = sprite.w / stripCount; return { x: sprite.x + index * stripW - 0.5, y: sprite.y, w: stripW + 1, h: sprite.h }; } /** rig.lipTexture가 없는 리그의 대체 경로(R8 이전 벡터 입술 그대로): 단색 입술 면 (upperD·lowerD), 입꼬리 잉크 쐐기, 아랫입술 안쪽 보조선, 아랫입술 아래 그늘 초승달. R8은 lipTexture가 있는 리그에만 적용되므로(오케스트레이터 지시), 없는 리그는 이 함수로 기존 동작을 그대로 낸다. */ export interface VectorLipFallback { upperD: string; lowerD: string; cornerStrokesD: string; lineSecondaryD: string; lineSecondaryOpacity: number; lowerShadowCrescentD: string; } function cornerWedge(corner: Point, control: Point, length: number, halfWidth: number): string { const dx = corner[0] - control[0]; const dy = corner[1] - control[1]; const len = Math.hypot(dx, dy) || 1; const tip: Point = [corner[0] + (dx / len) * length, corner[1] + (dy / len) * length]; return polylineRibbon([corner, tip], [halfWidth, 0]); } function buildVectorLipFallback( shape: MouthShape, CuAt: (x: number) => number, ClAt: (x: number) => number, sizeScale: number, chinRaise: number, ): VectorLipFallback { const { Lc, Rc, Mc, seamControl, W, tU, tL, gapHeight } = shape; const upperValleyY = CuAt(Mc[0]) - tU + 4; const lowerValleyY = ClAt(Mc[0]) + tL - 8 * chinRaise; const upperOuterAnchors: Point[] = [ Lc, [Lc[0] + 0.22 * W, CuAt(Lc[0] + 0.22 * W) - 0.55 * tU], [Mc[0] - 0.19 * W, CuAt(Mc[0] - 0.19 * W) - tU], [Mc[0], upperValleyY], [Mc[0] + 0.19 * W, CuAt(Mc[0] + 0.19 * W) - tU], [Rc[0] - 0.22 * W, CuAt(Rc[0] - 0.22 * W) - 0.55 * tU], Rc, ]; const lowerOuterAnchors: Point[] = [ Lc, [Lc[0] + 0.25 * W, ClAt(Lc[0] + 0.25 * W) + 0.7 * tL], [Mc[0], lowerValleyY], [Rc[0] - 0.25 * W, ClAt(Rc[0] - 0.25 * W) + 0.7 * tL], Rc, ]; const upperOutlineD = catmullRomPathD(upperOuterAnchors); const CLOSE_SAMPLES = 16; const cuSamplesRcToLc = [...sampleCurveX(Lc[0], Rc[0], CLOSE_SAMPLES, CuAt)].reverse(); const clSamplesLcToRc = sampleCurveX(Lc[0], Rc[0], CLOSE_SAMPLES, ClAt); const upperD = `${upperOutlineD} ${catmullRomCommands(cuSamplesRcToLc)} Z`; const lowerD = `M ${Lc[0]} ${Lc[1]} ${catmullRomCommands(clSamplesLcToRc)} ${catmullRomCommands([...lowerOuterAnchors].reverse())} Z`; const cornerStrokesD = [ cornerWedge(Lc, seamControl, 10, 2.2 * sizeScale), cornerWedge(Rc, seamControl, 10, 2.2 * sizeScale), ].join(" "); const lineSecondaryD = catmullRomPathD(sampleCurveX(Lc[0] + 0.1 * W, Rc[0] - 0.1 * W, 8, ClAt)); const lineSecondaryOpacity = gapHeight > 0.5 ? 0.6 : 0; const shadowBand = sampleCatmullRomWithT(lowerOuterAnchors, 8).filter((s) => s.t >= 0.15 && s.t <= 0.85); const shadowTop = shadowBand.map((s) => [s.point[0], s.point[1] + 3] as Point); const shadowBottom = shadowBand.map((s) => [s.point[0], s.point[1] + 9] as Point); const lowerShadowCrescentD = [ ...shadowTop.map((p, i) => `${i === 0 ? "M" : "L"} ${p[0]} ${p[1]}`), ...[...shadowBottom].reverse().map((p) => `L ${p[0]} ${p[1]}`), "Z", ].join(" "); return { upperD, lowerD, cornerStrokesD, lineSecondaryD, lineSecondaryOpacity, lowerShadowCrescentD }; } function buildMouth(rig: LinocutRig, v: ChannelVector, sizeScale: number): MouthGeometry { /* 찡그리면 입이 좁아진다(3차 판정: 비탄 입이 넓은 ∩자 만화 입이 되었다, §8.4 2단계-B-1d). mouthRound(§5.4 발화층, 2B-1d-R6)는 모서리를 안쪽으로 추가 32r 옮긴다(ㅗ·ㅜ 오므림). mouthStretch(넓힘)는 벌림 높이를 낮춘다(ㅣ·ㅡ처럼 옆으로 넓고 얕게). chinRaise(AU17)는 아랫입술을 밀어 올려 입을 삐죽 모은다. mouthRound는 입술을 도톰하게 한다(두께 ×(1+0.3r)). 이 핵심값은 computeMouthShape로 뽑아 입술 결 띠 변형(computeLipStripTransforms)과 공유한다(중복 계산 방지, R7). */ const shape = computeMouthShape(rig, v); const { Lc, Rc, W, gapHeight } = shape; const { CuAt, ClAt } = mouthGapCurves(shape); /* R8(2026-10-01, §8.4 입 "질감" 재개정): 입 모양은 이제 lipTexture 스프라이트 알파가 정한다(§8.4 원칙 "중립에서 렌더된 입은 원화와 픽셀 단위로 같고, 움직이면 원화 픽셀이 변형된다"). upperD·lowerD(벡터 입술 바깥선·면)는 더는 만들지 않는다 — 이전에는 (a) 입술 단색 채움 (b) 입술 결 띠의 clip 도형으로 썼는데, 둘 다 이제 스프라이트 알파가 대신한다(clip은 띠별 사각형만 남는다, computeLipStripTransforms 참고). 입 안(틈)·윗니·혀 그늘은 그대로 계산해 스프라이트 "아래"에 그린다(렌더러가 그리는 순서를 정한다). */ /* 벌어진 틈(§5.4 렌더러 입 모양 R6b): 오므린 입은 모서리부터 붙고 가운데만 둥글게 열린다(가운데 타원, mouthGapCurves). 틈 밖에서는 CuAt=ClAt(닫힘)이라 Lc~Rc 전체를 표본해도 틈 밖 구간은 저절로 높이 0인 얇은 선이 된다. */ const GAP_SAMPLES = 16; const innerUpper = sampleCurveX(Lc[0], Rc[0], GAP_SAMPLES, CuAt); const innerLowerRev = [...sampleCurveX(Lc[0], Rc[0], GAP_SAMPLES, ClAt)].reverse(); const innerD = `${catmullRomPathD([...innerUpper, ...innerLowerRev])} Z`; const innerOpacity = gapHeight > 0.5 ? 1 : 0; /* 윗니(§5.4 렌더러 입 모양, R9 개정): open>0.08이고 (mouthStretch>0.2 또는 open>0.3)이면 보인다. 높이는 min(6, 3+8·open)·(1−0.8·mouthRound)이고 틈 높이(G) 이하로 자른다(R9: 새하얀 막대로 튀고 ㅗ에서도 이가 도드라지던 문제 — mouthRound가 높을수록 낮아져 오므린 입에서는 거의 안 보인다). 윗니 y는 새 틈(CuAt)을 따른다. */ const teethVisible = v.mouthOpen > 0.08 && (v.mouthStretch > 0.2 || v.mouthOpen > 0.3); const teethHeightRaw = teethVisible ? Math.min(6, 3 + 8 * v.mouthOpen) * (1 - 0.8 * clamp(v.mouthRound)) : 0; const teethHeight = Math.min(teethHeightRaw, Math.max(0, gapHeight)); const insetX = W * 0.12; const teethTop = sampleCurveX(Lc[0] + insetX, Rc[0] - insetX, 6, CuAt); const teethBottom = teethTop.map((p) => [p[0], p[1] + teethHeight] as Point); const teethD = teethHeight > 0 ? [ ...teethTop.map((p, i) => `${i === 0 ? "M" : "L"} ${p[0]} ${p[1]}`), ...[...teethBottom].reverse().map((p) => `L ${p[0]} ${p[1]}`), "Z", ].join(" ") : ""; /* R9: 윗니 색은 teeth·mouthInner를 3:1로 섞은 종이색(새하얀 막대로 안 튀게), 위 35%에 잉크 0.35 그늘. */ const teethFillColor = mixHex(rig.palette.teeth, rig.palette.mouthInner, 0.75); const teethShadowD = teethHeight > 0 ? [ ...teethTop.map((p, i) => `${i === 0 ? "M" : "L"} ${p[0]} ${p[1]}`), ...[...teethTop].reverse().map((p) => `L ${p[0]} ${p[1] + teethHeight * 0.35}`), "Z", ].join(" ") : ""; /* R8: 벡터 입 선은 이제 작은 화면 보조일 뿐이다(§8.4 입 "질감" 재개정) — 큰 화면 (face 300px, sizeScale=1)에서는 스프라이트가 결을 보여주므로 0, 작아질수록 0.95까지 올라간다. d(모양)는 여전히 Cu(R6b 새 틈 반영)를 따라 매 프레임 갱신한다 (opacity가 0이어도 d는 계산해 둬야 화면이 커질 때 바로 보인다). */ const lineSamples = sampleCurveX(Lc[0], Rc[0], 12, CuAt); const lineHalfWidths = lineSamples.map((_, i) => valueAtStops(i / 11, MOUTH_LINE_STOPS) * sizeScale); const accentBoost = clamp(Math.max(0, v.mouthPress - 0.3) * 1.5 + Math.max(0, -v.mouthForm - 0.3) * 1.2); lineHalfWidths[0] += accentBoost * 0.8 * sizeScale; lineHalfWidths[lineHalfWidths.length - 1] += accentBoost * 0.8 * sizeScale; const lineD = polylineRibbon(lineSamples, lineHalfWidths); /* R8은 lipTexture가 있는 리그에만 적용된다(오케스트레이터 지시). 없으면 기존대로 0.95 고정(벡터 선이 유일한 입 모양 전달자이므로 작은 화면 보조로 낮추면 안 된다). */ const lineOpacity = rig.lipTexture ? 0.95 * clamp((sizeScale - 1) / 0.5, 0, 1) : 0.95; /* 혀 그늘(§5.4 렌더러 규칙): open>0.45이면 입 안 아래쪽에 밝은 적갈색 반달을 둔다. Cl(아랫입술 안쪽 경계) 바로 위 얇은 띠(모서리 제외), 입 안 clip 안에서만 보인다. */ const tongueOpacity = clamp((v.mouthOpen - 0.45) / 0.15); const clBand = sampleCurveX(Lc[0] + 0.2 * W, Rc[0] - 0.2 * W, 8, ClAt); const tongueTop = clBand.map((p) => [p[0], p[1] - 0.3 * gapHeight] as Point); const tongueBottom = clBand.map((p) => [p[0], p[1] - 0.04 * gapHeight] as Point); const tongueShadowD = tongueOpacity > 0 ? [ ...tongueTop.map((p, i) => `${i === 0 ? "M" : "L"} ${p[0]} ${p[1]}`), ...[...tongueBottom].reverse().map((p) => `L ${p[0]} ${p[1]}`), "Z", ].join(" ") : ""; /* 입술 결 띠 변형(§8.4 2단계-B-1d-R7). rig.lipTexture가 없으면 빈 배열(렌더러가 대체 경로로 단색만 쓴다). */ const lipStrips = computeLipStripTransforms(rig, v); /* 볼 불룩(§8.4, 2026-10-01 추가): b = max(mouthPress, mouthRound) — ㅁ·ㅂ·ㅗ·ㅜ에서 입 옆 볼이 볼록해진다. 입꼬리마다 바깥 (±16,−4)에 흐린 잉크 타원, 그 위 (±4,−8)에 종이색 하이라이트. */ const puffB = clamp(Math.max(v.mouthPress, v.mouthRound)); const puffOpacity = 0.1 * puffB; const cheekPuffLeft: CheekPuff = { cx: Lc[0] - 16, cy: Lc[1] - 4, highlightCx: Lc[0] - 4, highlightCy: Lc[1] - 8, opacity: puffOpacity, }; const cheekPuffRight: CheekPuff = { cx: Rc[0] + 16, cy: Rc[1] - 4, highlightCx: Rc[0] + 4, highlightCy: Rc[1] - 8, opacity: puffOpacity, }; /* R8: lipTexture가 없는 리그만 예전 벡터 입술 채움·그늘·쐐기 경로를 쓴다. */ const vectorLipFallback = rig.lipTexture ? undefined : buildVectorLipFallback(shape, CuAt, ClAt, sizeScale, v.chinRaise); return { lineD, lineOpacity, innerD, innerOpacity, teethD, teethFillColor, teethShadowD, tongueShadowD, tongueShadowOpacity: tongueOpacity, lipStripsUpper: lipStrips?.upper ?? [], lipStripsLower: lipStrips?.lower ?? [], cornerLeft: Lc, cornerRight: Rc, gapHeight, cheekPuffLeft, cheekPuffRight, vectorLipFallback, }; } /* ── 하관 띠 변형(§8.4 2026-10-01 추가: "말하면 코도 들썩이고... 턱이 만든다") ───── 입이 벌어지는 양의 대부분은 턱이 만든다. 리그 jaw(head·faceDetail에서 잘라 낸 조각)를 y_n부터 chinTip.y+30까지 가로 띠 JAW_STRIP_COUNT개로 나눠 세로 이동· 배율만 준다(가로는 항등, 이웃 띠와 경계가 이어져 찢어지지 않는다). 렌더러가 각 띠를 faceOval과 교차한 영역에만 그린다("띠 clip = 띠 사각형 ∩ 얼굴 윤곽"). */ /** R10(§8.4 개정, out5 반려 대응): 20 → 12. 잘라 낸 작은 조각(rig.jaw)을 쓰면서 전체 이미지를 띠마다 다시 그리던 비용이 사라져 띠 수를 줄여도 경계가 덜 보인다. */ export const JAW_STRIP_COUNT = 12; /** 띠 하나의 세로 아핀 변환(가로는 항등이라 렌더러는 matrix(1 0 0 scaleY 0 translateY)로 쓴다). 정적 사각형(경계 겹침 포함)은 jawWarpStripRects가 채널과 무관하게 따로 낸다 (lipStripClipRect·computeLipStripTransforms의 정적/매 프레임 분리와 같은 패턴). */ export interface JawWarpStrip { scaleY: number; translateY: number; } export interface JawWarpLayer { strips: JawWarpStrip[]; /** max|f| < 0.3px(§8.4)이면 true — 렌더러가 띠를 숨기고 원본만 그린다(쉬는 동안 비용 0). */ negligible: boolean; } export interface JawWarpFrame { head: JawWarpLayer | null; faceDetail: JawWarpLayer | null; /** 보고용: 턱 끝 변위 Jc(px, 아래가 +). */ chinShiftPx: number; } /** 띠 범위(y_n..chinTip.y+30)는 rig 랜드마크만으로 정해지고 채널과 무관하다. */ function jawWarpRange(rig: LinocutRig): { yFrom: number; yTo: number } { return { yFrom: rig.landmarks.noseTip[1] - 50, yTo: rig.landmarks.chinTip[1] + 30 }; } /** 하관 띠 변형의 위쪽 경계 y_n(§8.4). 이 아래(y > y_n)의 얼굴 벡터 점·faceDetail만 변위장 f(y)를 받는다 — 렌더러가 faceDetail 원본을 "변형 영역을 빼고" 그릴 때 쓰는 상수 경계이기도 하다(R10). */ export function jawWarpUpperBoundaryY(rig: LinocutRig): number { return jawWarpRange(rig).yFrom; } /** R10(§8.4 개정, out5 jaw-zoom 반려 대응): 띠 하나당 위아래로 이만큼 넓혀 이웃 띠와 겹친다(겹침 총량 JAW_STRIP_OVERLAP, lipStripClipRect와 같은 "1px 겹침" 패턴). 직전 라운드의 흐린 마스크(feGaussianBlur stdDeviation 4, margin 12)는 경계 이음매를 고치지 못했고 비용만 컸다(가로세로 canvas 전체 크기의 흐림 필터를 띠마다 새로 태운다) — 원인은 이음매가 아니라 원본 faceDetail이 변형 영역에서 그대로 비쳐 보이는 것(아래 buildJawWarpLayer 주석)이었으므로, 흐림을 걷어내고 1px 겹침 딱딱한 clip으로 되돌린다. */ const JAW_STRIP_OVERLAP = 1; /** 레이어(rig.jaw.head·rig.jaw.detail 조각, 또는 rig.jaw가 없는 리그의 대체 경로로 전체 head·faceDetail) 하나의 띠 JAW_STRIP_COUNT개의 정적 사각형(레이어 로컬 좌표, 위아래로 JAW_STRIP_OVERLAP만큼 넓혀 이웃 띠와 겹친다). 채널과 무관해 렌더러가 마운트 시 한 번만 계산해 에 쓴다(lipStripClipRect와 같은 역할). 레이어 범위 밖(원본 픽셀이 없는 범위)이면 null. */ export function jawWarpStripRects(rig: LinocutRig, layer: RigLayer): Array<{ y: number; h: number }> | null { const { yFrom, yTo } = jawWarpRange(rig); const top = Math.max(yFrom, layer.y); const bottom = Math.min(yTo, layer.y + layer.h); if (bottom <= top) return null; const sliceH = (bottom - top) / JAW_STRIP_COUNT; const overlap = JAW_STRIP_OVERLAP; const out: Array<{ y: number; h: number }> = []; for (let i = 0; i < JAW_STRIP_COUNT; i++) { const y0 = top + i * sliceH; out.push({ y: y0 - overlap / 2, h: sliceH + overlap }); } return out; } /** 세로 변위장 f(y)(§8.4): y≤y_n은 0, y_n→T.y는 smoothstep 0→−U, T.y→B.y는 smoothstep −U→+Jl, B.y→C.y+20은 smoothstep Jl→Jc, 그 아래는 Jc. */ function jawDisplacementField(yNose: number, tY: number, bY: number, cY: number, u: number, jl: number, jc: number) { const bottomEdge = cY + 20; return (y: number): number => { if (y <= yNose) return 0; if (y <= tY) return lerp(0, -u, smoothstep(yNose, tY, y)); if (y <= bY) return lerp(-u, jl, smoothstep(tY, bY, y)); if (y <= bottomEdge) return lerp(jl, jc, smoothstep(bY, bottomEdge, y)); return jc; }; } /** 레이어 하나의 띠 JAW_STRIP_COUNT개의 매 프레임 세로 변환. 범위는 jawWarpStripRects와 같은 top/bottom(레이어로 자른 y_n..chinTip.y+30)이다. */ function buildJawWarpLayer(layer: RigLayer, f: (y: number) => number, yFrom: number, yTo: number): JawWarpLayer | null { const top = Math.max(yFrom, layer.y); const bottom = Math.min(yTo, layer.y + layer.h); if (bottom <= top) return null; const sliceH = (bottom - top) / JAW_STRIP_COUNT; const strips: JawWarpStrip[] = []; let maxAbsF = 0; for (let i = 0; i < JAW_STRIP_COUNT; i++) { const y0 = top + i * sliceH; const y1 = y0 + sliceH; const f0 = f(y0); const f1 = f(y1); maxAbsF = Math.max(maxAbsF, Math.abs(f0), Math.abs(f1)); const scaleY = (y1 + f1 - (y0 + f0)) / (y1 - y0 || 1); const translateY = y0 + f0 - y0 * scaleY; strips.push({ scaleY, translateY }); } return { strips, negligible: maxAbsF < 0.3 }; } /** §8.4 하관 띠 변형: 변위장 f와 head·faceDetail 띠 변환을 함께 낸다. gapHeight는 buildMouth의 현재 틈 높이(§5.4 R6b 반영 값, "g는 현재 입 틈 높이")를 그대로 받는다(중복 계산 방지). R10: 띠의 원본은 rig.jaw의 잘라 낸 조각(head·detail)을 쓴다 — 전체 1005×1566 이미지를 띠마다 다시 그리던 out5 비용 문제(long task 14/7.3s)의 원인이었다. rig.jaw가 없는 리그는 기존처럼 전체 head·faceDetail을 쓴다(대체 경로). faceDetail은 대부분 투명한 덧칠 레이어라 이 띠가 켜진 동안 원본 faceDetail을 그대로 함께 그리면 변형된 사본과 겹쳐 턱선이 원래 자리와 내려간 자리에 두 겹(점선)으로 보인다(out4·out5 반려 사유) — 이 값(negligible)을 렌더러가 읽어 원본 faceDetail을 "변형 영역(y > y_n, jawWarpUpperBoundaryY 아래)을 빼고" 그릴지 전체를 그릴지 고른다(렌더러 쪽 책임, 이 함수는 띠 변환만 낸다). */ function computeJawWarp(rig: LinocutRig, v: ChannelVector, gapHeight: number): { frame: JawWarpFrame; f: (y: number) => number } { const T = toPoint(rig.landmarks.upperLipTop); const B = toPoint(rig.landmarks.lowerLipBottom); const C = toPoint(rig.landmarks.chinTip); const { yFrom: yNose, yTo } = jawWarpRange(rig); const u = 0.7 * 0.25 * gapHeight + 1.5 * v.mouthStretch; const jl = 0.8 * 0.75 * gapHeight; const jc = 1.3 * jl; const f = jawDisplacementField(yNose, T[1], B[1], C[1], u, jl, jc); const headSource = rig.jaw?.head ?? rig.layers.head; const faceDetailLayer = rig.layers.faceDetail; const faceDetailSource = rig.jaw?.detail ?? faceDetailLayer; const frame: JawWarpFrame = { head: buildJawWarpLayer(headSource, f, yNose, yTo), faceDetail: faceDetailLayer && faceDetailSource ? buildJawWarpLayer(faceDetailSource, f, yNose, yTo) : null, chinShiftPx: jc, }; return { frame, f }; } /* ── 프레임 조립 ───────────────────────────────────────────────────── */ export interface SweatDrop { cx: number; cy: number; r: number; } export interface LinocutFrame { transforms: LinocutTransforms; eyeLeft: EyeGeometry; eyeRight: EyeGeometry; browLeft: BrowGeometry; browRight: BrowGeometry; glabella: WrinkleSet; /** 안쪽 올림 주름(AU1, 비탄·슬픔·불안) — 좌우 각 3획 무리. */ foreheadInner: WrinkleSet; /** 전체 올림 주름(AU1+2, 놀람·기대) — 두 꼭짓점을 가로지르는 긴 호 3획. */ foreheadWhole: WrinkleSet; mouth: MouthGeometry; nasolabial: WrinkleSet; chinCrease: WrinkleSet; /** 하관 띠 변형(§8.4 2026-10-01 추가). */ jawWarp: JawWarpFrame; /** 얼굴 그룹 clip 다각형 points(R10: y>y_n 점에 하관 띠 변위 f(y) 반영, negligible이면 원본 얼굴 윤곽과 같은 값). */ faceOvalWarpedPointsAttr: string; /** 창백 마스크 다각형 points(위와 같은 이유로 f(y) 반영). */ pallorMaskWarpedPointsAttr: string; /** 아랫입술 아래 그늘 조각(rig.lipTexture.shadow)의 세로 이동값(§8.4: 턱 변위 f(그늘 중심 y)만 받는다, 가로 배율 없음). shadow가 없는 리그는 0(렌더러가 애초에 그리지 않아 값이 쓰이지 않는다). */ lipShadowTranslateY: number; cheekLeftOpacity: number; cheekRightOpacity: number; pallorColorOpacity: number; pallorWhiteOpacity: number; sweatOpacity: number; sweatDrops: [SweatDrop, SweatDrop]; } const FOREHEAD_STOPS: Array = [ [0, 0.4], [0.5, 1.1], [1, 0.4], ]; /** R10(§8.4 팔자 주름 개정, out4·out5 반려 대응): 반두께 1.1 → 0.3(이전 1.3 → 0.4는 코 옆 높은 곳에서 시작하는 균일한 가는 선이 입을 감싸는 괄호 아치로 떠 보였다). */ const NASOLABIAL_STOPS: Array = [ [0, 1.1], [1, 0.3], ]; /** 안쪽 올림 주름 한쪽 3획(§8.4 2단계-B-1d): 현재 안쪽 끝·꼭짓점 사이(안쪽 65%·꼭짓점 35%) 위, 맨 아래가 안쪽 끝보다 22px 위·간격 11px·길이 58/48/38(아래가 김), 미간 쪽 끝이 4px 더 높다. */ function browInnerClusterPaths(innerP: Point, peakP: Point, side: "left" | "right", sizeScale: number, seedBase: number): string[] { const anchorX = innerP[0] * 0.65 + peakP[0] * 0.35; const stops = FOREHEAD_STOPS.map(([t, hw]) => [t, hw * sizeScale] as const); return [58, 48, 38].map((len, idx) => { const baseY = innerP[1] - 22 - idx * 11; const half = len / 2; const leftY = side === "left" ? baseY : baseY - 4; const rightY = side === "left" ? baseY - 4 : baseY; const anchors: Point[] = [ [anchorX - half, leftY], [anchorX, baseY - 2], [anchorX + half, rightY], ]; const wavy = wavyRibbonPoints(anchors, 6, seedBase + idx * 7, 1.5, 1.5); return ribbonFromWavyPoints(wavy, stops); }); } /** 전체 올림 주름 한 획(§8.4 2단계-B-1d): 두 꼭짓점을 가로지르는 가운데 5px 솟은 호, 2~3토막(틈 6~12%, 시드 고정). */ function wholeRaiseArcPaths(peakLeft: Point, peakRight: Point, dy: number, sizeScale: number, seed: number): string[] { const cx = (peakLeft[0] + peakRight[0]) / 2; const halfW = Math.abs(peakRight[0] - peakLeft[0]) * 0.42; const baseY = Math.min(peakLeft[1], peakRight[1]) - dy; const anchors: Point[] = [ [cx - halfW, baseY], [cx, baseY - 5], [cx + halfW, baseY], ]; const wavy = wavyRibbonPoints(anchors, 8, seed, 1.5, 1.5); const stops = FOREHEAD_STOPS.map(([t, hw]) => [t, hw * sizeScale] as const); return seededSegmentRanges(seed + 1).map(([tFrom, tTo]) => ribbonFromWavyPoints( wavy.filter((p) => p.t >= tFrom && p.t <= tTo), stops, ), ); } export function computeLinocutFrame(rig: LinocutRig, v: ChannelVector, sizeScale = 1): LinocutFrame { const transforms = computeTransforms(rig, v); const eyeLeft = buildEye( rig.landmarks.eyeLeft, { open: v.eyeOpenL, smile: v.eyeSmileL, lidTension: v.lidTension, gazeX: v.gazeX, gazeY: v.gazeY, pupilSize: v.pupilSize, tear: v.tear }, false, sizeScale, ); const eyeRight = buildEye( rig.landmarks.eyeRight, { open: v.eyeOpenR, smile: v.eyeSmileR, lidTension: v.lidTension, gazeX: v.gazeX, gazeY: v.gazeY, pupilSize: v.pupilSize, tear: v.tear }, true, sizeScale, ); const browLeft = buildBrow(rig.landmarks.browLeft, { y: v.browLY, angle: v.browLAngle, x: v.browLX, form: v.browForm }, "left", sizeScale); const browRight = buildBrow(rig.landmarks.browRight, { y: v.browRY, angle: v.browRAngle, x: v.browRX, form: v.browForm }, "right", sizeScale); /* 미간 주름: 눈썹 안쪽 끝 중점 위, 짧은 수직 획 2개(변경 없음). */ const browInnerLeft = toPoint(rig.landmarks.browLeft.inner); const browInnerRight = toPoint(rig.landmarks.browRight.inner); const glabellaCx = (browInnerLeft[0] + browInnerRight[0]) / 2; const glabellaCy = (browInnerLeft[1] + browInnerRight[1]) / 2 - 6; const glabellaOpacity = clamp( Math.max(0, -v.browLX, -v.browRX) * 1.1 + Math.max(0, -v.browLAngle, -v.browRAngle) * 0.6, 0, 0.85, ); const glabella: WrinkleSet = { paths: [ `M ${glabellaCx - 5} ${glabellaCy - 7} L ${glabellaCx - 4} ${glabellaCy + 7}`, `M ${glabellaCx + 5} ${glabellaCy - 7} L ${glabellaCx + 4} ${glabellaCy + 7}`, ], opacity: glabellaOpacity, strokeWidth: 1.2 * sizeScale, }; /* 이마 주름 두 종류(§8.4 2단계-B-1d 개정: 같은 높이 곧은 평행선 3줄이 바코드처럼 보인 3차 판정 대응). 안쪽 올림(AU1)은 좌우 각 3획 무리, 전체 올림(AU1+2)은 두 꼭짓점을 가로지르는 긴 호 3획을 2~3토막. 둘 다 현재(채널 반영 후) 눈썹 좌표를 따라가고, 모든 획에 사인 물결을 준다. */ const browAngleAvg = (v.browLAngle + v.browRAngle) / 2; const browYAvg = (v.browLY + v.browRY) / 2; const foreheadInnerOpacity = clamp((browAngleAvg - 0.3) * 1.4, 0, 0.75); const foreheadInner: WrinkleSet = { paths: [ ...browInnerClusterPaths(browLeft.innerP, browLeft.peakP, "left", sizeScale, 0), ...browInnerClusterPaths(browRight.innerP, browRight.peakP, "right", sizeScale, 500), ], opacity: foreheadInnerOpacity, }; const foreheadWholeOpacity = clamp((browYAvg - 0.35) * 1.6, 0, 0.7); const foreheadWhole: WrinkleSet = { paths: [30, 42, 54].flatMap((dy, idx) => wholeRaiseArcPaths(browLeft.peakP, browRight.peakP, dy, sizeScale, 700 + idx * 20)), opacity: foreheadWholeOpacity, }; const mouth = buildMouth(rig, v, sizeScale); /* 하관 띠 변형(§8.4 2026-10-01 추가): 입 틈 높이(mouth.gapHeight, §5.4 R6b 반영 값)로 변위장 f(y)를 정한다. 입을 제외한 얼굴 벡터 점 중 y>y_n인 점(팔자·턱 주름 등)에 f(y)를 더한다. */ const jawWarp = computeJawWarp(rig, v, mouth.gapHeight); const shiftY = (p: Point): Point => [p[0], p[1] + jawWarp.f(p[1])]; /* R10(§8.4 개정): 얼굴 그룹 clip 다각형·창백 마스크 다각형의 점에도 f(y)를 더한다 — 얼굴 윤곽이 턱과 함께 내려가야 위 shiftY로 이미 옮겨진 벡터 부위 (팔자·턱 주름 등)와 faceDetail 띠가 원래(정지) 윤곽에서 잘리지 않는다. head 띠의 negligible을 전체 대표값으로 쓴다(head는 항상 존재하는 필수 레이어라 buildJawWarpLayer가 null을 내는 경우가 사실상 없다). negligible이면 원본 점을 그대로 쓴다(§8.4 "negligible이면 원래 값을 쓴다"). */ const jawNegligible = jawWarp.frame.head?.negligible ?? true; const faceOvalBase = rig.faceOval.map(toPoint); const faceOvalWarpedPointsAttr = formatPointsAttr(jawNegligible ? faceOvalBase : warpPointsY(faceOvalBase, jawWarp.f)); const pallorMaskBase = shrinkPointsToward(rig.faceOval, 0.96); const pallorMaskWarpedPointsAttr = formatPointsAttr(jawNegligible ? pallorMaskBase : warpPointsY(pallorMaskBase, jawWarp.f)); /* 아랫입술 아래 그늘 조각(§8.4 "질감" 개정, rig.lipTexture.shadow): 턱 변위 f(그늘 중심 y)만큼만 세로로 옮긴다(가로 배율 없음 — out5 판정: 아랫입술 스프라이트에 붙여 두자 ㅗ에서 W/W0로 함께 압축되어 짙은 직사각형이 됐다. 턱 피부 그늘은 입이 좁아져도 줄지 않는다). shadow가 없으면 값은 쓰이지 않는다. */ const lipShadowTranslateY = rig.lipTexture?.shadow ? jawWarp.f(rig.lipTexture.shadow.y + rig.lipTexture.shadow.h / 2) : 0; /* 팔자 주름: 코 날개 옆 → 현재 입꼬리, 바깥으로 10px 부푼 호(리본, 반두께 1.3→0.4). 수평 직선 금지(1차 반려 대응). 발화 반응(§8.4 2026-10-01): mouthStretch·mouthOpen도 불투명도에 더한다(ㅣ·ㅔ와 크게 벌린 입에서 깊어진다). */ const noseTip = toPoint(rig.landmarks.noseTip); /* R10(§8.4 팔자 주름 개정, out4·out5 반려 대응): 불투명도 상한 0.8 → 0.5로 낮춘다. */ const nasolabialOpacity = clamp((v.mouthForm - 0.15) * 1.6 + 0.35 * v.mouthStretch + 0.2 * v.mouthOpen, 0, 0.5); /* R10 개정: 시작 (noseTip.x∓40, noseTip.y+28), 끝 (corner.x에서 바깥으로 12, corner.y+2), 바깥으로 6px 부푼 호. R9 좌표(시작 노즈팁 옆 38·8, 끝 입꼬리 밖 10·6, 부풂 8)는 여전히 joy·warm에서 입을 감싸는 괄호 아치로 떠 보였다(out4·out5 반려). 시작점을 코 옆 더 낮고 안쪽으로, 끝점을 입꼬리에 더 가깝게 당겨 짧고 낮은 주름으로 바꾼다. */ const nasoLeftStart: Point = shiftY([noseTip[0] - 40, noseTip[1] + 28]); const nasoLeftEnd: Point = shiftY([mouth.cornerLeft[0] - 12, mouth.cornerLeft[1] + 2]); const nasoLeftMid: Point = [(nasoLeftStart[0] + nasoLeftEnd[0]) / 2 - 6, (nasoLeftStart[1] + nasoLeftEnd[1]) / 2]; const nasoRightStart: Point = shiftY([noseTip[0] + 40, noseTip[1] + 28]); const nasoRightEnd: Point = shiftY([mouth.cornerRight[0] + 12, mouth.cornerRight[1] + 2]); const nasoRightMid: Point = [(nasoRightStart[0] + nasoRightEnd[0]) / 2 + 6, (nasoRightStart[1] + nasoRightEnd[1]) / 2]; const nasolabialStops = NASOLABIAL_STOPS.map(([t, hw]) => [t, hw * sizeScale] as const); const nasoLeft = curveRibbonData([nasoLeftStart, nasoLeftMid, nasoLeftEnd], nasolabialStops, 6); const nasoRight = curveRibbonData([nasoRightStart, nasoRightMid, nasoRightEnd], nasolabialStops, 6); const nasolabial: WrinkleSet = { paths: [polylineRibbon(nasoLeft.samples, nasoLeft.halfWidths), polylineRibbon(nasoRight.samples, nasoRight.halfWidths)], opacity: nasolabialOpacity, }; /* 턱 주름: (B.x, B.y+26) 중심 타원(가로 0.3W, 세로 12) 안에 짧게 굽은 획 6개. 중심에도 하관 띠 변형 f(y)를 더한다(§8.4). */ const lowerLipBottom = toPoint(rig.landmarks.lowerLipBottom); const chinCreaseCenter = shiftY([lowerLipBottom[0], lowerLipBottom[1] + 26]); const chinW = Math.abs(mouth.cornerRight[0] - mouth.cornerLeft[0]); const chinCreaseOpacity = clamp(v.chinRaise * 0.8, 0, 0.8); const chinCrease: WrinkleSet = { paths: ellipseCurvedStrokes(chinCreaseCenter, chinW * 0.3, 12, 6, 400), opacity: chinCreaseOpacity, strokeWidth: 1.2 * sizeScale, }; const cheekOpacity = clamp(v.cheek) * 0.8; /* 창백: 얼굴 윤곽을 4% 줄이고 흐린 마스크를 컴포넌트가 적용한다(§8.4 개정). 여기서는 불투명도만 낸다. */ const pallorClamped = clamp(v.pallor); const pallorColorOpacity = pallorClamped * 0.45; const pallorWhiteOpacity = pallorClamped * 0.12; const sweatOpacity = clamp(v.sweat * 1.3); const dropY = v.sweat * 8; const browOuterLeft = toPoint(rig.landmarks.browLeft.outer); const browOuterRight = toPoint(rig.landmarks.browRight.outer); const sweatDrops: [SweatDrop, SweatDrop] = [ { cx: browOuterLeft[0] - 15, cy: browOuterLeft[1] - 25 + dropY, r: 4 }, { cx: browOuterRight[0] + 15, cy: browOuterRight[1] - 25 + dropY, r: 4 }, ]; return { transforms, eyeLeft, eyeRight, browLeft, browRight, glabella, foreheadInner, foreheadWhole, mouth, nasolabial, chinCrease, jawWarp: jawWarp.frame, faceOvalWarpedPointsAttr, pallorMaskWarpedPointsAttr, lipShadowTranslateY, cheekLeftOpacity: cheekOpacity, cheekRightOpacity: cheekOpacity, pallorColorOpacity, pallorWhiteOpacity, sweatOpacity, sweatDrops, }; } /** 점 배열을 SVG points/polygon용 문자열로. */ function formatPointsAttr(points: Point[]): string { return points.map((p) => `${p[0]},${p[1]}`).join(" "); } /** 다각형을 중심 기준 factor배로 줄인 점 배열(창백 마스크용). */ function shrinkPointsToward(points: readonly RigPoint[], factor: number): Point[] { const cx = points.reduce((s, p) => s + p[0], 0) / points.length; const cy = points.reduce((s, p) => s + p[1], 0) / points.length; return points.map((p) => [cx + (p[0] - cx) * factor, cy + (p[1] - cy) * factor] as Point); } /** R10(§8.4 하관 띠 변형 개정): 점의 y에 변위장 f(y)를 더한다(y≤y_n인 점은 f(y)=0이라 그대로다). 얼굴 그룹 clip 다각형·창백 마스크 다각형에 쓴다 — 얼굴 윤곽이 턱과 함께 내려가야 이미 f(y)로 옮겨진 벡터 부위(팔자·턱 주름 등)와 faceDetail 띠가 원래(정지) 윤곽에서 잘리지 않는다. */ function warpPointsY(points: Point[], f: (y: number) => number): Point[] { return points.map((p) => [p[0], p[1] + f(p[1])] as Point); } /** 얼굴 윤곽 다각형을 SVG points/polygon용 문자열로. */ export function faceOvalPointsAttr(rig: LinocutRig): string { return formatPointsAttr(rig.faceOval.map(toPoint)); } /** 창백 마스크용으로 얼굴 중심 기준 4% 줄인 다각형 points 문자열. */ export function shrunkFaceOvalPointsAttr(rig: LinocutRig, factor = 0.96): string { return formatPointsAttr(shrinkPointsToward(rig.faceOval, factor)); } /** 얼굴 윤곽을 바깥으로 distance만큼 넓힌 다각형(꼭짓점 법선 오프셋, R9 하관 띠 clip 확장). 각 꼭짓점에서 인접한 두 변의 법선 평균 방향으로 밀어낸다. 오목한 지점에서도 "바깥"이 확실하도록 중심에서 멀어지는 쪽으로 부호를 맞춘다(다각형 감김 방향에 기대지 않는다). 얼굴 윤곽처럼 매끈하고 대체로 볼록한 도형에서는 이 근사로 충분하다. */ function offsetPolygonOutward(points: readonly RigPoint[], distance: number): Point[] { const n = points.length; const cx = points.reduce((s, p) => s + p[0], 0) / n; const cy = points.reduce((s, p) => s + p[1], 0) / n; return points.map((p, i) => { const prev = points[(i - 1 + n) % n]; const next = points[(i + 1) % n]; const n1 = normal(toPoint(prev), toPoint(p)); const n2 = normal(toPoint(p), toPoint(next)); let nx = n1[0] + n2[0]; let ny = n1[1] + n2[1]; const len = Math.hypot(nx, ny) || 1; nx /= len; ny /= len; const dot = nx * (p[0] - cx) + ny * (p[1] - cy); if (dot < 0) { nx = -nx; ny = -ny; } return [p[0] + nx * distance, p[1] + ny * distance]; }); } /** R11(§8.4 개정, out6 jaw-zoom 반려 대응): 띠 하나의 clip 다각형 = 띠 사각형 (레이어 로컬 좌표, jawWarpStripRects가 낸 y·h) ∩ 얼굴 윤곽을 바깥으로 distance만큼 넓힌 다각형(R9 offsetPolygonOutward, rig 정적). 이 다각형을 각 띠의 (자신의 세로 이동·배율 transform을 받는 바로 그 요소)에 clip으로 걸면 교집합 경계가 띠 내용과 함께 움직인다 — R9처럼 그룹 전체에 정적 clip을 걸면(out6 반려 사유) 화면에 고정된 경계가 내려간 턱선을 경계마다 가로질러 잘라 점선처럼 보였다. 여백은 처음 18px에서 10px로 줄였다(넓으면 목 해칭까지 함께 내려가 목에 이음매가 생긴다 — 원화 턱선 잉크 두께 약 6px만 들어가면 된다). Sutherland–Hodgman으로 확장 얼굴 다각형을 띠 사각형(축 정렬, 볼록)으로 잘라 교집합 하나만 낸다(볼록 clip 창이라 조각나지 않는다). 채널과 무관해 마운트 시 한 번만 계산한다(jawWarpStripRects와 같은 "정적/매 프레임 분리" 패턴). 교집합이 없는 자리는 null(렌더러가 그 띠를 그리지 않는다). */ export function jawWarpStripClipPolygons( rig: LinocutRig, layer: RigLayer, rects: Array<{ y: number; h: number }>, distance = 10, ): Array { const expanded = offsetPolygonOutward(rig.faceOval, distance); return rects.map((rect) => { const clipped = clipPolygonToRect(expanded, layer.x, rect.y, layer.x + layer.w, rect.y + rect.h); return clipped.length < 3 ? null : formatPointsAttr(clipped); }); } /** 닫힌 다각형을 "M x,y L x,y ... Z" path 부분경로로. evenodd 구멍 뚫기(아래 jawWarpHeadHoleClipD)의 외곽·구멍 부분경로 조립에 쓴다. */ function pointsToClosedPathD(points: Point[]): string { return points.map((p, i) => `${i === 0 ? "M" : "L"} ${p[0]} ${p[1]}`).join(" ") + " Z"; } /** R13(§8.4 개정, out9 jaw-zoom 반려 대응): 원본 head-image(미변형, 정적)는 지금까지 clip 없이 항상 전체가 보였다 — 하관 띠(jaw-warp-head)가 켜진 동안에도 원본이 그대로 깔려 있어, 원본 턱선(제자리)과 띠가 옮겨 그린 턱선(내려간 자리)이 동시에 보여 턱선이 두 겹으로 갈라졌다(측정: DOM에서 head-image의 clip-path가 애초에 null, 정적 전용/전체 전용 두 상태로 캡처하니 "제자리 끊긴 조각"과 "내려간 매끈한 곡선"이 각각 따로 나왔다). faceDetail(R10)이 이미 같은 문제를 "원본 전체 (negligible)/변형 영역을 뺀 사본(그 외)" 두 장으로 나눠 풀었으니, head도 같은 패턴을 쓴다 — 다만 faceDetail과 달리 head-image는 얼굴 윤곽 밖(귀·머리카락·목)도 담고 있어 "y>y_n 전체를 뺀다"(faceDetail의 단순한 사각형 clip)를 그대로 쓰면 그 부분까지 잘려 나간다. 그래서 구멍은 "띠가 실제로 덮는 영역"(rects 합집합 y-범위 ∩ 확장 얼굴 윤곽, jawWarpStripClipPolygons와 같은 확장 거리)만큼만 뚫는다 — 귀·머리카락·목은 얼굴 윤곽 밖이라 구멍에 들지 않고 원본 그대로 남는다. evenodd 한 path에 캔버스 전체 사각형(바깥 부분경로)과 구멍 다각형(안쪽 부분경로)을 함께 넣어 "사각형 − 구멍" 모양 하나를 낸다(다각형 두 개를 clipPath에 따로 넣으면 합집합이 되어 구멍이 뚫리지 않는다). rects가 비어 있으면(레이어가 하관 띠 범위 밖) null — 렌더러는 이때 구멍 없는 원본만 그린다. 채널과 무관해 마운트 시 한 번만 계산한다. R14(§8.4 개정, out10 hair-edge 반려 대응): 구멍 위쪽 경계를 rects 최상단(y_n)이 아니라 입 중심(mouthCenter.y)으로 내린다. jawDisplacementField는 y_n부터 upperLipTop까지 f(y)가 음수(콧날~인중이 위로 들린다) — 이 구간은 띠 내용이 아래(얼굴 윤곽이 좁아지는 자리)에서 끌어올려지므로, 띠의 확장 clip이 얼굴 윤곽 가장자리를 다 덮지 못하고 배경이 비치는 가는 틈이 남는다(볼 가장자리 흰 선). 원본 head-image는 불투명 피부라 이 구간을 그대로 남겨 둬도 띠와 겹쳐 보이지 않는다(faceDetail처럼 잉크 선이 겹쳐 두 겹으로 보이는 레이어가 아니다) — 그래서 입 중심보다 위(코~인중)는 구멍을 뚫지 않고 원본을 남겨 틈을 메운다. 입 중심보다 아래(턱, f(y)가 양수로 전환하는 구간)만 여전히 구멍을 뚫어 두 겹 턱선을 막는다. */ export function jawWarpHeadHoleClipD( rig: LinocutRig, rects: Array<{ y: number; h: number }>, distance = 10, ): string | null { if (rects.length === 0) return null; const top = Math.max(Math.min(...rects.map((r) => r.y)), rig.landmarks.mouthCenter[1]); const bottom = Math.max(...rects.map((r) => r.y + r.h)); const expanded = offsetPolygonOutward(rig.faceOval, distance); const hole = clipPolygonToRect(expanded, 0, top, rig.canvas.w, bottom); if (hole.length < 3) return null; const outer: Point[] = [ [0, 0], [rig.canvas.w, 0], [rig.canvas.w, rig.canvas.h], [0, rig.canvas.h], ]; return `${pointsToClosedPathD(outer)} ${pointsToClosedPathD(hole)}`; } /** 볼록 다각형 clip 창(축 정렬 사각형)으로 다각형을 잘라 교집합 하나를 낸다 (Sutherland–Hodgman). clip 창이 볼록이라 subject(얼굴 윤곽)가 오목해도 조각나지 않고 결과는 항상 단일 폐다각형이다(교집합이 없으면 빈 배열). */ function clipPolygonToRect(poly: Point[], x0: number, y0: number, x1: number, y1: number): Point[] { let out = poly; out = clipHalfPlane(out, (p) => p[0] >= x0, (a, b) => intersectAtX(a, b, x0)); out = clipHalfPlane(out, (p) => p[0] <= x1, (a, b) => intersectAtX(a, b, x1)); out = clipHalfPlane(out, (p) => p[1] >= y0, (a, b) => intersectAtY(a, b, y0)); out = clipHalfPlane(out, (p) => p[1] <= y1, (a, b) => intersectAtY(a, b, y1)); return out; } function clipHalfPlane(poly: Point[], inside: (p: Point) => boolean, intersect: (a: Point, b: Point) => Point): Point[] { if (poly.length === 0) return poly; const out: Point[] = []; for (let i = 0; i < poly.length; i++) { const curr = poly[i]; const prev = poly[(i - 1 + poly.length) % poly.length]; const currIn = inside(curr); const prevIn = inside(prev); if (currIn) { if (!prevIn) out.push(intersect(prev, curr)); out.push(curr); } else if (prevIn) { out.push(intersect(prev, curr)); } } return out; } function intersectAtX(a: Point, b: Point, x: number): Point { const t = (x - a[0]) / (b[0] - a[0]); return [x, a[1] + t * (b[1] - a[1])]; } function intersectAtY(a: Point, b: Point, y: number): Point { const t = (y - a[1]) / (b[1] - a[1]); return [a[0] + t * (b[0] - a[0]), y]; }