fix(study): 선 마디 차단 + 큰 흐름의 굵기 변화 — pressure window-average

사용자 보고: 굵기 변동 없고 선 사이사이 마디 (점선 같은 끊어짐) 보임.

원인: EMA(α=0.15) 가 매 점마다 pressure 살짝 변동 + thinning=0.15 → outline polygon
에 점 간 micro 폭 변동 = 마디. 큰 흐름 변동은 약함.

Fix:
- smoothPressure (EMA) → smoothPressureWindow (마지막 16점 평균).
  매 점 변동은 1/16 수준 → micro 변동 평균화 (마디 차단). 큰 흐름은 따라옴.
- 보간된 점 (8px gap interpolation) 의 pressure 도 모두 sp 동일.
  점진 보간 (lp → sp) 이 outline 에 micro 변동 일으키던 부수 원인 제거.
- thinning 0.15 → 0.22. window 평균이 micro 변동 흡수하니 폭 반응 더 크게 두어도
  마디 안 발생. 큰 흐름의 굵기 변화 명확.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Hyungi Ahn
2026-04-27 15:11:25 +09:00
parent 084b85158b
commit a7de0d0d4e
@@ -214,9 +214,10 @@
if (!path) {
const outline = getStroke(pts, {
size,
// thinning 0.15 — pressure 변동에 stroke 폭 ±15% 반응. EMA 로 부드럽게
// smooth 된 pressure 와 조합 → Notability 같은 자연스러운 굵기 흐름.
thinning: 0.15,
// thinning 0.22 — pressure 변동에 stroke 폭 ±22% 반응. moving-window 평균
// pressure 와 조합 → 큰 흐름의 굵기 변화는 명확하지만 점 간 micro 변동 없음
// (마디 안 발생). Notability 비슷한 자연스러운 변화.
thinning: 0.22,
// smoothing 0.99 — 점 간 보간 사실상 최대.
smoothing: 0.99,
// streamline 0.86 — input lazy 강하게. 손떨림 보정 + 부드러움. 0.9 이상은 lag.
@@ -337,18 +338,25 @@
// 점 사이 거리가 너무 멀면 중간 점 보간 — 빠른 stroke 의 sparse point 점선 방지.
// iPad 60Hz pointermove + 빠른 펜 이동 시 점 간격이 16~30px 가 될 수 있음.
const MAX_GAP_PX = 8;
// pressure EMA — 점 간 pressure 변동을 *지수 이동 평균* 으로 부드럽게.
// alpha 0.15 = 새 값 15% + 이전 값 85% 가중. 잡음/덜컥 변동 제거 + Notability
// 같은 자연스러운 흐름. thinning=0.15 와 조합 시 stroke 굵기가 부드럽게 변함.
const PRESSURE_EMA_ALPHA = 0.15;
function smoothPressure(prev: number, current: number): number {
return prev + (current - prev) * PRESSURE_EMA_ALPHA;
// pressure moving-average window — 마지막 N 점의 pressure 평균. EMA 가 매 점마다
// 살짝 변동시켜 thinning 적용 시 *점 간 micro 폭 변동 = 마디* 일으키던 회귀를
// 차단. window 평균은 큰 흐름만 반영, 매 점 변동은 1/N 수준. 사용자 보고 "선
// 사이사이 애매한 끊어짐" 의 원인이었음.
const PRESSURE_WINDOW = 16;
function smoothPressureWindow(pts: Point[], current: number): number {
let sum = current;
let count = 1;
const start = Math.max(0, pts.length - PRESSURE_WINDOW + 1);
for (let i = start; i < pts.length; i++) {
sum += pts[i][2];
count++;
}
return sum / count;
}
function pushPointWithInterp(target: Stroke, x: number, y: number, p: number) {
const last = target.points[target.points.length - 1];
if (last) {
const lp = last[2];
const sp = smoothPressure(lp, p);
const sp = smoothPressureWindow(target.points, p);
const dx = x - last[0];
const dy = y - last[1];
const dist = Math.hypot(dx, dy);
@@ -356,10 +364,12 @@
const steps = Math.ceil(dist / MAX_GAP_PX);
for (let i = 1; i < steps; i++) {
const t = i / steps;
// 보간된 점들도 모두 sp 동일. 보간 점에 점진 변동 두면 perfect-freehand
// 알고리즘이 outline polygon 에 micro 변동 → 마디. 전부 sp 로 통일.
target.points.push([
last[0] + dx * t,
last[1] + dy * t,
lp + (sp - lp) * t,
sp,
]);
}
}