From 294bd775a94664342c1ea13f3391c27b2c4bc040 Mon Sep 17 00:00:00 2001 From: Hyungi Ahn Date: Mon, 27 Apr 2026 15:41:09 +0900 Subject: [PATCH] =?UTF-8?q?feat(study):=20smart=20pressure=20(fixed=20+=20?= =?UTF-8?q?intentional=20change)=20+=20=EA=B5=B5=EA=B8=B0=20=EA=B7=A0?= =?UTF-8?q?=ED=98=95=20=EC=9E=AC=EC=A1=B0=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 사용자 보고 통합: 1. "기본이 두꺼움" — 평소 stroke 가 두껍게 느껴짐 2. "힘 줘도 일정 이상 안 두꺼워짐" — max 굵기 부족 3. "약하게 그리면 점선" — min 폭 너무 작음 4. "압력 정해지면 stroke 그 굵기 유지" — Notability felt = stroke 내부 일정 5. "의도적 압력 변화 시 굵기 변동" — 단 명확한 변화는 따라옴 Fix: - baseSize 6 → 7. max 두꺼움 보장. - WIDTH_FACTOR { 0.4, 0.6, 1.0 } → { 0.35, 0.5, 0.85 }. 기본 살짝 가늘게. 결과 normal = 7×0.5 = 3.5 (이전 3.6 비슷), thick = 5.95 (충분히 두꺼움). - thinning 0.55 → 0.4. fixedPressure 가 잡음 흡수하니 폭 변동 더 키워도 안정. Smart pressure (getStrokePressure): - raw pressure 정상 시 → 그것 사용 (Pencil pressure 활용). - 비정상 시 → 점 간 거리 기반 속도 추정 (mouse / Pencil 미지원 빌드). - fixedPressure: stroke 시작 시 inputP 로 초기화. 그 후 hybrid update: · 변동 < 15% (잡음/평소) → alpha 0.03 (거의 무시) → 균일 굵기 · 변동 ≥ 15% (의도적 변화) → alpha 0.25 (빠르게 따라감) → 굵기 변화 - simulatePressure: true → false. getStrokePressure 가 자체 처리. 기존 smoothPressureWindow 제거. fixedPressure 가 동일 역할 + Notability felt. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../src/lib/components/HandwriteCanvas.svelte | 81 +++++++++++-------- .../src/routes/study/write/[id]/+page.svelte | 2 +- 2 files changed, 50 insertions(+), 33 deletions(-) diff --git a/frontend/src/lib/components/HandwriteCanvas.svelte b/frontend/src/lib/components/HandwriteCanvas.svelte index d8db44c..b4321d7 100644 --- a/frontend/src/lib/components/HandwriteCanvas.svelte +++ b/frontend/src/lib/components/HandwriteCanvas.svelte @@ -32,10 +32,9 @@ size?: number; refW?: number; refH?: number; - // mouse stroke 는 pressure 정보 없음 (항상 0.5 fallback) → 굵기 변화 0. - // 그 경우 perfect-freehand 의 속도 기반 simulatePressure 사용 = 빠른 stroke - // 가늘게, 느린 stroke 굵게. Pencil 은 실제 pressure 사용. - simPressure?: boolean; + // fixedPressure = stroke 시작 후 처음 N 점에서 측정한 평균 속도 기반 압력. + // 결정 후 stroke 전체 그 굵기 유지 (Notability felt). undefined = 아직 결정 전. + fixedPressure?: number; _path2d?: Path2D; }; export type StrokesJson = { version: 1; strokes: Stroke[] }; @@ -97,11 +96,11 @@ let strokeColor = $state('#e4e4e7'); let tool = $state('pen'); let widthMode = $state('normal'); - // 굵기 단계 한 단계씩 가는 쪽으로 시프트 (사용자 요청): - // thin 0.4 — 새로 추가된 더 가는 단계 - // normal 0.6 — 이전 thin - // thick 1.0 — 이전 normal (이전 thick 1.6 은 너무 굵어 제거) - const WIDTH_FACTOR: Record = { thin: 0.4, normal: 0.6, thick: 1 }; + // 굵기 단계 — 기본 살짝 가늘게 + 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 }; let effectiveSize = $derived(baseSize * WIDTH_FACTOR[widthMode]); let eraserRadius = $derived(Math.max(16, effectiveSize * 4)); @@ -219,14 +218,14 @@ if (!path) { const outline = getStroke(pts, { size, - thinning: 0.55, + // thinning 0.4 — fixedPressure 가 평소 잡음을 흡수하므로 변동 폭 키워도 안정. + // 의도적 큰 압력 변화 시만 굵기 변화 명확히 보임. + thinning: 0.4, smoothing: 0.99, streamline: 0.75, - // simulatePressure: true 항상. Apple Pencil 도 일부 iPadOS 빌드에서 실제 - // pressure 가 PointerEvent 에 정상 도달 안 하거나 일정 → 굵기 변화 0. - // 속도 기반 시뮬 (점 간 거리로 자동 추정) 이 더 robust + Notability 도 속도 - // 기반 felt. 빠른 stroke = 가늘게, 천천히 = 굵게. - simulatePressure: true, + // simulatePressure: false — getStrokePressure 가 raw pressure / 속도 추정 / + // smart smoothing 모두 직접 처리. perfect-freehand 의 자체 시뮬 끔. + simulatePressure: false, last: !isInflight, // cap: false — stroke 끝의 round cap 이 짧은 stroke 에선 dot 처럼 보이는 // 회귀. taper 가 stroke 끝을 자연스럽게 마무리하므로 cap 불필요. @@ -351,25 +350,43 @@ // 부드러운 곡선 생성 → 보간 점 적게 둘 수록 일정 간격 vertex 패턴 (= 사용자 // 보고 "선 사이사이 마디") 안 발생. 단 30px+ gap 은 보간으로 점선 방지. const MAX_GAP_PX = 16; - // pressure moving-average window — 마지막 N 점의 pressure 평균. EMA 가 매 점마다 - // 살짝 변동시켜 thinning 적용 시 *점 간 micro 폭 변동 = 마디* 일으키던 회귀를 - // 차단. window 평균은 큰 흐름만 반영, 매 점 변동은 1/N 수준. 사용자 보고 "선 - // 사이사이 애매한 끊어짐" 의 원인이었음. - const PRESSURE_WINDOW = 8; - 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++; + // Smart pressure — fixed 유지 (잡음/평소) + 의도적 큰 변화 시 따라감. + // 사용자 시나리오: + // - Pencil 정상 + 평소 압력 → fixed 유지 (Notability felt: stroke 내부 균일) + // - Pencil 정상 + 의도적 압력 변화 → 따라감 (굵기 변화) + // - Pencil 비정상 / mouse → raw pressure 가 0 또는 일정 → 속도 기반 추정. + const FIXED_THRESHOLD = 0.15; // 변동 < 15% 잡음, ≥ 15% 의도적 + const FIXED_ALPHA_NOISE = 0.03; + const FIXED_ALPHA_INTENT = 0.25; + function getStrokePressure(target: Stroke, x: number, y: number, rawPressure: number): number { + // 1. inputP 결정 — raw pressure 정상이면 그것, 비정상이면 속도 기반. + let inputP: number; + if (Number.isFinite(rawPressure) && rawPressure > 0.05 && rawPressure < 0.99) { + inputP = rawPressure; + } else { + const last = target.points[target.points.length - 1]; + if (last) { + const dist = Math.hypot(x - last[0], y - last[1]); + // 빠른 (큰 dist) = 약함, 느린 (작은 dist) = 강함. + inputP = Math.max(0.3, Math.min(1.0, 1.5 - dist / 25)); + } else { + inputP = 0.55; + } } - return sum / count; + // 2. fixed 초기화 (첫 점) 또는 hybrid update. + if (target.fixedPressure === undefined) { + target.fixedPressure = inputP; + return inputP; + } + const dev = Math.abs(inputP - target.fixedPressure); + const alpha = dev < FIXED_THRESHOLD ? FIXED_ALPHA_NOISE : FIXED_ALPHA_INTENT; + target.fixedPressure = target.fixedPressure + (inputP - target.fixedPressure) * alpha; + return target.fixedPressure; } function pushPointWithInterp(target: Stroke, x: number, y: number, p: number) { const last = target.points[target.points.length - 1]; if (last) { - const sp = smoothPressureWindow(target.points, p); + const sp = getStrokePressure(target, x, y, p); const dx = x - last[0]; const dy = y - last[1]; const dist = Math.hypot(dx, dy); @@ -377,8 +394,6 @@ 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, @@ -389,7 +404,9 @@ target.points.push([x, y, sp]); return; } - target.points.push([x, y, p]); + // 첫 점 — fixedPressure 초기화 위해 호출 (속도 추정 위해 dummy) + const sp = getStrokePressure(target, x, y, p); + target.points.push([x, y, sp]); } // 점 P 와 선분 [A, B] 사이 최단 거리의 제곱. 빠른 비교를 위해 sqrt 안 씀. diff --git a/frontend/src/routes/study/write/[id]/+page.svelte b/frontend/src/routes/study/write/[id]/+page.svelte index 6dcc47b..a7baadd 100644 --- a/frontend/src/routes/study/write/[id]/+page.svelte +++ b/frontend/src/routes/study/write/[id]/+page.svelte @@ -268,7 +268,7 @@ sessionId={sess.id} initialStrokes={sess.strokes_json} traceText={sess.mode === 'trace' ? sess.source_text : null} - baseSize={sess.metadata?.unit_type === 'kanji' ? 8 : 6} + baseSize={sess.metadata?.unit_type === 'kanji' ? 9 : 7} onChange={onStrokesChange} onSnapshot={onSnapshot} />