feat(study): smart pressure (fixed + intentional change) + 굵기 균형 재조정

사용자 보고 통합:
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) <noreply@anthropic.com>
This commit is contained in:
Hyungi Ahn
2026-04-27 15:41:09 +09:00
parent 56efc6ffc5
commit 294bd775a9
2 changed files with 50 additions and 33 deletions
@@ -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<Tool>('pen');
let widthMode = $state<WidthMode>('normal');
// 굵기 단계 한 단계씩 가는 쪽으로 시프트 (사용자 요청):
// thin 0.4 새로 추가된 더 가는 단계
// normal 0.6이전 thin
// thick 1.0 — 이전 normal (이전 thick 1.6 은 너무 굵어 제거)
const WIDTH_FACTOR: Record<WidthMode, number> = { 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<WidthMode, number> = { 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 안 씀.
@@ -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}
/>