- 표현 엔진: 채널 34개(mouthRound 추가), 표정 28종·강도 곡선·반응 클립 26종·지문 cue, 미세표정 누설 - 발화층: 한글 자모 비짐 9종, TTS 오디오 선분석 정렬, 60ms 앞당김·모음 간 비닫힘 - 발화 동반층: 억양·강세에 맞춘 고개 표류·끄덕임·질문 갸웃·들숨·눈썹 박·시선 회피·쉼 깜빡임 - P1 서연 리노컷 렌더러: 원화 픽셀 입술 띠 변형, 하관 띠 변형(턱·코 들썩), 볼 불룩, 작은 크기 선 보정, 모티프·배경 색면 - /dev/avatar-lab, check:avatar-presets·check:avatar-lipsync, avatar-lab E2E 19개
1622 lines
75 KiB
TypeScript
1622 lines
75 KiB
TypeScript
/* =====================================================================
|
||
리노컷 렌더러 기하 — 결정문 §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<readonly [number, number]>): 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<readonly [number, number]>, 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<readonly [number, number]>): 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<readonly [number, number]> = [
|
||
[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<readonly [number, number]> = [
|
||
[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<LinocutRig, NeutralMouthRef>();
|
||
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 겹침). 채널과 무관해
|
||
렌더러가 마운트 시 한 번만 계산해 <clipPath>에 쓴다. */
|
||
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만큼 넓혀 이웃 띠와 겹친다). 채널과 무관해
|
||
렌더러가 마운트 시 한 번만 계산해 <clipPath>에 쓴다(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<readonly [number, number]> = [
|
||
[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<readonly [number, number]> = [
|
||
[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 정적). 이 다각형을 각
|
||
띠의 <image>(자신의 세로 이동·배율 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<string | null> {
|
||
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];
|
||
}
|