feat(study): PEN_PRESET_NOTABILITY_LIKE — 사용자 지정 프리셋 적용

사용자 분석 + 1차 프리셋 반영:
- streamline 0.75 → 0.45. 입력 lazy 줄여 손끝-잉크 latency 감소.
- smoothing 0.99 → 0.82. 기계적 보정 줄여 자연스러운 필기감.
- thinning 0.35 → 0.45. 변동 폭 키워 필압 차이 명확.
- WIDTH_FACTOR { 0.35, 0.50, 0.85 } → { 0.38, 0.55, 0.90 }.
- MAX_GAP_PX 16 → 6. 빠른 stroke 점선 차단 (촘촘 보간).
- start.taper size×0.20 → ×0.15. end.taper ×0.40 → ×0.25. Notability felt.
- cap: false → true. 둥근 끝점.

Smart pressure 강화 (획 내부 균일):
- PRESSURE_FLOOR 0.5 → 0.6. 약한 입력에서도 선 사라지지 않음.
- FIRST_POINT_PRESSURE 0.7 → 0.72.
- FIXED_THRESHOLD 0.15 → 0.18. 잡음 범위 넓게.
- FIXED_ALPHA_NOISE 0.03 → 0.015. 잡음 더 강하게 무시 → 획 내부 균일.
- FIXED_LARGE 0.30 → 0.32.
- FIXED_ALPHA_INTENT 0.50 → 0.40.

getCoalescedEvents 이미 사용 중 — Chrome 의 raw sample 활용 보장.

