diff --git a/src/app/api/image/canvas/route.js b/src/app/api/image/canvas/route.js index b96e079f..11f3b115 100644 --- a/src/app/api/image/canvas/route.js +++ b/src/app/api/image/canvas/route.js @@ -3,6 +3,37 @@ import { DeleteObjectCommand, GetObjectCommand, PutObjectCommand, S3Client } fro import { v4 as uuidv4 } from 'uuid' import { Jimp } from 'jimp' +/** + * ====================================================================== + * 캔버스 도면 이미지 처리 API + * ====================================================================== + * + * [개요] + * Fabric.js 캔버스에서 캡처한 도면 이미지를 2벌로 가공하여 S3에 업로드한다. + * + * [생성되는 이미지 2벌] + * 1. 셀범위용 (기존 이미지명 유지) + * - 파일명: {objectNo}_{planNo}_{type}.png + * - 용도: 割付図・系統図, 架台図 시트 (jx:image lastCell 셀범위에 맞춤) + * - 비율: 엑셀 셀 영역 비율 35.4:12.89 (~2.75:1) 에 맞춰 흰 여백 추가 + * - 템플릿: data.drawingImg1, data.drawingImg2 + * + * 2. 풀사이즈용 (신규) + * - 파일명: {objectNo}_{planNo}_{type}_full.png + * - 용도: P1, P2 시트 (A4 가로 전체 페이지에 이미지만 출력) + * - 비율: A4 가로 비율 27.7:19.0 (~1.46:1) 에 맞춰 흰 여백 최소화 + * - 템플릿: data.drawingImg1Full, data.drawingImg2Full + * + * [처리 흐름] + * 프론트(useImgLoader) → 도면 영역만 캡처하여 PNG 전송 + * → S3에 원본 임시 저장 (uuid 키) + * → autocrop으로 흰 여백 제거 + * → 풀사이즈/셀범위 2벌 리사이즈 + * → S3에 2벌 업로드 + * → 원본 삭제 + * ====================================================================== + */ + const Bucket = process.env.AMPLIFY_BUCKET const s3 = new S3Client({ region: process.env.AWS_REGION, @@ -12,113 +43,140 @@ const s3 = new S3Client({ }, }) -const checkArea = (obj) => { - const { width, height, left, top } = obj - - if (left < 0 || top < 0 || width > 1600 || height > 1000) { - return false +/** + * S3에서 이미지를 읽어 autocrop된 Jimp 이미지를 반환한다. + * 프론트에서 배경을 흰색으로 설정하여 캡처하므로 autocrop이 정상 동작한다. + * + * @param {string} Key - S3 오브젝트 키 + * @returns {Promise} autocrop된 Jimp 이미지 객체 + */ +const loadAndCrop = async (Key) => { + const { Body } = await s3.send( + new GetObjectCommand({ Bucket, Key }), + ) + const chunks = [] + for await (const chunk of Body) { + chunks.push(chunk) } + const buffer = Buffer.concat(chunks) + const image = await Jimp.read(buffer) - return true + // tolerance: 배경색과 5% 차이까지 여백으로 인식 + // leaveBorder: 크롭 후 5px 테두리 여유 + image.autocrop({ tolerance: 0.05, leaveBorder: 5 }) + return image } -const cropImage = async (Key, width, height, left, top) => { +/** + * 풀사이즈용 리사이즈 (P1, P2 시트) + * + * A4 가로(297mm × 210mm) 인쇄 영역에 맞춰 이미지를 리사이즈한다. + * - 가로 도면: A4에 거의 꽉 참 + * - 세로 도면: 상하가 꽉 차고 좌우에 최소 여백 + * + * 엑셀 템플릿 설정: jx:image(lastCell="V47" src="data.drawingImg1Full" imageType="PNG") + * + * @param {Jimp} image - autocrop된 Jimp 이미지 객체 + * @returns {Promise} A4 비율에 맞춘 이미지 + */ +const resizeForFull = async (image) => { + const DPI = 200 + + // A4 가로 인쇄 영역 (여백 제외): 약 277mm × 190mm + const maxWidth = Math.round((27.7 * DPI) / 2.54) // ~2181px + const maxHeight = Math.round((19.0 * DPI) / 2.54) // ~1496px + + // 이미지를 인쇄 영역의 95%까지 채움 (약간의 여유) + const targetW = maxWidth * 0.95 + const targetH = maxHeight * 0.95 + const scaleX = targetW / image.bitmap.width + const scaleY = targetH / image.bitmap.height + const scale = Math.min(scaleX, scaleY) // 비율 유지하며 최대 크기 + + image.resize({ w: Math.round(image.bitmap.width * scale), h: Math.round(image.bitmap.height * scale) }) + + // A4 비율의 흰 배경 캔버스에 이미지를 중앙 배치 + const background = new Jimp({ width: maxWidth, height: maxHeight, color: 0xffffffff }) + const x = Math.floor((maxWidth - image.bitmap.width) / 2) + const y = Math.floor((maxHeight - image.bitmap.height) / 2) + background.composite(image, x, y) + + return background +} + +/** + * 셀범위용 리사이즈 (割付図・系統図, 架台図 시트) + * + * 엑셀 템플릿의 이미지 셀 영역(35.4cm × 12.89cm) 비율에 맞춰 리사이즈한다. + * 가로로 넓은 비율(~2.75:1)이므로 세로 도면은 좌우 여백이 많아진다. + * + * 엑셀 템플릿 설정: jx:image(lastCell="V38" src="data.drawingImg1" imageType="PNG") + * + * @param {Jimp} image - autocrop된 Jimp 이미지 객체 + * @returns {Promise} 셀 비율에 맞춘 이미지 + */ +const resizeForCell = async (image) => { + const DPI = 200 + + // 엑셀 셀 영역 크기: 35.4cm × 12.89cm + const maxWidth = Math.round((35.4 * DPI) / 2.54) // ~2787px + const maxHeight = Math.round((12.89 * DPI) / 2.54) // ~1015px + + // 이미지를 셀 영역의 98%까지 채움 + const targetW = maxWidth * 0.98 + const targetH = maxHeight * 0.98 + const scaleX = targetW / image.bitmap.width + const scaleY = targetH / image.bitmap.height + const scale = Math.min(scaleX, scaleY) // 비율 유지하며 최대 크기 + + image.resize({ w: Math.round(image.bitmap.width * scale), h: Math.round(image.bitmap.height * scale) }) + + // 셀 비율의 흰 배경 캔버스에 이미지를 중앙 배치 + const background = new Jimp({ width: maxWidth, height: maxHeight, color: 0xffffffff }) + const x = Math.floor((maxWidth - image.bitmap.width) / 2) + const y = Math.floor((maxHeight - image.bitmap.height) / 2) + background.composite(image, x, y) + + return background +} + +/** + * 이미지 2벌 생성: 풀사이즈(P1,P2) + 셀범위(割付図 등) + * + * @param {string} Key - S3에 저장된 원본 이미지 키 + * @returns {Promise<{fullBuffer: Buffer, cellBuffer: Buffer}>} 2벌의 PNG 버퍼 + */ +const processImage = async (Key) => { try { - // Get the image from S3 - const { Body } = await s3.send( - new GetObjectCommand({ - Bucket, - Key, - }), - ) + const image = await loadAndCrop(Key) + const imageCopy = image.clone() // 동일 원본에서 2벌 생성을 위해 복제 - const chunks = [] - for await (const chunk of Body) { - chunks.push(chunk) - } - const buffer = Buffer.concat(chunks) + const fullBuffer = await resizeForFull(image).then((img) => img.getBuffer('image/png')) + const cellBuffer = await resizeForCell(imageCopy).then((img) => img.getBuffer('image/png')) - let image = await Jimp.read(buffer) - image.autocrop({ tolerance: 0.0002, leaveBorder: 10 }) - - const resizedImage = await resizeImage(image).then((result) => { - return result - }) - - return await resizedImage.getBuffer('image/png') - - // Convert stream to buffer - // const chunks = [] - // for await (const chunk of Body) { - // chunks.push(chunk) - // } - // const imageBuffer = Buffer.concat(chunks) - - // const image = await Jimp.read(Body) - - // if (!checkResult) { - // processedImage = await image.toBuffer() - // } - - //let processedImage - // if (!checkResult) { - // processedImage = await sharp(imageBuffer).toBuffer() - // } else { - // processedImage = await sharp(imageBuffer) - // .extract({ - // width: parseInt(width), - // height: parseInt(height), - // left: parseInt(left), - // top: parseInt(top), - // }) - // .png() - // .toBuffer() - // } - // return processedImage + return { fullBuffer, cellBuffer } } catch (error) { console.error('Error processing image:', error) throw error } } -//크롭된 이미지를 배경 크기에 맞게 리사이즈 -const resizeImage = async (image) => { - //엑셀 템플릿 너비 35.4cm, 높이 12.89cm - const convertStandardWidth = Math.round((35.4 * 96) / 2.54) - const convertStandardHeight = Math.round((12.89 * 96) / 2.54) - - // 이미지를 배경의 98%까지 확대 (훨씬 더 크게) - const targetImageWidth = convertStandardWidth * 0.98 - const targetImageHeight = convertStandardHeight * 0.98 - - const scaleX = targetImageWidth / image.bitmap.width - const scaleY = targetImageHeight / image.bitmap.height - let scale = Math.min(scaleX, scaleY) // 비율 유지하면서 최대한 크게 - - let finalWidth = Math.round(image.bitmap.width * scale) - let finalHeight = Math.round(image.bitmap.height * scale) - - // 항상 리사이즈 실행 (scale >= 0.6 조건 제거) - image.resize({ w: finalWidth, h: finalHeight }) - - //배경 이미지를 생성 - const mixedImage = new Jimp({ width: convertStandardWidth, height: convertStandardHeight, color: 0xffffffff }) - - //이미지를 중앙에 배치 - const x = Math.floor((mixedImage.bitmap.width - image.bitmap.width) / 2) - const y = Math.floor((mixedImage.bitmap.height - image.bitmap.height) / 2) - - //이미지를 배경 이미지에 합성 - mixedImage.composite(image, x, y, { - opacitySource: 1, // 원본 투명도 유지 - opacityDest: 1, - }) - - // 1.5x 확대 로직 제거 - 이미지가 템플릿 크기를 초과하지 않도록 함 - - return mixedImage -} - +/** + * POST: 캔버스 도면 이미지 처리 및 S3 업로드 + * + * [요청 FormData] + * - file: PNG 이미지 (프론트에서 도면 영역만 캡처) + * - objectNo: 물건번호 + * - planNo: 프랜번호 + * - type: 1(모듈만) / 2(가대포함) + * - width, height, left, top: 크롭 좌표 (프론트에서 이미 크롭하여 전송하므로 left=0, top=0) + * + * [응답 JSON] + * - filePath: 셀범위용 이미지 URL (기존 이미지명) + * - fullFilePath: 풀사이즈용 이미지 URL (_full 접미사) + * - fileName: 셀범위용 S3 키 + * - fullFileName: 풀사이즈용 S3 키 + */ export async function POST(req) { try { const formData = await req.formData() @@ -126,16 +184,12 @@ export async function POST(req) { const objectNo = formData.get('objectNo') const planNo = formData.get('planNo') const type = formData.get('type') - const width = formData.get('width') - const height = formData.get('height') - const left = formData.get('left') - const top = formData.get('top') const OriginalKey = `Drawing/${uuidv4()}` /** - * 원본 이미지를 우선 저장한다. - * 이미지 이름이 겹지는 현상을 방지하기 위해 uuid 를 사용한다. + * 1단계: 원본 이미지를 S3에 임시 저장 + * - uuid를 사용하여 이미지 이름 충돌 방지 */ await s3.send( new PutObjectCommand({ @@ -147,29 +201,26 @@ export async function POST(req) { ) /** - * 저장된 원본 이미지를 기준으로 크롭여부를 결정하여 크롭 이미지를 저장한다. + * 2단계: 이미지 2벌 생성 + * - fullBuffer: P1, P2 시트용 (A4 가로 비율, 여백 최소화) + * - cellBuffer: 割付図・架台図 시트용 (셀범위 비율, 여백 포함) */ - const bufferImage = await cropImage(OriginalKey, width, height, left, top) + const { fullBuffer, cellBuffer } = await processImage(OriginalKey) + + // 기존 이미지명 유지 (셀범위용), _full 접미사 추가 (풀사이즈용) + const cellKey = `Drawing/${process.env.S3_PROFILE}/${objectNo}_${planNo}_${type}.png` + const fullKey = `Drawing/${process.env.S3_PROFILE}/${objectNo}_${planNo}_${type}_full.png` /** - * 크롭 이미지 이름을 결정한다. + * 3단계: 2벌 이미지를 S3에 병렬 업로드 */ - const Key = `Drawing/${process.env.S3_PROFILE}/${objectNo}_${planNo}_${type}.png` + await Promise.all([ + s3.send(new PutObjectCommand({ Bucket, Key: fullKey, Body: fullBuffer, ContentType: 'image/png' })), + s3.send(new PutObjectCommand({ Bucket, Key: cellKey, Body: cellBuffer, ContentType: 'image/png' })), + ]) /** - * 크롭이 완료된 이미지를 업로드한다. - */ - await s3.send( - new PutObjectCommand({ - Bucket, - Key, - Body: bufferImage, - ContentType: 'image/png', - }), - ) - - /** - * 크롭이미지 저장이 완료되면 원본 이미지를 삭제한다. + * 4단계: 원본 임시 이미지 삭제 */ await s3.send( new DeleteObjectCommand({ @@ -179,8 +230,10 @@ export async function POST(req) { ) const result = { - filePath: `https://${process.env.AMPLIFY_BUCKET}.s3.${process.env.AWS_REGION}.amazonaws.com/${Key}`, - fileName: Key, + filePath: `https://${process.env.AMPLIFY_BUCKET}.s3.${process.env.AWS_REGION}.amazonaws.com/${cellKey}`, + fullFilePath: `https://${process.env.AMPLIFY_BUCKET}.s3.${process.env.AWS_REGION}.amazonaws.com/${fullKey}`, + fileName: cellKey, + fullFileName: fullKey, } return NextResponse.json(result) diff --git a/src/hooks/floorPlan/useImgLoader.js b/src/hooks/floorPlan/useImgLoader.js index 81383f13..958a87c9 100644 --- a/src/hooks/floorPlan/useImgLoader.js +++ b/src/hooks/floorPlan/useImgLoader.js @@ -8,30 +8,53 @@ import { useContext } from 'react' import Config from '@/config/config.export' /** - * 이미지 로더 hook - * 캔버스를 바이너리로 변환하고 이미지 객체에 붙여서 다시 이미지를 바이너리로 전달 - * 캔버스 데이터가 바이너리로 변경되면 용량이 너무 커서 post전송에 실패할 수 있음 - * @returns {function} handleCanvasToPng + * ====================================================================== + * 이미지 로더 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) + /** - * 이미지 저장 시 왼쪽 위, 오른쪽 아래 좌표 - * return [start, end] + * 도면 오브젝트의 바운딩 박스를 계산한다. + * 지붕(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)) - const minX = objects.reduce((acc, cur) => (cur.left < acc ? cur.left : acc), objects[0].left) - const minY = objects.reduce((acc, cur) => (cur.top < acc ? cur.top : acc), objects[0].top) + 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) + }) - const maxX = objects.reduce((acc, cur) => (cur.left + cur.width > acc ? cur.left : acc), 0) - const maxY = objects.reduce((acc, cur) => (cur.top + cur.height > acc ? cur.top : acc), 0) return [ { x: minX - margin, y: minY - margin }, { x: maxX + margin, y: maxY + margin }, @@ -39,13 +62,29 @@ export function useImgLoader() { } /** - * 캔버스를 이미지로 저장 - * @param {integer} type 1: 모듈만 있는 상태, 2: 가대까지 올린 상태 + * 캔버스를 PNG 이미지로 캡처하여 서버에 전송한다. + * + * [처리 순서] + * 1. 그리드/흡착점 등 불필요한 요소 숨김 + * 2. 배경을 흰색으로 변경 (격자 패턴 제거 → autocrop 가능하도록) + * 3. 도면 오브젝트의 바운딩 박스 계산 + * 4. 해당 영역만 2배 해상도로 캡처 (toDataURL) + * 5. FormData로 서버 API에 전송 + * 6. 배경/그리드 원복 + * + * @param {number} type - 1: 모듈만 있는 상태, 2: 가대까지 올린 상태 + * @returns {Promise} 서버 응답 (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() @@ -58,51 +97,72 @@ export function useImgLoader() { canvas.renderAll() const formData = new FormData() - // 고해상도 캡처를 위해 multiplier 옵션 추가 (2배 해상도) + + // 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) - /** 이미지 크롭 좌표 계산 (multiplier 배율 적용) */ - const positionObj = getImageCoordinates() - console.log('🚀 ~ handleCanvasToPng ~ positionObj:', positionObj) - formData.append('width', Math.round((positionObj[1].x - positionObj[0].x + 100) * multiplier)) - formData.append('height', Math.round((positionObj[1].y - positionObj[0].y + 100) * multiplier)) - formData.append('left', Math.round(positionObj[0].x * multiplier)) - formData.append('top', Math.round(positionObj[0].y * multiplier)) + 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: `${process.env.NEXT_PUBLIC_API_HOST_URL}/image/canvas`, 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) {