리노컷 공통 파이프라인 캐스트 확장 — 정렬·정규화·얼굴 해칭·원화 눈썹·눈 실측

- register_edit(편집본 ECC 정렬·챔퍼 지표), normalize_base(P1 머리 크기 정규화, 위쪽 종이색 채움)
- faceDetail region=face(얼굴 해칭을 원화 픽셀로 보존)·matchFacelessColor
- brow_sprite 단계: 원화 눈썹 잉크 스프라이트(hairFront 아래 제외), 윗눈꺼풀 두께·홍채 실측
- 리그 계약 RigBrow.halfWidth/sprite·RigEye.lineScale, 렌더러 눈썹 16띠·바깥쪽 눈꺼풀 굵기
- 실행기 UTF-8 하위 실행, brow_centerline 패딩본 자체 생성, QA 시트 촬영 도구(깜빡임 회피·고해상도 대조)
- P1은 새 필드를 모두 끄고 게시 자산·리그 바이트 동일
This commit is contained in:
Yun Chan 2026-10-01 22:38:27 +09:00
parent 9a22d1647b
commit 98577cf4ae
18 changed files with 2524 additions and 85 deletions

View file

@ -16,6 +16,8 @@ import {
shrunkFaceOvalPointsAttr,
lipStripClipRect,
LIP_STRIP_COUNT,
browSpriteStripClipRect,
BROW_STRIP_COUNT,
jawWarpStripRects,
jawWarpStripClipPolygons,
jawWarpHeadHoleClipD,
@ -75,9 +77,13 @@ function applyEye(refs: Refs, side: "left" | "right", eye: LinocutFrame["eyeLeft
if (crease) {
crease.setAttribute("d", eye.creaseD);
crease.setAttribute("opacity", String(eye.creaseOpacity));
crease.setAttribute("stroke-width", String(eye.creaseStrokeWidth));
}
const lowerLid = refs[`${p}-lower-lid`] as SVGPathElement | null;
if (lowerLid) lowerLid.setAttribute("d", eye.lowerLidD);
if (lowerLid) {
lowerLid.setAttribute("d", eye.lowerLidD);
lowerLid.setAttribute("stroke-width", String(eye.lowerLidStrokeWidth));
}
const shadow = refs[`${p}-shadow`] as SVGPathElement | null;
if (shadow) shadow.setAttribute("d", eye.shadowD);
@ -208,6 +214,16 @@ function applyFrame(refs: Refs, frame: LinocutFrame): void {
browRightShadow.setAttribute("cy", String(frame.browRight.shadowCy));
browRightShadow.setAttribute("opacity", String(frame.browRight.shadowOpacity));
}
/* 눈썹 스프라이트 띠 변형(결정문 §8.4.1): sprite가 없는 리그는 spriteStrips가
빈 배열이라 ref도 없어(JSX가 아예 안 그린다) 아래는 그냥 건너뛴다. */
for (let i = 0; i < BROW_STRIP_COUNT; i++) {
const stripLeft = refs[`brow-left-strip-${i}`] as SVGImageElement | null;
const sl = frame.browLeft.spriteStrips[i];
if (stripLeft && sl) stripLeft.setAttribute("transform", `translate(${sl.dx} ${sl.dy})`);
const stripRight = refs[`brow-right-strip-${i}`] as SVGImageElement | null;
const sr = frame.browRight.spriteStrips[i];
if (stripRight && sr) stripRight.setAttribute("transform", `translate(${sr.dx} ${sr.dy})`);
}
const glabella = refs.glabella as SVGPathElement | null;
if (glabella) {
@ -394,6 +410,8 @@ export default function LinocutAvatar({
if (rig.lipTexture) hrefs.push(rig.lipTexture.upper.href, rig.lipTexture.lower.href);
if (rig.lipTexture?.shadow) hrefs.push(rig.lipTexture.shadow.href);
if (rig.jaw) hrefs.push(rig.jaw.head.href, rig.jaw.detail.href);
if (rig.landmarks.browLeft.sprite) hrefs.push(rig.landmarks.browLeft.sprite.href);
if (rig.landmarks.browRight.sprite) hrefs.push(rig.landmarks.browRight.sprite.href);
return hrefs;
}, [rig, texture]);
@ -865,10 +883,68 @@ export default function LinocutAvatar({
/>
<g filter={texture ? `url(#${inkFilterId})` : undefined}>
<path data-part="brow-left" ref={setRef(refs.current, "brow-left")} fill={pal.ink} opacity={0.6} />
<path ref={setRef(refs.current, "brow-left-hair")} className="linocut-avatar__brow-hair" stroke={pal.ink} />
<path data-part="brow-right" ref={setRef(refs.current, "brow-right")} fill={pal.ink} opacity={0.6} />
<path ref={setRef(refs.current, "brow-right-hair")} className="linocut-avatar__brow-hair" stroke={pal.ink} />
{/* 눈썹(결정문 §8.4.1): rig.landmarks.browLeft/Right.sprite가 있으면
원화 눈썹 스프라이트를 세로 BROW_STRIP_COUNT개 띠로 나눠 그린다
(벡터 몸통·털 획은 그리지 않는다 — 안쪽 올림 그늘은 아래에서
그대로 그린다). 없으면 기존 벡터 경로를 그린다. */}
{!rig.landmarks.browLeft.sprite ? (
<>
<path data-part="brow-left" ref={setRef(refs.current, "brow-left")} fill={pal.ink} opacity={0.6} />
<path ref={setRef(refs.current, "brow-left-hair")} className="linocut-avatar__brow-hair" stroke={pal.ink} />
</>
) : (
Array.from({ length: BROW_STRIP_COUNT }, (_, i) => {
const sprite = rig.landmarks.browLeft.sprite!;
const rect = browSpriteStripClipRect(sprite, i);
const stripClipId = `${uid}-brow-left-strip-clip-${i}`;
return (
<g key={`bl${i}`}>
<clipPath id={stripClipId} clipPathUnits="userSpaceOnUse">
<rect x={rect.x} y={rect.y} width={rect.w} height={rect.h} />
</clipPath>
<image
ref={setRef(refs.current, `brow-left-strip-${i}`)}
href={sprite.href}
x={sprite.x}
y={sprite.y}
width={sprite.w}
height={sprite.h}
preserveAspectRatio="none"
clipPath={`url(#${stripClipId})`}
/>
</g>
);
})
)}
{!rig.landmarks.browRight.sprite ? (
<>
<path data-part="brow-right" ref={setRef(refs.current, "brow-right")} fill={pal.ink} opacity={0.6} />
<path ref={setRef(refs.current, "brow-right-hair")} className="linocut-avatar__brow-hair" stroke={pal.ink} />
</>
) : (
Array.from({ length: BROW_STRIP_COUNT }, (_, i) => {
const sprite = rig.landmarks.browRight.sprite!;
const rect = browSpriteStripClipRect(sprite, i);
const stripClipId = `${uid}-brow-right-strip-clip-${i}`;
return (
<g key={`br${i}`}>
<clipPath id={stripClipId} clipPathUnits="userSpaceOnUse">
<rect x={rect.x} y={rect.y} width={rect.w} height={rect.h} />
</clipPath>
<image
ref={setRef(refs.current, `brow-right-strip-${i}`)}
href={sprite.href}
x={sprite.x}
y={sprite.y}
width={sprite.w}
height={sprite.h}
preserveAspectRatio="none"
clipPath={`url(#${stripClipId})`}
/>
</g>
);
})
)}
<path data-part="glabella" ref={setRef(refs.current, "glabella")} stroke={pal.ink} className="linocut-avatar__wrinkle" />
<path data-part="forehead-inner" ref={setRef(refs.current, "forehead-inner")} fill={pal.ink} className="linocut-avatar__wrinkle-ribbon" />

View file

@ -364,6 +364,11 @@ export interface EyeGeometry {
highlightCx: number;
highlightCy: number;
highlightRadius: number;
/** 쌍꺼풀 주름(creaseD) stroke-width = sizeScale × lineScale × 1.2(기존 CSS 고정값).
홍채·흰자에는 lineScale을 곱하지 않는다(결정문 §8.4.1). */
creaseStrokeWidth: number;
/** 아랫눈꺼풀(lowerLidD) stroke-width = sizeScale × lineScale × 1.2. */
lowerLidStrokeWidth: number;
radialStrokesD: string;
tearPoolD: string;
tearPoolOpacity: number;
@ -390,6 +395,16 @@ interface EyeChannels {
const CRESCENT_SAMPLES = 14;
const CREASE_SAMPLES = 8;
const LOWER_LID_SAMPLES = 10;
/** 쌍꺼풀·아랫눈꺼풀 stroke-width 기준값(기존 CSS 고정값 1.2와 같다, linocut-avatar.css
.linocut-avatar__crease/.lower-lid). eye.lineScale이 없으면(기본 1) 전과 같다. */
const CREASE_LOWER_LID_BASE_STROKE_WIDTH = 1.2;
/** 윗눈꺼풀 리본 두께 기준식(t_base(s), s는 O→I 곡선 매개변수 0~1). lineScale=1일 때
curveTop에서 안쪽(눈 틈 쪽)으로 더하는 두께이자, lineScale>1일 때 바깥쪽으로 추가로
더하는 두께의 단위이기도 하다(T2, 아래 buildEye 참고). */
function lidThicknessAt(s: number): number {
return 1.5 + 4 * (1 - smoothstep(0.45, 1, s));
}
function buildEye(eye: RigEye, ch: EyeChannels, isScreenRight: boolean, sizeScale: number): EyeGeometry {
const O = toPoint(eye.outer);
@ -398,6 +413,9 @@ function buildEye(eye: RigEye, ch: EyeChannels, isScreenRight: boolean, sizeScal
const D = toPoint(eye.lowerLidBottom);
const C = toPoint(eye.iris.center);
const r = eye.iris.radius;
/* 원화 윗눈꺼풀 잉크 두께 ÷ P1 두께(결정문 §8.4.1) — 눈꺼풀(아래 t)·쌍꺼풀·아랫눈꺼풀
굵기에만 곱한다. 홍채·흰자(irisRadius·irisRingWidth 등)에는 곱하지 않는다. */
const lineScale = eye.lineScale ?? 1;
/* 크게 뜬 눈(eyeOpen>1) 증폭: 그대로 두면 놀람이 잘 안 읽힌다(1차 반려 "표정 간 차이가
작다"와 연결). e는 eyeOpen<=1이면 그대로, 넘으면 1.8배 증폭(최대 1.5)한다. */
@ -424,11 +442,21 @@ function buildEye(eye: RigEye, ch: EyeChannels, isScreenRight: boolean, sizeScal
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) => {
/* 윗눈꺼풀 선: s=0(바깥 O)→1(안쪽 I), t_base(s) = lidThicknessAt(s).
2차 보정(T2, 2단계-B-2a 반려 — 안쪽 경계가 홍채 위를 덮었다): lineScale로 늘어나는
두께 (lineScale−1)·t_base는 바깥쪽(위, curveTop 기준 음의 y)으로만 더한다. 안쪽
경계(crescentBottom, 눈 틈·홍채 쪽)는 lineScale과 무관하게 항상 curveTop + t_base다
(= lineScale=1일 때의 자리와 같다). lineScale=1이면 extra=0이라 crescentTop=curveTop,
crescentBottom은 전과 바이트 단위로 같다. */
const curveTop = sampleQuad(O, upperControl, I, 0, 1, CRESCENT_SAMPLES);
const crescentTop = curveTop.map((p, i) => {
const s = i / (CRESCENT_SAMPLES - 1);
const t = sizeScale * (1.5 + 4 * (1 - smoothstep(0.45, 1, s)));
const extra = sizeScale * (lineScale - 1) * lidThicknessAt(s);
return [p[0], p[1] - extra] as Point;
});
const crescentBottom = curveTop.map((p, i) => {
const s = i / (CRESCENT_SAMPLES - 1);
const t = sizeScale * lidThicknessAt(s);
return [p[0], p[1] + t] as Point;
});
const tailDir = (() => {
@ -447,11 +475,20 @@ function buildEye(eye: RigEye, ch: EyeChannels, isScreenRight: boolean, sizeScal
"Z",
].join(" ");
/* 쌍꺼풀 주름: s 0.15~0.85, 윗곡선 위로 9 + 5·max(0, openU−1)만큼, 불투명도 0.6 기준. */
/* 쌍꺼풀 주름: s 0.15~0.85, 윗곡선 위로 9 + 5·max(0, openU−1)만큼. lineScale>1이면
윗눈꺼풀 리본이 바깥쪽으로 그만큼 두꺼워지므로(위 crescentTop), 주름도 같은 양만큼
더 위로 비켜 겹치지 않게 한다(T2). */
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 creasePts = Array.from({ length: CREASE_SAMPLES }, (_, i) => {
const t = lerp(0.15, 0.85, i / (CREASE_SAMPLES - 1));
const p = quadPointAt(O, upperControl, I, t);
const extra = sizeScale * (lineScale - 1) * lidThicknessAt(t);
return [p[0], p[1] - creaseOffset - extra] 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;
const creaseStrokeWidth = sizeScale * lineScale * CREASE_LOWER_LID_BASE_STROKE_WIDTH;
const lowerLidStrokeWidth = sizeScale * lineScale * CREASE_LOWER_LID_BASE_STROKE_WIDTH;
/* 아랫눈꺼풀: s 0~0.8 가는 선. */
const lowerPts = sampleQuad(O, lowerControl, I, 0, 0.8, LOWER_LID_SAMPLES);
@ -523,6 +560,8 @@ function buildEye(eye: RigEye, ch: EyeChannels, isScreenRight: boolean, sizeScal
highlightCx,
highlightCy,
highlightRadius: 0.13 * r,
creaseStrokeWidth,
lowerLidStrokeWidth,
radialStrokesD: radialSegments.join(" "),
tearPoolD,
tearPoolOpacity: pool,
@ -549,6 +588,11 @@ export interface BrowGeometry {
shadowCx: number;
shadowCy: number;
shadowOpacity: number;
/** 눈썹 스프라이트 띠 변형(결정문 §8.4.1, rig.landmarks.browLeft/Right.sprite가
있는 리그 한정). 없으면 빈 배열 — 렌더러가 기존 벡터 몸통·털 획(bodyD·
hairStrokesD)을 그린다. 있으면 렌더러가 이 배열로 스프라이트 띠를 옮기고
벡터 몸통·털 획은 그리지 않는다(안쪽 올림 그늘은 그대로 그린다). */
spriteStrips: BrowStripMatrix[];
}
interface BrowChannels {
@ -564,14 +608,26 @@ const BROW_STOPS: Array<readonly [number, number]> = [
[1, 1.0],
];
function buildBrow(brow: RigBrow, ch: BrowChannels, side: "left" | "right", sizeScale: number): BrowGeometry {
interface BrowDisplacement {
innerDy: number;
peakDy: number;
outerDy: number;
innerDx: number;
innerP: Point;
peakP: Point;
outerP: Point;
}
/** browAngle a는 부호에 따라 다른 계수를 쓴다(AU1 안쪽 올림은 바깥이 따라 오르지 않는다,
§8.4 2단계-B-1d 개정): a>0이면 안쪽 −60a·꼭짓점 −4a·바깥 +18a, a<0(AU4)이면
안쪽 −50a·꼭짓점 −12a·바깥 +14a. buildBrow(벡터 몸통·털 획)와
computeBrowSpriteStripTransforms(스프라이트 띠) 둘 다 이 변위를 쓴다(§8.4.1 —
"같은 계수로 구한" 요구, 중복 계산 아님). */
function computeBrowDisplacement(brow: RigBrow, ch: BrowChannels, side: "left" | "right"): BrowDisplacement {
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;
@ -582,13 +638,91 @@ function buildBrow(brow: RigBrow, ch: BrowChannels, side: "left" | "right", size
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];
return {
innerDy,
peakDy,
outerDy,
innerDx,
innerP: [inner[0] + innerDx, inner[1] + innerDy],
peakP: [peak[0], peak[1] + peakDy],
outerP: [outer[0], outer[1] + outerDy],
};
}
export const BROW_STRIP_COUNT = 16;
export interface BrowStripMatrix {
dx: number;
dy: number;
}
/** 세 점 (x0,y0)(x1,y1)(x2,y2)를 "정확히" 지나는 2차 다항식 y=f(x)의 x에서의
값(라그랑주 보간). quadControlThrough(베지어 제어점)는 t=0.5가 mid의 x를
지난다는 보장이 없어(x 간격이 고르지 않으면 어긋난다) 눈썹처럼 inner·peak·outer
간격이 비대칭인 랜드마크에는 안 맞는다. */
function quadLagrangeAt(p0: Point, p1: Point, p2: Point, x: number): number {
const [x0, y0] = p0;
const [x1, y1] = p1;
const [x2, y2] = p2;
const l0 = ((x - x1) * (x - x2)) / ((x0 - x1) * (x0 - x2));
const l1 = ((x - x0) * (x - x2)) / ((x1 - x0) * (x1 - x2));
const l2 = ((x - x0) * (x - x1)) / ((x2 - x0) * (x2 - x1));
return y0 * l0 + y1 * l1 + y2 * l2;
}
/** 눈썹 스프라이트 띠 변형(결정문 §8.4.1): 원화 눈썹 스프라이트를 세로
BROW_STRIP_COUNT개 띠로 나눠 computeBrowDisplacement와 같은 계수로 구한
inner·peak·outer 변위만큼만 옮긴다(스케일 없음 — 원화 픽셀을 늘리면 잉크
굵기가 왜곡된다). 띠 i의 중심 x에서: dy(x)는 (inner.x,innerDy)·(peak.x,peakDy)·
(outer.x,outerDy)를 지나는 2차 보간(quadLagrangeAt), dx는 innerDx ×
(안쪽→바깥 선형 가중 1→0)이다. brow.sprite가 없으면 null(렌더러가 기존 벡터
경로를 쓴다). */
export function computeBrowSpriteStripTransforms(
brow: RigBrow,
ch: BrowChannels,
side: "left" | "right",
stripCount: number = BROW_STRIP_COUNT,
): BrowStripMatrix[] | null {
if (!brow.sprite) return null;
const disp = computeBrowDisplacement(brow, ch, side);
const innerX = brow.inner[0];
const outerX = brow.outer[0];
const span = outerX - innerX || 1;
const p0: Point = [innerX, disp.innerDy];
const p1: Point = [brow.peak[0], disp.peakDy];
const p2: Point = [outerX, disp.outerDy];
const sprite = brow.sprite;
const stripW = sprite.w / stripCount;
const out: BrowStripMatrix[] = [];
for (let i = 0; i < stripCount; i++) {
const cx = sprite.x + (i + 0.5) * stripW;
const weight = clamp(1 - (cx - innerX) / span, 0, 1);
out.push({ dx: disp.innerDx * weight, dy: quadLagrangeAt(p0, p1, p2, cx) });
}
return out;
}
/** 눈썹 스프라이트 띠 clip 사각형(스프라이트 폭 기준 등분, 이웃과 1px 겹침) —
lipStripClipRect와 같은 패턴. 채널과 무관해 렌더러가 마운트 시 한 번만
계산해 <clipPath>에 쓴다. */
export function browSpriteStripClipRect(sprite: RigLayer, index: number, stripCount: number = BROW_STRIP_COUNT) {
const stripW = sprite.w / stripCount;
return { x: sprite.x + index * stripW - 0.5, y: sprite.y, w: stripW + 1, h: sprite.h };
}
function buildBrow(brow: RigBrow, ch: BrowChannels, side: "left" | "right", sizeScale: number): BrowGeometry {
const disp = computeBrowDisplacement(brow, ch, side);
const { innerP, peakP, outerP } = disp;
/* "세 점을 지나는 곡선(12점 이상 표본)", 꺾인 직선 금지(1차 반려 사유). 작은 화면
보정(§8.4 2단계-B-1d)으로 반두께·털 획 길이·굵기에 sizeScale을 곱한다. */
const scaledStops = BROW_STOPS.map(([t, hw]) => [t, hw * sizeScale] as const);
보정(§8.4 2단계-B-1d)으로 반두께·털 획 길이·굵기에 sizeScale을 곱한다.
rig에 원화 실측 반두께(halfWidth)가 있으면 그것을, 없으면 P1 설계값(BROW_STOPS)을
쓴다(캐스트 확장, 결정문 §8.4). */
const stops: Array<readonly [number, number]> = brow.halfWidth
? [[0, brow.halfWidth[0]], [0.5, brow.halfWidth[1]], [1, brow.halfWidth[2]]]
: BROW_STOPS;
const scaledStops = 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;
@ -596,12 +730,17 @@ function buildBrow(brow: RigBrow, ch: BrowChannels, side: "left" | "right", size
const hairStrokeWidth = 1.3 * sizeScale;
/* 안쪽 올림 그늘(§8.4): a>0.3이면 눈썹 머리 위에 흐린 잉크 그늘(가우시안 흐림은
렌더러의 SVG 필터가 맡는다, 여기서는 위치·불투명도만). */
const shadowOpacity = 0.18 * clamp((a - 0.3) / 0.7);
렌더러의 SVG 필터가 맡는다, 여기서는 위치·불투명도만). 스프라이트가 있는 리그도
이 그늘은 그대로 그린다(§8.4.1 — 벡터 몸통·털 획만 스프라이트로 대체한다). */
const shadowOpacity = 0.18 * clamp((ch.angle - 0.3) / 0.7);
const shadowCx = innerP[0];
const shadowCy = innerP[1] - 18;
return { bodyD, hairStrokesD, hairStrokeWidth, innerP, peakP, shadowCx, shadowCy, shadowOpacity };
/* 눈썹 스프라이트 띠 변형(§8.4.1): sprite가 없으면 빈 배열 — 렌더러가 위
bodyD·hairStrokesD(벡터 경로)를 그대로 그린다. */
const spriteStrips = computeBrowSpriteStripTransforms(brow, ch, side) ?? [];
return { bodyD, hairStrokesD, hairStrokeWidth, innerP, peakP, shadowCx, shadowCy, shadowOpacity, spriteStrips };
}
/* ── 보조 주름 ─────────────────────────────────────────────────────── */

View file

@ -28,6 +28,9 @@ export interface RigEye {
upperLidTop: RigPoint;
lowerLidBottom: RigPoint;
iris: { center: RigPoint; radius: number };
/** 눈꺼풀·쌍꺼풀·아랫눈꺼풀 선 굵기 배율 — 원화 윗눈꺼풀 잉크 두께 ÷ P1 두께(결정문 §8.4.1).
굵은 판화 선 캐릭터의 벡터 눈이 가늘고 흐려지는 것을 막는다. 없으면 1. */
lineScale?: number;
}
/** 눈썹 잉크 띠의 **중심선** 위 세 점(결정문 §8.4). 띠의 윗가장자리·아래 모서리를 찍으면
@ -36,6 +39,13 @@ export interface RigBrow {
inner: RigPoint;
peak: RigPoint;
outer: RigPoint;
/** 원화 잉크 띠의 반두께(px) — [안쪽, 꼭짓점, 바깥]. 캐스트마다 눈썹 굵기가 달라(남성·성인은
더 굵다) 원화에서 잰 값을 쓴다. 없으면 P1 설계값 [4.5, 3.2, 1.0](결정문 §8.4 캐스트 확장). */
halfWidth?: readonly [number, number, number];
/** 원화 눈썹 잉크 스프라이트(결정문 §8.4.1). 알파는 잉크 농도(피부는 투명)이고 앞머리 가닥은
빠져 있다. 있으면 렌더러는 벡터 눈썹 몸통·털 획 대신 이 픽셀을 세로 띠로 나눠, 각 띠를
inner·peak·outer 변위를 잇는 곡선 값만큼 옮긴다(입술 결과 같은 "중립 렌더 = 원화" 원칙). */
sprite?: RigLayer;
}
export interface RigLandmarks {