From 8b27eadf2e9353ea0b504e584b2bac3d1a03df3d Mon Sep 17 00:00:00 2001 From: Hyungi Ahn Date: Mon, 27 Apr 2026 15:51:10 +0900 Subject: [PATCH] =?UTF-8?q?feat(study):=20PEN=5FPRESET=5FNOTABILITY=5FLIKE?= =?UTF-8?q?=20=E2=80=94=20=EC=82=AC=EC=9A=A9=EC=9E=90=20=EC=A7=80=EC=A0=95?= =?UTF-8?q?=20=ED=94=84=EB=A6=AC=EC=85=8B=20=EC=A0=81=EC=9A=A9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 사용자 분석 + 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) --- .../src/lib/components/HandwriteCanvas.svelte | 72 +++++++++---------- 1 file changed, 33 insertions(+), 39 deletions(-) diff --git a/frontend/src/lib/components/HandwriteCanvas.svelte b/frontend/src/lib/components/HandwriteCanvas.svelte index 3deed29..7488b6c 100644 --- a/frontend/src/lib/components/HandwriteCanvas.svelte +++ b/frontend/src/lib/components/HandwriteCanvas.svelte @@ -96,11 +96,11 @@ let strokeColor = $state('#e4e4e7'); let tool = $state('pen'); let widthMode = $state('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 = { 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 = { 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; }