feat(study): 굵기 단계 시프트 + 부드러움 강화 (선 흔들림 차단)

사용자 요청:
1. 굵기 단계 한 단계씩 가는 쪽 시프트 — 새 thin (0.4) 추가, 새 normal (0.6) =
   이전 thin, 새 thick (1.0) = 이전 normal. 이전 thick (1.6) 제거.
2. 만년필 같은 부드러움 + 약한 압력에도 안정.

Stroke 옵션 튜닝 (선 흔들림 차단):
- thinning 0.18 → 0. pressure 변동에 따른 stroke 폭 변화 제거 → 일정 굵기 → 흔들림
  최소화. 사용자 보고 "선이 흔들림" 의 직접 원인이었음.
- smoothing 0.95 → 0.98. 점 간 보간 거의 최대. Pencil 240Hz 미세 떨림 + 손떨림 흡수.
- streamline 0.7 → 0.82. input lazy 강하게. 0.85 이상은 lag 발생 위험.
- start/end taper effectiveSize*0.5 → 0. 짧은 stroke 시작/끝에서 굵기 급변이 흔들림
  인식 강화. cap round 만 유지로 충분.

Pressure smoothing 함수 추가 (선택적 만년필 효과 잔존):
- pushPointWithInterp 에서 점 간 pressure 변동 5% 이내로 제한.
- thinning 0 인 현재는 visible 영향 없지만, 향후 thinning 도입 시 재활용 가능.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Hyungi Ahn
2026-04-27 15:00:24 +09:00
parent 2041809cb9
commit 187fe2bb01
@@ -94,7 +94,11 @@
let strokeColor = $state('#e4e4e7');
let tool = $state<Tool>('pen');
let widthMode = $state<WidthMode>('normal');
const WIDTH_FACTOR: Record<WidthMode, number> = { thin: 0.6, normal: 1, thick: 1.6 };
// 굵기 단계 한 단계씩 가는 쪽으로 시프트 (사용자 요청):
// 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 };
let effectiveSize = $derived(baseSize * WIDTH_FACTOR[widthMode]);
let eraserRadius = $derived(Math.max(16, effectiveSize * 4));
@@ -205,16 +209,19 @@
if (!path) {
const outline = getStroke(pts, {
size: effectiveSize,
// thinning 0 = pressure 변동에 stroke 폭 영향 받지 않음. Apple Pencil 의
// pressure 변동 (0~1 빈번) 이 stroke 경계를 들쭉날쭉하게 만들던 회귀 차단.
// 마우스 stroke 와 동일한 일정 굵기 = 부드러움.
// thinning 0 pressure 변동에 stroke 폭 영향 안 받음. 일정한 굵기 = 흔들림
// 최소화. 만년필 효과는 *부드러운 흐름* 으로 표현 (smoothing/streamline 강화).
thinning: 0,
// smoothing 0.95 — 점 간 보간 매우 강하게. Pencil 240Hz 미세 떨림 흡수.
smoothing: 0.95,
// streamline 0.7손떨림 보정 강화 (이전 0.65 → 0.7).
streamline: 0.7,
// smoothing 0.98 — 점 간 보간 거의 최대. Pencil 240Hz 미세 떨림 + 손떨림 흡수.
smoothing: 0.98,
// streamline 0.82input lazy 강하게. 손떨림 보정 큼. 너무 높으면 lag.
streamline: 0.82,
simulatePressure: false,
last: !isInflight,
// cap round 만 유지 — taper 는 짧은 stroke 시작/끝에서 굵기 급변 일으켜
// 흔들림 인식 강화. 만년필 nib 효과는 cap 으로 충분.
start: { cap: true, taper: 0 },
end: { cap: true, taper: 0 },
});
if (outline.length < 2) return;
path = new Path2D(getSvgPathFromStroke(outline));
@@ -330,24 +337,35 @@
// 점 사이 거리가 너무 멀면 중간 점 보간 — 빠른 stroke 의 sparse point 점선 방지.
// iPad 60Hz pointermove + 빠른 펜 이동 시 점 간격이 16~30px 가 될 수 있음.
const MAX_GAP_PX = 8;
// pressure 변동 limit — 만년필 효과: 약한 압력에도 부드럽게, 점 간 압력 차이가
// 너무 크면 stroke 폭이 들쭉날쭉해짐. 한 점당 5% 이내로만 변동.
const PRESSURE_SMOOTH_RATE = 0.05;
function smoothPressure(prev: number, current: number): number {
const dp = current - prev;
if (Math.abs(dp) <= PRESSURE_SMOOTH_RATE) return current;
return prev + Math.sign(dp) * PRESSURE_SMOOTH_RATE;
}
function pushPointWithInterp(target: Stroke, x: number, y: number, p: number) {
const last = target.points[target.points.length - 1];
if (last) {
const lp = last[2];
const sp = smoothPressure(lp, p);
const dx = x - last[0];
const dy = y - last[1];
const dist = Math.hypot(dx, dy);
if (dist > MAX_GAP_PX) {
const steps = Math.ceil(dist / MAX_GAP_PX);
const lp = last[2];
for (let i = 1; i < steps; i++) {
const t = i / steps;
target.points.push([
last[0] + dx * t,
last[1] + dy * t,
lp + (p - lp) * t,
lp + (sp - lp) * t,
]);
}
}
target.points.push([x, y, sp]);
return;
}
target.points.push([x, y, p]);
}