fix(study): perfect-freehand 미사용으로 단순 ctx.stroke() 전환 + 좌표 scale 보정

증상: stroke count 는 올라가는데 화면에 그려지지 않음 + 위치 어긋남.

원인 격리 시도:
- perfect-freehand 의 polygon fill 이 일부 환경에서 제대로 그려지지 않는 것으로
  보여 단순 ctx.beginPath/moveTo/lineTo/stroke() 로 갈아치움. lineCap/lineJoin
  'round' + lineWidth=baseSize 로 자연스러운 라인. 압력 효과는 일시 제거.
- getLocalXY 에 scale 보정 추가: canvas.style.width(cssWidth) 와 rect.width 가
  다른 ResizeObserver 지연 케이스에서 좌표가 어긋나지 않도록 비율 보정.

이번 변경으로도 stroke 가 안 보이면 디버그 오버레이의 좌표/크기를 보고
다른 경로 (캔버스 자체 비활성, layer 가림 등) 추적.
This commit is contained in:
Hyungi Ahn
2026-04-27 09:00:39 +09:00
parent 77790d6dc1
commit 85659ce928
@@ -18,7 +18,6 @@
* (전체 지우기 버튼은 별도)
*/
import { onMount, onDestroy } from 'svelte';
import { getStroke } from 'perfect-freehand';
import { Eraser, Pencil, Undo2, Redo2, Trash2 } from 'lucide-svelte';
import IconButton from '$lib/components/ui/IconButton.svelte';
@@ -118,24 +117,30 @@
redraw();
}
// ── render ──
function strokeToPath(_stroke: Stroke): Path2D {
const outlinePoints = getStroke(_stroke.points, {
size: baseSize,
thinning: 0.3, // 시작 부분이 너무 얇아지지 않게 (0.5 → 0.3)
smoothing: 0.5,
streamline: 0.4,
simulatePressure: true, // pressure 0 으로 들어오는 케이스 방어
last: true, // 진행 중에도 양쪽 outline + cap 완성 (polygon 닫힘 보장)
});
const path = new Path2D();
if (outlinePoints.length === 0) return path;
path.moveTo(outlinePoints[0][0], outlinePoints[0][1]);
for (let i = 1; i < outlinePoints.length; i++) {
path.lineTo(outlinePoints[i][0], outlinePoints[i][1]);
// ── render — 단순 ctx.stroke() 로 갈아치움 (perfect-freehand 미사용).
// 이유: perfect-freehand 의 polygon fill 이 그려지지 않는 보고. 단순 line 으로
// 안정성 확보 후 문제 격리. 압력 효과는 lineWidth 변경으로 흉내.
function drawStroke(ctx: CanvasRenderingContext2D, s: Stroke) {
if (s.points.length === 0) return;
ctx.beginPath();
ctx.lineCap = 'round';
ctx.lineJoin = 'round';
ctx.lineWidth = baseSize;
ctx.strokeStyle = strokeColor;
if (s.points.length === 1) {
// 점 하나만 있으면 작은 원
const [x, y] = s.points[0];
ctx.beginPath();
ctx.fillStyle = strokeColor;
ctx.arc(x, y, baseSize / 2, 0, Math.PI * 2);
ctx.fill();
return;
}
path.closePath();
return path;
ctx.moveTo(s.points[0][0], s.points[0][1]);
for (let i = 1; i < s.points.length; i++) {
ctx.lineTo(s.points[i][0], s.points[i][1]);
}
ctx.stroke();
}
function drawTraceBackground(ctx: CanvasRenderingContext2D) {
@@ -156,12 +161,11 @@
if (!ctx) return;
ctx.clearRect(0, 0, cssWidth, cssHeight);
drawTraceBackground(ctx);
ctx.fillStyle = strokeColor;
for (const s of strokes) {
ctx.fill(strokeToPath(s));
drawStroke(ctx, s);
}
if (inflight) {
ctx.fill(strokeToPath(inflight));
drawStroke(ctx, inflight);
}
}
@@ -169,7 +173,14 @@
function getLocalXY(e: PointerEvent): [number, number] {
if (!canvas) return [0, 0];
const rect = canvas.getBoundingClientRect();
return [e.clientX - rect.left, e.clientY - rect.top];
// canvas 의 실제 표시 크기와 attribute 크기가 다른 경우 (ResizeObserver 가 아직 못 따라잡은 케이스)
// 비율로 보정. rect.width 가 canvas 의 실제 표시 width.
const scaleX = rect.width === 0 ? 1 : cssWidth / rect.width;
const scaleY = rect.height === 0 ? 1 : cssHeight / rect.height;
return [
(e.clientX - rect.left) * scaleX,
(e.clientY - rect.top) * scaleY,
];
}
function isPenLike(e: PointerEvent): boolean {