import { useSetRecoilState, useRecoilValue } from 'recoil' import { fabric } from 'fabric' import { canvasState, canvasZoomState } from '@/store/canvasAtom' import { outerLineFixState, outerLinePointsState } from '@/store/outerLineAtom' import { outlineDisplaySelector } from '@/store/settingAtom' import { POLYGON_TYPE } from '@/common/common' import { useLine } from '@/hooks/useLine' import { useSwal } from '@/hooks/useSwal' import { useMessage } from '@/hooks/useMessage' import { logger } from '@/util/logger' import { snapNearAxisEdges, cleanSelfIntersectingPolygon } from '@/util/qpolygon-utils' import * as turf from '@turf/turf' const MM_PER_CANVAS_UNIT = 10 // useLine.addLine 이 length < 1(=10mm) 라인을 생성하지 않으므로 동일 임계값으로 사전 제거한다. const MIN_CANVAS_UNIT_LENGTH = 1 const VIEWPORT_FIT_RATIO = 0.6 // 화면맞춤 시 캔버스 대비 도면 최대 비율 const SIMPLIFY_TOLERANCE = 0.5 // canvas unit ≈ 5mm. 직선 위 잉여 정점/미세 엣지 제거(보수적) const SNAP_ANGLE_TOL_DEG = 2 // 거의 수평/수직 엣지를 정확히 축정렬로 스냅 const toCanvasUnits = (mmPoints) => mmPoints.map((p) => ({ x: p.x / MM_PER_CANVAS_UNIT, y: p.y / MM_PER_CANVAS_UNIT })) const polygonArea = (points) => { let area = 0 for (let i = 0; i < points.length; i++) { const cur = points[i] const next = points[(i + 1) % points.length] area += cur.x * next.y - next.x * cur.y } return area } const ensureCounterClockwise = (points) => { // canvas 좌표계 (y 가 아래로 증가) 에서 CCW 는 signed area 가 음수가 된다. return polygonArea(points) > 0 ? [...points].reverse() : points } // 직전 '유지된' 정점과의 거리로 짧은 엣지를 제거한다. 닫힘 엣지(마지막→첫)도 동일 기준으로 검사 — // Gemini 가 닫힌 링(첫점=끝점 중복)을 반환하는 경우가 여기서 함께 정리된다. const dedupShortEdges = (points) => { const out = [] points.forEach((p) => { const prev = out[out.length - 1] if (prev && Math.hypot(p.x - prev.x, p.y - prev.y) < MIN_CANVAS_UNIT_LENGTH) return out.push(p) }) while (out.length >= 3 && Math.hypot(out[out.length - 1].x - out[0].x, out[out.length - 1].y - out[0].y) < MIN_CANVAS_UNIT_LENGTH) { out.pop() } return out } // 좌표에 축소 scale 을 곱하면 'QLine 길이×10 = 표시 mm' 불변식이 깨져 치수 라벨이 실측과 어긋난다. // 좌표는 실측 그대로 평행이동만 하고, 화면맞춤은 fitViewport 의 zoom(viewportTransform)으로 분리 처리한다. const centerOnCanvas = (points, canvas) => { const xs = points.map((p) => p.x) const ys = points.map((p) => p.y) const minX = Math.min(...xs) const minY = Math.min(...ys) const width = Math.max(...xs) - minX const height = Math.max(...ys) - minY const cx = (canvas?.getWidth?.() ?? 1000) / 2 const cy = (canvas?.getHeight?.() ?? 1000) / 2 return points.map((p) => ({ x: cx + (p.x - (minX + width / 2)), y: cy + (p.y - (minY + height / 2)), })) } const simplifyRing = (points, tolerance) => { if (points.length < 4) return points try { const coords = points.map((p) => [p.x, p.y]) coords.push([points[0].x, points[0].y]) const simplified = turf.simplify(turf.polygon([coords]), { tolerance, highQuality: true }) const ring = simplified.geometry.coordinates[0] const out = ring.slice(0, -1).map((c) => ({ x: c[0], y: c[1] })) return out.length >= 3 ? out : points } catch (e) { logger.warn('[usePdfImport] simplifyRing 실패', e?.message || e) return points } } // AI 추출 좌표의 결정적 정규화: 축정렬 스냅 → 과다정점 단순화 → 자기교차 복구. // 순서 주의 — 스냅(정점 이동) 후 단순화(정점 제거)해야 축정렬이 보존된다. const normalizeGeometry = (points) => { let pts = snapNearAxisEdges(points, SNAP_ANGLE_TOL_DEG) pts = simplifyRing(pts, SIMPLIFY_TOLERANCE) pts = cleanSelfIntersectingPolygon(pts) return pts.length >= 3 ? pts : points } export function usePdfImport() { const canvas = useRecoilValue(canvasState) const setOuterLinePoints = useSetRecoilState(outerLinePointsState) const setOuterLineFix = useSetRecoilState(outerLineFixState) const setCanvasZoom = useSetRecoilState(canvasZoomState) const isOutlineDisplay = useRecoilValue(outlineDisplaySelector) const { addLine } = useLine() const { swalFire } = useSwal() const { getMessage } = useMessage() const hasExistingOuterLine = () => { if (!canvas) return false return canvas.getObjects().some((obj) => obj.name === 'outerLine' || obj.name === POLYGON_TYPE.WALL || obj.name === POLYGON_TYPE.ROOF) } const clearExistingCanvasState = () => { if (!canvas) return const removable = canvas.getObjects().filter((obj) => { return ( obj.name === 'outerLine' || obj.name === 'outerLinePoint' || obj.name === 'startPoint' || obj.name === 'helpGuideLine' || obj.name === 'lengthText' || obj.name === 'pitchText' || obj.name === POLYGON_TYPE.WALL || obj.name === POLYGON_TYPE.ROOF ) }) removable.forEach((obj) => canvas.remove(obj)) } // 수동 그리기(useOuterLineWall.drawLine)와 동일하게 useLine.addLine 경유로 생성한다 — // attributes/planeSize/fontSize 가 없으면 지붕형상 설정·처마/케라바 편집·재로드 복원이 깨진다. const drawOuterLines = (points) => { points.forEach((point, idx) => { if (idx === 0) return const prev = points[idx - 1] addLine([prev.x, prev.y, point.x, point.y], { stroke: 'black', strokeWidth: 3, idx, selectable: true, name: 'outerLine', x1: prev.x, y1: prev.y, x2: point.x, y2: point.y, visible: isOutlineDisplay, }) }) } // skeletonBuilder 등 지붕 생성 로직이 정점 위치를 'outerLinePoint' circle 에서 읽는다 — 수동 플로우와 동일하게 생성 const drawOuterLinePoints = (points) => { points.forEach((point) => { canvas.add( new fabric.Circle({ left: point.x, top: point.y, visible: false, name: 'outerLinePoint', }), ) }) } // 도면이 화면보다 크면 캔버스 중앙 고정 줌으로 화면맞춤한다. canvasZoomState 를 함께 동기화하면 // useCanvasEvent 의 setZoom(동일 zoom 값) 은 translation 을 보존하므로 중앙 정렬이 유지된다. const fitViewport = (points) => { const xs = points.map((p) => p.x) const ys = points.map((p) => p.y) const width = Math.max(...xs) - Math.min(...xs) const height = Math.max(...ys) - Math.min(...ys) const targetWidth = canvas.getWidth() * VIEWPORT_FIT_RATIO const targetHeight = canvas.getHeight() * VIEWPORT_FIT_RATIO let zoomPercent = 100 if (width > targetWidth || height > targetHeight) { zoomPercent = Math.max(1, Math.floor(Math.min(targetWidth / width, targetHeight / height) * 100)) } const zoom = zoomPercent / 100 const cx = canvas.getWidth() / 2 const cy = canvas.getHeight() / 2 canvas.setViewportTransform([zoom, 0, 0, zoom, cx * (1 - zoom), cy * (1 - zoom)]) setCanvasZoom(zoomPercent) } const applyOuterline = async (mmPoints, options = {}) => { if (!canvas) { logger.warn('[usePdfImport] canvas 가 준비되지 않았습니다.') return false } const { unit = 'mm', autoFix = true } = options if (unit !== 'mm') { logger.warn('[usePdfImport] 지원하지 않는 단위:', unit) swalFire({ text: getMessage('pdf.import.error.unsupported.unit'), type: 'alert' }) return false } if (!Array.isArray(mmPoints) || mmPoints.length < 3) { swalFire({ text: getMessage('pdf.import.error.too.few.vertices'), type: 'alert' }) return false } let canvasPoints = toCanvasUnits(mmPoints) canvasPoints = dedupShortEdges(canvasPoints) if (canvasPoints.length < 3) { swalFire({ text: getMessage('pdf.import.error.too.few.vertices'), type: 'alert' }) return false } canvasPoints = normalizeGeometry(canvasPoints) canvasPoints = dedupShortEdges(canvasPoints) // 정규화 과정에서 생긴 미세 엣지 제거 if (canvasPoints.length < 3) { swalFire({ text: getMessage('pdf.import.error.too.few.vertices'), type: 'alert' }) return false } canvasPoints = centerOnCanvas(canvasPoints, canvas) canvasPoints = ensureCounterClockwise(canvasPoints) const proceed = async () => { clearExistingCanvasState() const closedPoints = [...canvasPoints, canvasPoints[0]] setOuterLinePoints(closedPoints) drawOuterLines(closedPoints) drawOuterLinePoints(closedPoints) // 외벽선 자동 확정(autoFix) 옵션에 따라 확정 여부를 분기한다. // - ON: 수동 확정(useOuterLineWall.handleFix) 직후와 동일하게 outerLineFix 를 켠다. 이 플래그가 없으면 // 지붕형상 설정(useRoofShapeSetting/useRoofShapePassivitySetting) 진입이 'wall.line.not.found' 로 차단된다. // WALL/ROOF 폴리곤은 수동 플로우와 동일하게 지붕형상 설정 적용 시점에 생성된다. // - OFF: 외벽선만 그려둔 미확정 상태로 두어, 사용자가 외벽선 그리기 단계에서 직접 확정하도록 한다. canvas.outerLineFix = autoFix setOuterLineFix(autoFix) fitViewport(closedPoints) canvas.renderAll() } if (hasExistingOuterLine()) { return await new Promise((resolve) => { swalFire({ text: getMessage('pdf.import.confirm.overwrite'), type: 'confirm', confirmFn: async () => { await proceed() resolve(true) }, denyFn: () => resolve(false), }) }) } await proceed() return true } return { applyOuterline, hasExistingOuterLine } }