[1470] 배치도·계통도 및 구조도의 화질 개선 cell 이미지 추가 #707
@ -3,6 +3,37 @@ import { DeleteObjectCommand, GetObjectCommand, PutObjectCommand, S3Client } fro
|
|||||||
import { v4 as uuidv4 } from 'uuid'
|
import { v4 as uuidv4 } from 'uuid'
|
||||||
import { Jimp } from 'jimp'
|
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 Bucket = process.env.AMPLIFY_BUCKET
|
||||||
const s3 = new S3Client({
|
const s3 = new S3Client({
|
||||||
region: process.env.AWS_REGION,
|
region: process.env.AWS_REGION,
|
||||||
@ -12,113 +43,140 @@ const s3 = new S3Client({
|
|||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
const checkArea = (obj) => {
|
/**
|
||||||
const { width, height, left, top } = obj
|
* S3에서 이미지를 읽어 autocrop된 Jimp 이미지를 반환한다.
|
||||||
|
* 프론트에서 배경을 흰색으로 설정하여 캡처하므로 autocrop이 정상 동작한다.
|
||||||
if (left < 0 || top < 0 || width > 1600 || height > 1000) {
|
*
|
||||||
return false
|
* @param {string} Key - S3 오브젝트 키
|
||||||
|
* @returns {Promise<Jimp>} 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<Jimp>} 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<Jimp>} 셀 비율에 맞춘 이미지
|
||||||
|
*/
|
||||||
|
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 {
|
try {
|
||||||
// Get the image from S3
|
const image = await loadAndCrop(Key)
|
||||||
const { Body } = await s3.send(
|
const imageCopy = image.clone() // 동일 원본에서 2벌 생성을 위해 복제
|
||||||
new GetObjectCommand({
|
|
||||||
Bucket,
|
|
||||||
Key,
|
|
||||||
}),
|
|
||||||
)
|
|
||||||
|
|
||||||
const chunks = []
|
const fullBuffer = await resizeForFull(image).then((img) => img.getBuffer('image/png'))
|
||||||
for await (const chunk of Body) {
|
const cellBuffer = await resizeForCell(imageCopy).then((img) => img.getBuffer('image/png'))
|
||||||
chunks.push(chunk)
|
|
||||||
}
|
|
||||||
const buffer = Buffer.concat(chunks)
|
|
||||||
|
|
||||||
let image = await Jimp.read(buffer)
|
return { fullBuffer, cellBuffer }
|
||||||
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
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Error processing image:', error)
|
console.error('Error processing image:', error)
|
||||||
throw error
|
throw error
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
//크롭된 이미지를 배경 크기에 맞게 리사이즈
|
/**
|
||||||
const resizeImage = async (image) => {
|
* POST: 캔버스 도면 이미지 처리 및 S3 업로드
|
||||||
//엑셀 템플릿 너비 35.4cm, 높이 12.89cm
|
*
|
||||||
const convertStandardWidth = Math.round((35.4 * 96) / 2.54)
|
* [요청 FormData]
|
||||||
const convertStandardHeight = Math.round((12.89 * 96) / 2.54)
|
* - file: PNG 이미지 (프론트에서 도면 영역만 캡처)
|
||||||
|
* - objectNo: 물건번호
|
||||||
// 이미지를 배경의 98%까지 확대 (훨씬 더 크게)
|
* - planNo: 프랜번호
|
||||||
const targetImageWidth = convertStandardWidth * 0.98
|
* - type: 1(모듈만) / 2(가대포함)
|
||||||
const targetImageHeight = convertStandardHeight * 0.98
|
* - width, height, left, top: 크롭 좌표 (프론트에서 이미 크롭하여 전송하므로 left=0, top=0)
|
||||||
|
*
|
||||||
const scaleX = targetImageWidth / image.bitmap.width
|
* [응답 JSON]
|
||||||
const scaleY = targetImageHeight / image.bitmap.height
|
* - filePath: 셀범위용 이미지 URL (기존 이미지명)
|
||||||
let scale = Math.min(scaleX, scaleY) // 비율 유지하면서 최대한 크게
|
* - fullFilePath: 풀사이즈용 이미지 URL (_full 접미사)
|
||||||
|
* - fileName: 셀범위용 S3 키
|
||||||
let finalWidth = Math.round(image.bitmap.width * scale)
|
* - fullFileName: 풀사이즈용 S3 키
|
||||||
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
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function POST(req) {
|
export async function POST(req) {
|
||||||
try {
|
try {
|
||||||
const formData = await req.formData()
|
const formData = await req.formData()
|
||||||
@ -126,16 +184,12 @@ export async function POST(req) {
|
|||||||
const objectNo = formData.get('objectNo')
|
const objectNo = formData.get('objectNo')
|
||||||
const planNo = formData.get('planNo')
|
const planNo = formData.get('planNo')
|
||||||
const type = formData.get('type')
|
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()}`
|
const OriginalKey = `Drawing/${uuidv4()}`
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 원본 이미지를 우선 저장한다.
|
* 1단계: 원본 이미지를 S3에 임시 저장
|
||||||
* 이미지 이름이 겹지는 현상을 방지하기 위해 uuid 를 사용한다.
|
* - uuid를 사용하여 이미지 이름 충돌 방지
|
||||||
*/
|
*/
|
||||||
await s3.send(
|
await s3.send(
|
||||||
new PutObjectCommand({
|
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' })),
|
||||||
|
])
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 크롭이 완료된 이미지를 업로드한다.
|
* 4단계: 원본 임시 이미지 삭제
|
||||||
*/
|
|
||||||
await s3.send(
|
|
||||||
new PutObjectCommand({
|
|
||||||
Bucket,
|
|
||||||
Key,
|
|
||||||
Body: bufferImage,
|
|
||||||
ContentType: 'image/png',
|
|
||||||
}),
|
|
||||||
)
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 크롭이미지 저장이 완료되면 원본 이미지를 삭제한다.
|
|
||||||
*/
|
*/
|
||||||
await s3.send(
|
await s3.send(
|
||||||
new DeleteObjectCommand({
|
new DeleteObjectCommand({
|
||||||
@ -179,8 +230,10 @@ export async function POST(req) {
|
|||||||
)
|
)
|
||||||
|
|
||||||
const result = {
|
const result = {
|
||||||
filePath: `https://${process.env.AMPLIFY_BUCKET}.s3.${process.env.AWS_REGION}.amazonaws.com/${Key}`,
|
filePath: `https://${process.env.AMPLIFY_BUCKET}.s3.${process.env.AWS_REGION}.amazonaws.com/${cellKey}`,
|
||||||
fileName: Key,
|
fullFilePath: `https://${process.env.AMPLIFY_BUCKET}.s3.${process.env.AWS_REGION}.amazonaws.com/${fullKey}`,
|
||||||
|
fileName: cellKey,
|
||||||
|
fullFileName: fullKey,
|
||||||
}
|
}
|
||||||
|
|
||||||
return NextResponse.json(result)
|
return NextResponse.json(result)
|
||||||
|
|||||||
@ -8,30 +8,53 @@ import { useContext } from 'react'
|
|||||||
import Config from '@/config/config.export'
|
import Config from '@/config/config.export'
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 이미지 로더 hook
|
* ======================================================================
|
||||||
* 캔버스를 바이너리로 변환하고 이미지 객체에 붙여서 다시 이미지를 바이너리로 전달
|
* 이미지 로더 Hook
|
||||||
* 캔버스 데이터가 바이너리로 변경되면 용량이 너무 커서 post전송에 실패할 수 있음
|
* ======================================================================
|
||||||
* @returns {function} handleCanvasToPng
|
*
|
||||||
|
* [개요]
|
||||||
|
* Fabric.js 캔버스에서 도면 영역만 PNG로 캡처하여 서버(/api/image/canvas)로 전송한다.
|
||||||
|
* 서버에서 2벌(풀사이즈/셀범위)로 가공 후 S3에 업로드한다.
|
||||||
|
*
|
||||||
|
* [캡처 방식]
|
||||||
|
* - getBoundingRect()로 도면 오브젝트(지붕, 치수, 화살표)의 정확한 바운딩 박스 계산
|
||||||
|
* - canvas.toDataURL()에 크롭 좌표를 전달하여 도면 영역만 직접 캡처
|
||||||
|
* - 배경을 흰색으로 설정하여 서버 autocrop이 정상 동작하도록 함
|
||||||
|
* - multiplier: 2로 고해상도(2배) 캡처
|
||||||
|
*
|
||||||
|
* @returns {function} handleCanvasToPng - 캔버스를 PNG로 캡처하여 서버에 전송
|
||||||
*/
|
*/
|
||||||
export function useImgLoader() {
|
export function useImgLoader() {
|
||||||
const canvas = useRecoilValue(canvasState)
|
const canvas = useRecoilValue(canvasState)
|
||||||
const { currentCanvasPlan } = usePlan()
|
const { currentCanvasPlan } = usePlan()
|
||||||
const { post } = useAxios()
|
const { post } = useAxios()
|
||||||
const { setIsGlobalLoading } = useContext(QcastContext)
|
const { setIsGlobalLoading } = useContext(QcastContext)
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 이미지 저장 시 왼쪽 위, 오른쪽 아래 좌표
|
* 도면 오브젝트의 바운딩 박스를 계산한다.
|
||||||
* return [start, end]
|
* 지붕(ROOF), 치수 텍스트(lengthText), 방위 화살표(arrow)를 대상으로 한다.
|
||||||
|
* getBoundingRect()를 사용하여 스케일/회전이 적용된 정확한 좌표를 구한다.
|
||||||
|
*
|
||||||
|
* @returns {Array<{x: number, y: number}>} [좌상단 좌표, 우하단 좌표] (margin 포함)
|
||||||
*/
|
*/
|
||||||
const getImageCoordinates = () => {
|
const getImageCoordinates = () => {
|
||||||
const margin = 20
|
const margin = 20
|
||||||
|
|
||||||
const objects = canvas.getObjects().filter((obj) => [POLYGON_TYPE.ROOF, 'lengthText', 'arrow'].includes(obj.name))
|
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)
|
let minX = Infinity
|
||||||
const minY = objects.reduce((acc, cur) => (cur.top < acc ? cur.top : acc), objects[0].top)
|
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 [
|
return [
|
||||||
{ x: minX - margin, y: minY - margin },
|
{ x: minX - margin, y: minY - margin },
|
||||||
{ x: maxX + margin, y: maxY + margin },
|
{ x: maxX + margin, y: maxY + margin },
|
||||||
@ -39,13 +62,29 @@ export function useImgLoader() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 캔버스를 이미지로 저장
|
* 캔버스를 PNG 이미지로 캡처하여 서버에 전송한다.
|
||||||
* @param {integer} type 1: 모듈만 있는 상태, 2: 가대까지 올린 상태
|
*
|
||||||
|
* [처리 순서]
|
||||||
|
* 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) => {
|
const handleCanvasToPng = async (type) => {
|
||||||
try {
|
try {
|
||||||
|
// 1. 그리드, 흡착점, 마우스라인 등 제거/숨김
|
||||||
toggleLineEtc(false)
|
toggleLineEtc(false)
|
||||||
|
|
||||||
|
// 2. 배경을 흰색으로 설정 (격자 패턴이 autocrop을 방해하므로)
|
||||||
|
const originalBg = canvas.backgroundColor
|
||||||
|
canvas.backgroundColor = '#ffffff'
|
||||||
|
|
||||||
|
// CORS 대응: 이미지 오브젝트에 crossOrigin 설정
|
||||||
canvas.getObjects('image').forEach((obj) => {
|
canvas.getObjects('image').forEach((obj) => {
|
||||||
if (obj.getSrc) {
|
if (obj.getSrc) {
|
||||||
const img = new Image()
|
const img = new Image()
|
||||||
@ -58,51 +97,72 @@ export function useImgLoader() {
|
|||||||
canvas.renderAll()
|
canvas.renderAll()
|
||||||
|
|
||||||
const formData = new FormData()
|
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 multiplier = 2
|
||||||
const dataUrl = canvas.toDataURL({
|
const dataUrl = canvas.toDataURL({
|
||||||
format: 'png',
|
format: 'png',
|
||||||
multiplier: multiplier,
|
multiplier: multiplier,
|
||||||
|
left: cropLeft,
|
||||||
|
top: cropTop,
|
||||||
|
width: cropWidth,
|
||||||
|
height: cropHeight,
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// base64 → Blob 변환
|
||||||
const blobBin = atob(dataUrl.split(',')[1])
|
const blobBin = atob(dataUrl.split(',')[1])
|
||||||
const array = []
|
const array = []
|
||||||
for (let i = 0; i < blobBin.length; i++) {
|
for (let i = 0; i < blobBin.length; i++) {
|
||||||
array.push(blobBin.charCodeAt(i))
|
array.push(blobBin.charCodeAt(i))
|
||||||
}
|
}
|
||||||
const file = new Blob([new Uint8Array(array)], { type: 'image/png' })
|
const file = new Blob([new Uint8Array(array)], { type: 'image/png' })
|
||||||
|
|
||||||
|
// 5. FormData 구성 및 서버 전송
|
||||||
formData.append('file', file, 'canvas.png')
|
formData.append('file', file, 'canvas.png')
|
||||||
formData.append('objectNo', currentCanvasPlan.objectNo)
|
formData.append('objectNo', currentCanvasPlan.objectNo)
|
||||||
formData.append('planNo', currentCanvasPlan.planNo)
|
formData.append('planNo', currentCanvasPlan.planNo)
|
||||||
formData.append('type', type)
|
formData.append('type', type)
|
||||||
/** 이미지 크롭 좌표 계산 (multiplier 배율 적용) */
|
formData.append('width', Math.round(cropWidth * multiplier))
|
||||||
const positionObj = getImageCoordinates()
|
formData.append('height', Math.round(cropHeight * multiplier))
|
||||||
console.log('🚀 ~ handleCanvasToPng ~ positionObj:', positionObj)
|
formData.append('left', 0) // 프론트에서 이미 크롭하여 전송하므로 0
|
||||||
formData.append('width', Math.round((positionObj[1].x - positionObj[0].x + 100) * multiplier))
|
formData.append('top', 0)
|
||||||
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))
|
|
||||||
console.log('🚀 ~ handleCanvasToPng ~ formData:', formData)
|
console.log('🚀 ~ handleCanvasToPng ~ formData:', formData)
|
||||||
|
|
||||||
/** 이미지 크롭 요청 */
|
|
||||||
const result = await post({
|
const result = await post({
|
||||||
// url: `${process.env.NEXT_PUBLIC_API_HOST_URL}/image/canvas`,
|
|
||||||
url: `${Config().baseUrl}/api/image/canvas`,
|
url: `${Config().baseUrl}/api/image/canvas`,
|
||||||
data: formData,
|
data: formData,
|
||||||
})
|
})
|
||||||
console.log('🚀 ~ handleCanvasToPng ~ result:', result)
|
console.log('🚀 ~ handleCanvasToPng ~ result:', result)
|
||||||
|
|
||||||
|
// 6. 배경/그리드 원복
|
||||||
|
canvas.backgroundColor = originalBg
|
||||||
|
canvas.renderAll()
|
||||||
|
|
||||||
toggleLineEtc(true)
|
toggleLineEtc(true)
|
||||||
|
|
||||||
return result
|
return result
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
|
canvas.backgroundColor = originalBg
|
||||||
|
canvas.renderAll()
|
||||||
setIsGlobalLoading(false)
|
setIsGlobalLoading(false)
|
||||||
console.log('🚀 ~ handleCanvasToPng ~ e:', e)
|
console.log('🚀 ~ handleCanvasToPng ~ e:', e)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 마우스 포인터 그리드, 임의그리드, 흡착점 등 제거.
|
* 캡처 시 불필요한 요소(마우스라인, 그리드, 흡착점)를 숨기거나 복원한다.
|
||||||
*
|
*
|
||||||
|
* @param {boolean} visible - true: 복원, false: 숨김
|
||||||
*/
|
*/
|
||||||
const toggleLineEtc = (visible = false) => {
|
const toggleLineEtc = (visible = false) => {
|
||||||
if (canvas?._objects.length > 0) {
|
if (canvas?._objects.length > 0) {
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user