테스트 기준:
1. 빠른 가로선 점선 안 됨.
2. 천천히 세로선 굵기 출렁이지 않음.
3. 강/약 stroke 차이 보이되 약한 stroke 도 끊김 없음.
4. 한글 자모 빠르게 이어쓸 때 두 번째 획 누락 없음.
5. Chrome 기준 우선 통과.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Hyungi Ahn
2026-04-27 15:51:10 +09:00
parent 1ba425f07a
commit 8b27eadf2e
@@ -96,11 +96,11 @@
let strokeColor = $state('#e4e4e7');
let tool = $state<Tool>('pen');
let widthMode = $state<WidthMode>('normal');
// 굵기 단계 — 기본 살짝 가늘게 + max 두꺼움 보장.
// thin 0.35 — 가는 의도
// normal 0.50 — 기본. baseSize 7 × 0.5 = 3.5px (이전 3.6 비슷, 점선 방지엔 충분)
// thick 0.85 — 굵게 (baseSize 7 × 0.85 = 5.95px)
const WIDTH_FACTOR: Record<WidthMode, number> = { thin: 0.35, normal: 0.5, thick: 0.85 };
// PEN_PRESET_NOTABILITY_LIKE — 사용자 지정 프리셋.
// thin 0.38
// normal 0.55 → baseSize 7 × 0.55 = 3.85px
// thick 0.90 → baseSize 7 × 0.90 = 6.30px
const WIDTH_FACTOR: Record<WidthMode, number> = { thin: 0.38, normal: 0.55, thick: 0.9 };
let effectiveSize = $derived(baseSize * WIDTH_FACTOR[widthMode]);
let eraserRadius = $derived(Math.max(16, effectiveSize * 4));
@@ -218,20 +218,15 @@
if (!path) {
const outline = getStroke(pts, {
size,
// thinning 0.35 — 변동 폭 줄여 min stroke 폭 보장. visual continuity (점선
// 끊김 차단) 우선. 큰 압력 변화 시도 stroke 폭 차이는 inputP floor 0.5
// 와 max 1.0 사이 = 50% 변동 → 폭 ±17.5% 변화 (충분히 보임).
thinning: 0.35,
smoothing: 0.99,
streamline: 0.75,
// simulatePressure: false — getStrokePressure 가 raw pressure / 속도 추정 /
// smart smoothing 모두 직접 처리. perfect-freehand 의 자체 시뮬 끔.
// PEN_PRESET_NOTABILITY_LIKE — 획 내부 안정 + 자연스러운 끝 + 입력 즉시 반응.
thinning: 0.45,
smoothing: 0.82,
streamline: 0.45,
simulatePressure: false,
last: !isInflight,
// cap: false — stroke 끝의 round cap 이 짧은 stroke 에선 dot 처럼 보이는
// 회귀. taper 가 stroke 끝을 자연스럽게 마무리하므로 cap 불필요.
start: { cap: false, taper: size * 0.2, easing: (t) => t * (2 - t) },
end: { cap: false, taper: size * 0.4, easing: (t) => t * (2 - t) },
// taper 짧게 — Notability 처럼 끝부분만 살짝 빠짐. cap: true 로 둥근 끝.
start: { cap: true, taper: size * 0.15, easing: (t) => t * (2 - t) },
end: { cap: true, taper: size * 0.25, easing: (t) => t * (2 - t) },
});
if (outline.length < 2) return;
path = new Path2D(getSvgPathFromStroke(outline));
@@ -345,35 +340,35 @@
return Math.max(MIN_PRESSURE, Math.min(1, p));
}
// 점 사이 거리가 너무 멀면 중간 점 보간 — 빠른 stroke 의 sparse point 점선 방지.
// iPad 60Hz pointermove + 빠른 펜 이동 시 점 간격이 16~30px 가 될 수 있음.
// 16 = 절충값. perfect-freehand smoothing 0.99 / streamline 0.86 가 sparse 점에서도
// 부드러운 곡선 생성 → 보간 점 적게 둘 수록 일정 간격 vertex 패턴 (= 사용자
// 보고 "선 사이사이 마디") 안 발생. 단 30px+ gap 은 보간으로 점선 방지.
const MAX_GAP_PX = 16;
// Smart pressure — 3단계 threshold.
// 잡음 (dev < 15%) → alpha 0.03 (거의 무시, fixed 유지)
// 의도적 (15% ≤ dev < 30%) → alpha 0.5 (빠르게 따라감)
// 매우 큼 (dev ≥ 30%) → 즉시 update (사용자 빡세게 누름 같은 큰 변화)
const FIXED_THRESHOLD = 0.15;
const FIXED_LARGE = 0.3;
const FIXED_ALPHA_NOISE = 0.03;
const FIXED_ALPHA_INTENT = 0.5;
// 입력 보간 거리 — 사용자 분석: 16 은 빠른 stroke 시 점 간격 벌어져 ".........."
// 효과. 6 으로 줄여 촘촘히 보간. getCoalescedEvents() 의 raw sample 도 활용.
const MAX_GAP_PX = 6;
// Notability felt — 획 내부 균일 + 의도적 변화만 반영.
// 잡음 (dev < 0.18) → α 0.015 (강하게 무시) → 획 내부 압력 노이즈 억제
// 의도적 (0.18 ≤ dev < 0.32) → α 0.4 (자연스럽게 따라감)
// 매우 큼 (dev ≥ 0.32) → 즉시 update (강한 필압 변화 반응)
const FIXED_THRESHOLD = 0.18;
const FIXED_LARGE = 0.32;
const FIXED_ALPHA_NOISE = 0.015;
const FIXED_ALPHA_INTENT = 0.4;
// pressure floor 0.6 — 약한 입력에도 stroke 가 사라지거나 점선 안 되게.
// firstPointPressure 0.72 — 첫 점 fallback (Notability 기본 굵기 느낌).
const PRESSURE_FLOOR = 0.6;
const FIRST_POINT_PRESSURE = 0.72;
function getStrokePressure(target: Stroke, x: number, y: number, rawPressure: number): number {
// 1. inputP 결정. floor 0.5 — 가장 약한 입력에도 stroke 가 픽셀 단위 끊김
// 없이 visual continuity 유지 (사용자 보고: 빠른 stroke 가 점선처럼 끊김).
// 1. inputP 결정.
let inputP: number;
if (Number.isFinite(rawPressure) && rawPressure > 0.1 && rawPressure < 0.99) {
// raw pressure 0.1~0.99 → 0.5~1.0 매핑. floor 0.5.
inputP = 0.5 + ((rawPressure - 0.1) / 0.89) * 0.5;
// raw pressure 0.1~0.99 → 0.6~1.0 매핑. floor 보장.
inputP = PRESSURE_FLOOR + ((rawPressure - 0.1) / 0.89) * (1 - PRESSURE_FLOOR);
} else {
const last = target.points[target.points.length - 1];
if (last) {
const dist = Math.hypot(x - last[0], y - last[1]);
// 속도 기반 0.5~1.0. 빠른 stroke 도 가늘어지지만 minimum 폭 보장.
inputP = Math.max(0.5, Math.min(1.0, 1.6 - dist / 25));
// 속도 기반 0.6~1.0.
inputP = Math.max(PRESSURE_FLOOR, Math.min(1.0, 1.6 - dist / 25));
} else {
inputP = 0.7;
inputP = FIRST_POINT_PRESSURE;
}
}
// 2. fixed hybrid.
@@ -383,7 +378,6 @@
}
const dev = Math.abs(inputP - target.fixedPressure);
if (dev >= FIXED_LARGE) {
// 매우 큰 변화 — 즉시 update.
target.fixedPressure = inputP;
return inputP;
}