- 「배치면 초기설정」 모달 寸法入力方法 라디오에 '도면 파일 업로드' 옵션 추가 - 선택 시 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>
177 lines
5.4 KiB
JavaScript
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 }
|
|
}
|