diff --git a/frontend/src/lib/components/HandwriteCanvas.svelte b/frontend/src/lib/components/HandwriteCanvas.svelte index 3ebf6fb..338f237 100644 --- a/frontend/src/lib/components/HandwriteCanvas.svelte +++ b/frontend/src/lib/components/HandwriteCanvas.svelte @@ -21,19 +21,18 @@ import IconButton from '$lib/components/ui/IconButton.svelte'; type Point = [number, number, number]; // [x, y, pressure] - // refW / refH = stroke 가 그려진 시점의 cssWidth / cssHeight. drawStroke 시 - // ctx.scale(cssWidth/refW, cssHeight/refH) 로 비례 보정 → 창 크기 변화 / button - // click 으로 인한 layout shift 후에도 stroke 가 *원래 시각적 위치/비율* 유지 - // (사용자 보고: "펜/지우개 누르면 해당 부분 확대"의 진짜 root cause = stroke - // 좌표가 절대 px 라 cssWidth 변경 시 비례 깨짐). - // _path2d / _size 는 런타임 캐시 — 직렬화 시 제외 (밑줄 prefix 가 marker). + // size = 그렸을 시점의 effectiveSize. widthMode (가늘게/보통/굵게) 변경 시 *기존* + // stroke 의 굵기는 변하지 않도록 (사용자 요청). 새 stroke 만 새 effectiveSize 사용. + // refW / refH = 그렸을 시점의 cssWidth / cssHeight. canvas dimension 변화 후에도 + // stroke 의 시각적 위치/비율 유지 (style:cursor 회귀 fix 와 별개의 비례 보정). + // _path2d 는 런타임 캐시 — 직렬화 시 제외 (밑줄 prefix 가 marker). type Stroke = { id: string; points: Point[]; + size?: number; refW?: number; refH?: number; _path2d?: Path2D; - _size?: number; }; export type StrokesJson = { version: 1; strokes: Stroke[] }; type Tool = 'pen' | 'eraser'; @@ -119,10 +118,11 @@ // Path2D 등 런타임 캐시 (`_` prefix) 제외하고 직렬화. refW/refH 는 그렸을 시점의 // cssWidth/cssHeight — 다른 환경 (창 크기 다른 데스크톱, 모바일 등) 에서 load 시 // 비례 보정에 사용. 없으면 load 시점의 cssWidth/cssHeight 가 기준이 됨. - function serializableStrokes(): Pick[] { + function serializableStrokes(): Pick[] { return strokes.map((s) => ({ id: s.id, points: s.points, + size: s.size, refW: s.refW, refH: s.refH, })); @@ -193,9 +193,14 @@ const pts = s.points; if (pts.length === 0) return; + // stroke 별 size — widthMode 변경 시 기존 stroke 굵기 유지. legacy stroke (size + // 없음) 은 첫 draw 시점의 effectiveSize 로 fix (refW/refH 패턴과 동일). + if (s.size === undefined || s.size <= 0) s.size = effectiveSize; + const size = s.size; + if (pts.length === 1) { const [x, y, p] = pts[0]; - const r = effectiveSize * (0.4 + p * 0.6) / 2; + const r = size * (0.4 + p * 0.6) / 2; ctx.fillStyle = strokeColor; ctx.beginPath(); ctx.arc(x, y, Math.max(1, r), 0, Math.PI * 2); @@ -203,32 +208,26 @@ return; } - // 완료 stroke 는 캐시된 Path2D 재사용 (size 변경 시만 재계산). inflight 는 - // 점이 매 frame 추가되므로 매번 재계산. - let path = !isInflight && s._size === effectiveSize ? s._path2d : undefined; + // 완료 stroke 는 캐시된 Path2D 재사용. stroke.size 가 불변이므로 캐시 영원. + // inflight 는 점이 매 frame 추가되므로 매번 재계산. + let path = !isInflight ? s._path2d : undefined; if (!path) { const outline = getStroke(pts, { - size: effectiveSize, - // thinning 0 — pressure 변동에 stroke 폭 영향 안 받음. 일정한 굵기 = 흔들림 - // 최소화. 만년필 효과는 *부드러운 흐름* 으로 표현 (smoothing/streamline 강화). + size, + // thinning 0 — pressure 변동에 stroke 폭 영향 안 받음. 흔들림 1차 차단. thinning: 0, - // smoothing 0.98 — 점 간 보간 거의 최대. Pencil 240Hz 미세 떨림 + 손떨림 흡수. - smoothing: 0.98, - // streamline 0.82 — input lazy 강하게. 손떨림 보정 큼. 너무 높으면 lag. - streamline: 0.82, + // smoothing 0.99 — 점 간 보간 사실상 최대. + smoothing: 0.99, + // streamline 0.86 — input lazy 강하게. 손떨림 보정 + 부드러움. 0.9 이상은 lag. + streamline: 0.86, 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)); - if (!isInflight) { - s._path2d = path; - s._size = effectiveSize; - } + if (!isInflight) s._path2d = path; } ctx.fillStyle = strokeColor; ctx.fill(path); @@ -462,6 +461,7 @@ inflight = { id: `s_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`, points: [[x, y, normalizePressure(e.pressure)]], + size: effectiveSize, refW: cssWidth, refH: cssHeight, };