qcast-front/src/hooks/floorPlan/useImgLoader.js

184 lines
6.6 KiB
JavaScript

import { useRecoilValue } from 'recoil'
import { canvasState } from '@/store/canvasAtom'
import { useAxios } from '../useAxios'
import { usePlan } from '../usePlan'
import { POLYGON_TYPE } from '@/common/common'
import { QcastContext } from '@/app/QcastProvider'
import { useContext } from 'react'
import Config from '@/config/config.export'
/**
* ======================================================================
* 이미지 로더 Hook
* ======================================================================
*
* [개요]
* Fabric.js 캔버스에서 도면 영역만 PNG로 캡처하여 서버(/api/image/canvas)로 전송한다.
* 서버에서 2벌(풀사이즈/셀범위)로 가공 후 S3에 업로드한다.
*
* [캡처 방식]
* - getBoundingRect()로 도면 오브젝트(지붕, 치수, 화살표)의 정확한 바운딩 박스 계산
* - canvas.toDataURL()에 크롭 좌표를 전달하여 도면 영역만 직접 캡처
* - 배경을 흰색으로 설정하여 서버 autocrop이 정상 동작하도록 함
* - multiplier: 2로 고해상도(2배) 캡처
*
* @returns {function} handleCanvasToPng - 캔버스를 PNG로 캡처하여 서버에 전송
*/
export function useImgLoader() {
const canvas = useRecoilValue(canvasState)
const { currentCanvasPlan } = usePlan()
const { post } = useAxios()
const { setIsGlobalLoading } = useContext(QcastContext)
/**
* 도면 오브젝트의 바운딩 박스를 계산한다.
* 지붕(ROOF), 치수 텍스트(lengthText), 방위 화살표(arrow)를 대상으로 한다.
* getBoundingRect()를 사용하여 스케일/회전이 적용된 정확한 좌표를 구한다.
*
* @returns {Array<{x: number, y: number}>} [좌상단 좌표, 우하단 좌표] (margin 포함)
*/
const getImageCoordinates = () => {
const margin = 20
const objects = canvas.getObjects().filter((obj) => [POLYGON_TYPE.ROOF, 'lengthText', 'arrow'].includes(obj.name))
let minX = Infinity
let minY = Infinity
let maxX = -Infinity
let maxY = -Infinity
objects.forEach((obj) => {
const rect = obj.getBoundingRect()
minX = Math.min(minX, rect.left)
minY = Math.min(minY, rect.top)
maxX = Math.max(maxX, rect.left + rect.width)
maxY = Math.max(maxY, rect.top + rect.height)
})
return [
{ x: minX - margin, y: minY - margin },
{ x: maxX + margin, y: maxY + margin },
]
}
/**
* 캔버스를 PNG 이미지로 캡처하여 서버에 전송한다.
*
* [처리 순서]
* 1. 그리드/흡착점 등 불필요한 요소 숨김
* 2. 배경을 흰색으로 변경 (격자 패턴 제거 → autocrop 가능하도록)
* 3. 도면 오브젝트의 바운딩 박스 계산
* 4. 해당 영역만 2배 해상도로 캡처 (toDataURL)
* 5. FormData로 서버 API에 전송
* 6. 배경/그리드 원복
*
* @param {number} type - 1: 모듈만 있는 상태, 2: 가대까지 올린 상태
* @returns {Promise<Object>} 서버 응답 (filePath, fullFilePath, fileName, fullFileName)
*/
const handleCanvasToPng = async (type) => {
try {
// 1. 그리드, 흡착점, 마우스라인 등 제거/숨김
toggleLineEtc(false)
// 2. 배경을 흰색으로 설정 (격자 패턴이 autocrop을 방해하므로)
const originalBg = canvas.backgroundColor
canvas.backgroundColor = '#ffffff'
// CORS 대응: 이미지 오브젝트에 crossOrigin 설정
canvas.getObjects('image').forEach((obj) => {
if (obj.getSrc) {
const img = new Image()
img.crossOrigin = 'anonymous'
img.src = obj.getSrc()
obj.setElement(img)
}
})
canvas.renderAll()
const formData = new FormData()
// 3. 도면 오브젝트의 바운딩 박스 계산
const positionObj = getImageCoordinates()
console.log('🚀 ~ handleCanvasToPng ~ positionObj:', positionObj)
const cropLeft = positionObj[0].x
const cropTop = positionObj[0].y
const cropWidth = positionObj[1].x - positionObj[0].x
const cropHeight = positionObj[1].y - positionObj[0].y
// 4. 도면 영역만 2배 해상도로 캡처
// toDataURL에 left/top/width/height를 전달하면 해당 영역만 캡처됨
const multiplier = 2
const dataUrl = canvas.toDataURL({
format: 'png',
multiplier: multiplier,
left: cropLeft,
top: cropTop,
width: cropWidth,
height: cropHeight,
})
// base64 → Blob 변환
const blobBin = atob(dataUrl.split(',')[1])
const array = []
for (let i = 0; i < blobBin.length; i++) {
array.push(blobBin.charCodeAt(i))
}
const file = new Blob([new Uint8Array(array)], { type: 'image/png' })
// 5. FormData 구성 및 서버 전송
formData.append('file', file, 'canvas.png')
formData.append('objectNo', currentCanvasPlan.objectNo)
formData.append('planNo', currentCanvasPlan.planNo)
formData.append('type', type)
formData.append('width', Math.round(cropWidth * multiplier))
formData.append('height', Math.round(cropHeight * multiplier))
formData.append('left', 0) // 프론트에서 이미 크롭하여 전송하므로 0
formData.append('top', 0)
console.log('🚀 ~ handleCanvasToPng ~ formData:', formData)
const result = await post({
url: `${Config().baseUrl}/api/image/canvas`,
data: formData,
})
console.log('🚀 ~ handleCanvasToPng ~ result:', result)
// 6. 배경/그리드 원복
canvas.backgroundColor = originalBg
canvas.renderAll()
toggleLineEtc(true)
return result
} catch (e) {
canvas.backgroundColor = originalBg
canvas.renderAll()
setIsGlobalLoading(false)
console.log('🚀 ~ handleCanvasToPng ~ e:', e)
}
}
/**
* 캡처 시 불필요한 요소(마우스라인, 그리드, 흡착점)를 숨기거나 복원한다.
*
* @param {boolean} visible - true: 복원, false: 숨김
*/
const toggleLineEtc = (visible = false) => {
if (canvas?._objects.length > 0) {
const mouseLines = canvas?._objects.filter((obj) => obj.name === 'mouseLine')
mouseLines.forEach((item) => canvas?.remove(item))
}
const adsorptionPoints = canvas?._objects.filter((obj) => obj.name === 'adsorptionPoint')
const gridLines = canvas?._objects.filter((obj) => obj.name === 'lineGrid' || obj.name === 'tempGrid' || obj.name === 'dotGrid')
adsorptionPoints.forEach((item) => item.set({ visible: visible }))
gridLines.forEach((item) => item.set({ visible: visible }))
canvas?.renderAll()
}
return { handleCanvasToPng }
}