qcast-front/src/hooks/pdf-import/usePdfImport.js
sangwook yoo 849f7a1dc9 feat(pdf-gemini): 배치면 초기설정에 PDF 도면 업로드 옵션 추가 — Gemini 외곽선 분석
- 「배치면 초기설정」 모달 寸法入力方法 라디오에 '도면 파일 업로드' 옵션 추가
- 선택 시 PDF 파일 업로드 + 읽기 페이지 설정(전체/입면도·평면도 지정) UI 노출
- 保存 클릭 시 /api/gemini/floor-plan 호출 → 외곽선을 캔버스에 반영한 후 기존 저장 흐름 진행
- Gemini File API 업로드 + finally 파일 폐기, 정점/면적 검증, 페이지 힌트 프롬프트 주입
- usePdfImport 훅: mm→canvas 좌표 변환, CCW 정규화, 기존 외곽선 덮어쓰기 confirm 후 QLine/QPolygon 생성
- @google/generative-ai 의존성 추가, GEMINI_MODEL env 4개 환경에 동기화
- 다국어(ja/ko) 메시지 추가, .playwright-mcp/ gitignore

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-20 09:39:41 +09:00

177 lines
5.4 KiB
JavaScript

import { useSetRecoilState, useRecoilValue } from 'recoil'
import { canvasState } from '@/store/canvasAtom'
import { outerLinePointsState } from '@/store/outerLineAtom'
import { POLYGON_TYPE } from '@/common/common'
import { QLine } from '@/components/fabric/QLine'
import { usePolygon } from '@/hooks/usePolygon'
import { useSwal } from '@/hooks/useSwal'
import { useMessage } from '@/hooks/useMessage'
import { logger } from '@/util/logger'
const MM_PER_CANVAS_UNIT = 10
const MAX_DIM = 1500
const MIN_CANVAS_UNIT_LENGTH = 0.5
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
}
const fitAndCenter = (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 maxX = Math.max(...xs)
const maxY = Math.max(...ys)
const width = maxX - minX
const height = maxY - minY
const targetWidth = (canvas?.getWidth?.() ?? 1000) * 0.6
const targetHeight = (canvas?.getHeight?.() ?? 1000) * 0.6
const maxAllowed = Math.min(MAX_DIM, Math.max(targetWidth, targetHeight))
let scale = 1
if (width > maxAllowed || height > maxAllowed) {
scale = Math.min(maxAllowed / width, maxAllowed / height)
}
const cx = (canvas?.getWidth?.() ?? 1000) / 2
const cy = (canvas?.getHeight?.() ?? 1000) / 2
return points.map((p) => ({
x: cx + (p.x - (minX + width / 2)) * scale,
y: cy + (p.y - (minY + height / 2)) * scale,
}))
}
export function usePdfImport() {
const canvas = useRecoilValue(canvasState)
const setOuterLinePoints = useSetRecoilState(outerLinePointsState)
const { addPolygonByLines } = usePolygon()
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))
}
const drawOuterLines = (points) => {
points.forEach((point, idx) => {
if (idx === 0) return
const prev = points[idx - 1]
const line = new QLine([prev.x, prev.y, point.x, point.y], {
stroke: '#000000',
strokeWidth: 3,
idx,
selectable: true,
name: 'outerLine',
})
canvas.add(line)
})
}
const applyOuterline = async (mmPoints, options = {}) => {
if (!canvas) {
logger.warn('[usePdfImport] canvas 가 준비되지 않았습니다.')
return false
}
const { unit = 'mm' } = 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 = canvasPoints.filter((p, idx, arr) => {
if (idx === 0) return true
const prev = arr[idx - 1]
const dx = p.x - prev.x
const dy = p.y - prev.y
return Math.hypot(dx, dy) >= MIN_CANVAS_UNIT_LENGTH
})
if (canvasPoints.length < 3) {
swalFire({ text: getMessage('pdf.import.error.too.few.vertices'), type: 'alert' })
return false
}
canvasPoints = fitAndCenter(canvasPoints, canvas)
canvasPoints = ensureCounterClockwise(canvasPoints)
const proceed = async () => {
clearExistingCanvasState()
const closedPoints = [...canvasPoints, canvasPoints[0]]
setOuterLinePoints(closedPoints)
drawOuterLines(closedPoints)
try {
const lines = canvas.getObjects().filter((obj) => obj.name === 'outerLine')
if (lines.length >= 3) {
addPolygonByLines(lines, { name: POLYGON_TYPE.WALL, fill: 'transparent', stroke: 'black' })
}
} catch (e) {
logger.warn('[usePdfImport] wall polygon 생성 실패', e?.message || e)
}
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 }
}