시공 지시는 이런 식이었다.
"상판 2400×600, ㄱ자, 오른쪽 싱크볼 타공"
문자로 받은 시공기사가 현장에서 해석한다. 해석이 다르면 오가공이 생긴다. 현장에서 다시 측정하고, 자재를 재주문하고, 일정이 밀린다.
웹에서 직접 그리고 발주서에 도면이 붙어서 나가면 이 문제가 사라진다.
Konva.js를 고른 이유
후보를 네 가지로 좁혔다.
| 후보 | 탈락 이유 |
|---|---|
| SVG + React | 드래그 인터랙션 구현이 복잡, 고밀도 디스플레이 대응 어려움 |
<canvas> 직접 |
선택·호버·애니메이션을 처음부터 구현해야 함 |
| Fabric.js | 번들 크기 크고 TypeScript 지원 약함 |
| Konva.js | React 래퍼(react-konva) 제공, 레이어 구조, 픽셀비율 자동 처리 |
Konva는 Canvas 위에 React 컴포넌트처럼 도형을 배치할 수 있다. 레이어 분리도 선언적으로 된다. Konva는 window와 canvas에 의존하므로 Next.js에서 SSR을 꺼야 한다.
// SSR 비활성화
const KonvaCountertopEditor = dynamic(
() => import('./KonvaCountertopEditor'),
{ ssr: false }
);
// 고해상도 렌더
Konva.pixelRatio = Math.min(3, Math.max(2, window.devicePixelRatio || 1));
데이터 모델 설계
도면 하나의 구조다. 단위는 모두 mm.
// lib/drawing/types.ts
interface CountertopDrawing {
version: number;
shape: 'straight' | 'L' | 'U'; // 一자형 / ㄱ자형 / ㄷ자형
dimensions: {
thicknessMm: number | null; // 상판 두께 (12 / 20mm)
};
edgeLengths: Record<string, number | null>; // e0, e1, ... 각 변의 치수
edges: EdgeFinishing[]; // 마감 (졸리컷 / 라운드 / 물막이)
cutouts: Cutout[]; // 타공 위치
rotation?: number; // 0 / 90 / 180 / 270도
}
핵심 결정: 엣지 ID를 e0, e1, e2...로 고정한다.
각 변의 ID를 정점 순서 인덱스로 고정하면, 형태나 치수가 바뀌어도 ID가 유지된다. 마감 지정(edges)과 타공 위치(cutouts)가 어떤 변을 참조하는지가 깨지지 않는다.
타공 데이터:
interface Cutout {
type: 'sink' | 'cooktop' | 'outlet';
x: number; y: number; // 중심 좌표 (mm)
widthMm?: number | null;
heightMm?: number | null;
posLeft?: number | null; // 왼쪽 모서리까지 거리
posRight?: number | null;
posTop?: number | null;
posBottom?: number | null;
rot?: number; // 타공 회전각
}
위치 치수(posLeft, posTop 등)가 가공 지시의 핵심이다. "싱크볼 중심이 왼쪽에서 480mm"처럼.
기하학 엔진
형태별로 정점 좌표를 계산하는 순수 함수들이다.
// lib/drawing/geometry.ts
// 一자형: 직사각형 4정점
straight: [[0,0], [W,0], [W,D], [0,D]]
// ㄱ자형: L-폴리곤 6정점
L: [[0,0], [a,0], [a,armR], [armB,armR], [armB,b], [0,b]]
// ㄷ자형: U-폴리곤 8정점
U: [[0,0], [armL,0], [armL,openH], [armL+bOpen,openH],
[armL+bOpen,0], [totalW,0], [totalW,H], [0,H]]
치수선 라벨을 폴리곤 바깥쪽에 배치하려면 각 변의 법선(normal) 방향을 알아야 한다. 폴리곤이 시계 방향인지 반시계 방향인지에 따라 법선이 달라지기 때문이다.
// 쇼엘라스 공식(Shoelace formula)으로 면적 부호 계산 → 시계/반시계 판별
let area2 = 0;
for (let i = 0; i < n; i++) {
area2 += verts[i][0] * verts[(i+1)%n][1]
- verts[(i+1)%n][0] * verts[i][1];
}
const cw = area2 < 0; // 화면좌표 Y축이 아래 방향이라 부호 반전
// 법선 = 진행방향 90도 회전
const nx = cw ? -dy : dy;
const ny = cw ? dx : -dx;
타공 위치 치수는 단순한 바운딩박스로 계산하면 안 된다. ㄷ자형에서 파인 곳 안에 싱크볼이 있으면 바운딩박스 기준으로는 위치가 엉터리가 된다.
타공 중심에서 상하좌우로 레이캐스팅해서 실제 폴리곤 변과의 교점을 찾는다. 그 교점까지의 거리가 위치 치수다.
// 타공 중심 (cx, cy)에서 방향별로 레이캐스팅
// 만나는 가장 가까운 폴리곤 변의 좌표를 반환
export function cutoutEdgeCoord(
verts: number[][], cx: number, cy: number,
side: 'left' | 'right' | 'top' | 'bottom'
): number
입력할 때 도형이 붕괴되지 않도록
2400을 입력할 때 2, 24, 240 순서로 타이핑된다. 240이 도형에 즉시 반영되면 도형이 순간적으로 이상하게 줄어들었다가 늘어난다.
Draft 패턴으로 해결했다. 타이핑 중에는 임시 상태(lenDraft)에만 반영하고, Enter나 blur 시점에만 실제 치수에 커밋한다.
const [lenDraft, setLenDraft] = useState<string>('');
onBlur: () => {
const val = parseFloat(lenDraft);
if (!isNaN(val) && val > 0) {
setDrawing(prev => ({
...prev,
edgeLengths: { ...prev.edgeLengths, [selectedEdge]: val }
}));
}
}
형태가 一→ㄱ로 바뀔 때 기존 타공이 경계 밖으로 벗어나면 자동으로 경계 안으로 클램핑된다.
function clampCutoutsToBounds(next: CountertopDrawing): CountertopDrawing {
const { minX, minY, maxX, maxY } = buildGeometry(next).bounds;
return {
...next,
cutouts: next.cutouts.map((c) => ({
...c,
x: Math.min(Math.max(c.x, minX + hw), maxX - hw),
y: Math.min(Math.max(c.y, minY + hh), maxY - hh),
})),
};
}
도면 → 발주서 자동 파생
도면을 그리면 발주서 텍스트 필드가 자동으로 채워진다.
// deriveOrderFields(drawing)
{
spec_text: "ㄱ자형 · 상단 2400 / 우측 600 / 안쪽 상 1800 · 20t",
edge_text: "마감 2개소",
cutout_text: "싱크볼(좌480·하200) 780×480, 쿡탑(IH 3구)(좌900·하180) 900×510",
width_mm: 2400,
height_mm: 600,
}
발주서 하나에 여러 도면을 붙일 수 있다. 직접 그린 스케치와 고객사가 보내온 캐드 PDF 첨부파일을 같은 배열로 관리한다.
type DrawingItem =
| { kind: 'sketch'; drawing: CountertopDrawing }
| { kind: 'upload'; fileUrl: string; mime: string };
kind 필드 하나로 렌더 방식을 분기한다. 스케치는 SVG로 렌더하고, 업로드 파일은 이미지 또는 PDF 뷰어로 보여준다.
발주서 인쇄 시 도면이 제도 시트 형식으로
발주서를 인쇄하면 전문 제도 시트 형식으로 도면이 출력된다. 오른쪽에 회사명, 도면명, 현장, 자재, 축척, 도번, 작성일이 표 형태로 들어간다.
html2canvas가 Konva Canvas를 직접 캡처하면 좌표계 변환 오차가 생겨서, 인쇄·PDF 출력용으로는 SVG를 별도 컴포넌트(DrawingPreviewSvg)로 구현해 사용한다. Konva는 편집기 전용, SVG는 출력 전용으로 역할을 나눴다.
다음 편에서는 이 출력 부분의 가장 큰 골칫거리였던 html2canvas 이야기가 이어진다.