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:
@@ -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}
|
||||
/>
|
||||
|
||||
Reference in New Issue
Block a user