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>
This commit is contained in:
sangwook yoo 2026-05-20 09:39:41 +09:00
parent c1247a95db
commit 849f7a1dc9
12 changed files with 2219 additions and 1476 deletions

View File

@ -37,3 +37,4 @@ NEXT_PUBLIC_ENABLE_LOGGING=true
#Gemini API Key
GEMINI_API_KEY="AIzaSyDDmMkTkNt12QTj7I3SUygYkOoeG_Sc28Q"
GEMINI_MODEL="gemini-3.1-pro-preview"

View File

@ -30,3 +30,7 @@ AWS_SECRET_ACCESS_KEY="Cw87TjKwnTWRKgORGxYiFU6GUTgu25eUw4eLBNcA"
NEXT_PUBLIC_AWS_S3_BASE_URL="//files.hanasys.jp"
S3_PROFILE="dev"
#Gemini API Key
GEMINI_API_KEY="AIzaSyDDmMkTkNt12QTj7I3SUygYkOoeG_Sc28Q"
GEMINI_MODEL="gemini-3.1-pro-preview"

View File

@ -35,3 +35,7 @@ S3_PROFILE="dev"
#logging
# [debug auto-capture 2026-05-06] true 로 켜면 debugCapture 가 debug/*.json/*.log 로 자동 저장.
NEXT_PUBLIC_ENABLE_LOGGING=true
#Gemini API Key
GEMINI_API_KEY="AIzaSyDDmMkTkNt12QTj7I3SUygYkOoeG_Sc28Q"
GEMINI_MODEL="gemini-3.1-pro-preview"

View File

@ -36,3 +36,4 @@ NEXT_PUBLIC_ENABLE_LOGGING=false
#Gemini API Key
GEMINI_API_KEY="AIzaSyDDmMkTkNt12QTj7I3SUygYkOoeG_Sc28Q"
GEMINI_MODEL="gemini-3.1-pro-preview"

3
.gitignore vendored
View File

@ -56,6 +56,9 @@ sl
# [graphify 2026-05-12] graphify 지식 그래프 산출물 (로컬 캐시, post-commit hook 으로 자동 갱신)
graphify-out/
# [playwright-mcp] Playwright MCP 임시 산출물 (스크린샷, 콘솔 로그)
.playwright-mcp/
# [local-only 2026-05-08] 모든 markdown 문서는 git 추적 제외 (로컬 전용)
# 단, 루트 README.md / CLAUDE.md / AGENTS.md, docs, .agents 및 .claude 하위의 md 는 추적 유지
*.md

View File

@ -19,6 +19,7 @@
},
"dependencies": {
"@aws-sdk/client-s3": "^3.772.0",
"@google/generative-ai": "^0.24.1",
"ag-grid-react": "^32.0.2",
"axios": "^1.7.8",
"big.js": "^6.2.2",

View File

@ -0,0 +1,210 @@
import { NextResponse } from 'next/server'
import { GoogleGenerativeAI, SchemaType } from '@google/generative-ai'
import { GoogleAIFileManager, FileState } from '@google/generative-ai/server'
import { logger } from '@/util/logger'
const MAX_FILE_BYTES = 20 * 1024 * 1024 // 20MB
const MAX_VERTICES = 500
const DEFAULT_MODEL = 'gemini-3.1-pro-preview'
const FLOOR_PLAN_SCHEMA = {
type: SchemaType.OBJECT,
properties: {
outerline: {
type: SchemaType.ARRAY,
items: {
type: SchemaType.OBJECT,
properties: {
x: { type: SchemaType.NUMBER },
y: { type: SchemaType.NUMBER },
},
required: ['x', 'y'],
},
},
unit: { type: SchemaType.STRING },
scale: { type: SchemaType.NUMBER },
confidence: { type: SchemaType.NUMBER },
notes: { type: SchemaType.STRING },
},
required: ['outerline', 'unit'],
}
const BASE_PROMPT = `당신은 건축 평면도(주택 도면)의 외곽선 추출 전문가입니다.
첨부된 PDF에서 건물의 외곽(외벽선) 폴리곤 좌표를 추출하여 JSON 으로만 응답하세요.
규칙:
1. 모든 좌표는 밀리미터(mm) 단위의 실측 치수로 환산해야 합니다. 도면의 축척(scale) 식별하여 적용하세요.
2. 좌표계는 도면의 좌상단을 원점으로 하고, x 우측 양수, y 하단 양수로 합니다.
3. 외곽선은 시계 반대 방향(CCW) 으로 정렬된 정점 배열로 반환합니다.
4. 자기교차가 없는 단순 폴리곤이어야 하며, 정점은 3 이상이어야 합니다.
5. PDF 평면도가 아니거나 외곽을 추출할 없으면 outerline 배열로 반환하고 notes 사유를 적습니다.
6. 응답은 반드시 단일 JSON 객체만 포함하며, 다른 텍스트, 마크다운, 코드펜스를 포함하지 마세요.
7. 좌표는 정수 또는 소수 첫째 자리까지 허용합니다.
스키마:
{
"outerline": [{"x": number, "y": number}, ...],
"unit": "mm",
"scale": number (선택, 도면 1mm 실제 mm),
"confidence": number (0~1),
"notes": string (선택)
}`
const buildPrompt = ({ pageMode, facadePages, floorPages } = {}) => {
if (pageMode === 'specify' && (facadePages || floorPages)) {
const hints = []
if (floorPages) hints.push(`평면도(平面図) 페이지: ${floorPages}`)
if (facadePages) hints.push(`입면도(立面図) 페이지: ${facadePages}`)
return `${BASE_PROMPT}\n\n페이지 힌트: ${hints.join(' / ')}. 외곽선은 위에서 지정한 평면도 페이지 기준으로 추출하세요.`
}
return BASE_PROMPT
}
const validateOuterline = (outerline) => {
if (!Array.isArray(outerline)) {
return { ok: false, code: 'INVALID_FORMAT', message: 'outerline 이 배열이 아닙니다.' }
}
if (outerline.length === 0) {
return { ok: true, empty: true }
}
if (outerline.length < 3) {
return { ok: false, code: 'TOO_FEW_VERTICES', message: '정점이 3개 미만입니다.' }
}
if (outerline.length > MAX_VERTICES) {
return { ok: false, code: 'TOO_MANY_VERTICES', message: `정점이 ${MAX_VERTICES} 개를 초과합니다.` }
}
for (const p of outerline) {
if (typeof p?.x !== 'number' || typeof p?.y !== 'number' || !Number.isFinite(p.x) || !Number.isFinite(p.y)) {
return { ok: false, code: 'INVALID_VERTEX', message: '정점에 숫자가 아닌 좌표가 포함되어 있습니다.' }
}
}
// 면적 0 거부 (signed area)
let area = 0
for (let i = 0; i < outerline.length; i++) {
const cur = outerline[i]
const next = outerline[(i + 1) % outerline.length]
area += cur.x * next.y - next.x * cur.y
}
if (Math.abs(area) < 1) {
return { ok: false, code: 'ZERO_AREA', message: '폴리곤 면적이 0 입니다.' }
}
return { ok: true, empty: false }
}
const waitForFileActive = async (fileManager, fileName, { timeoutMs = 60000, intervalMs = 1500 } = {}) => {
const started = Date.now()
let file = await fileManager.getFile(fileName)
while (file.state === FileState.PROCESSING) {
if (Date.now() - started > timeoutMs) {
throw new Error('Gemini 파일 처리 타임아웃')
}
await new Promise((r) => setTimeout(r, intervalMs))
file = await fileManager.getFile(fileName)
}
if (file.state !== FileState.ACTIVE) {
throw new Error(`Gemini 파일 처리 실패: ${file.state}`)
}
return file
}
export async function POST(req) {
const apiKey = process.env.GEMINI_API_KEY
const modelName = process.env.GEMINI_MODEL || DEFAULT_MODEL
if (!apiKey) {
return NextResponse.json({ error: { code: 'NO_API_KEY', message: 'GEMINI_API_KEY 가 설정되지 않았습니다.' } }, { status: 500 })
}
let uploadedFileName = null
const fileManager = new GoogleAIFileManager(apiKey)
try {
const formData = await req.formData()
const file = formData.get('file')
if (!file || typeof file === 'string') {
return NextResponse.json({ error: { code: 'NO_FILE', message: '파일이 전달되지 않았습니다.' } }, { status: 400 })
}
if (file.type !== 'application/pdf') {
return NextResponse.json({ error: { code: 'INVALID_MIME', message: 'PDF 파일만 업로드할 수 있습니다.' } }, { status: 400 })
}
if (file.size > MAX_FILE_BYTES) {
return NextResponse.json(
{ error: { code: 'FILE_TOO_LARGE', message: `파일 크기가 ${Math.round(MAX_FILE_BYTES / 1024 / 1024)}MB 를 초과합니다.` } },
{ status: 400 },
)
}
const pageMode = formData.get('pageMode') || 'all'
const facadePages = formData.get('facadePages') || ''
const floorPages = formData.get('floorPages') || ''
const buffer = Buffer.from(await file.arrayBuffer())
const uploadResult = await fileManager.uploadFile(buffer, {
mimeType: 'application/pdf',
displayName: file.name || 'floor-plan.pdf',
})
uploadedFileName = uploadResult.file.name
await waitForFileActive(fileManager, uploadedFileName)
const genAI = new GoogleGenerativeAI(apiKey)
const model = genAI.getGenerativeModel({
model: modelName,
generationConfig: {
responseMimeType: 'application/json',
responseSchema: FLOOR_PLAN_SCHEMA,
temperature: 0.1,
},
})
const prompt = buildPrompt({ pageMode, facadePages, floorPages })
const result = await model.generateContent([
{
fileData: {
fileUri: uploadResult.file.uri,
mimeType: uploadResult.file.mimeType,
},
},
{ text: prompt },
])
const raw = result.response.text()
logger.debug('[gemini/floor-plan] raw response', raw)
let parsed
try {
parsed = JSON.parse(raw)
} catch (e) {
return NextResponse.json({ error: { code: 'PARSE_FAILED', message: '응답 JSON 파싱 실패' } }, { status: 502 })
}
const validation = validateOuterline(parsed.outerline)
if (!validation.ok) {
return NextResponse.json({ error: { code: validation.code, message: validation.message } }, { status: 422 })
}
return NextResponse.json({
outerline: parsed.outerline,
unit: parsed.unit || 'mm',
scale: parsed.scale ?? null,
confidence: parsed.confidence ?? null,
notes: parsed.notes ?? null,
empty: validation.empty,
})
} catch (error) {
logger.error('[gemini/floor-plan] error', error?.message || error)
return NextResponse.json({ error: { code: 'GEMINI_FAILED', message: error?.message || 'Gemini 호출에 실패했습니다.' } }, { status: 500 })
} finally {
if (uploadedFileName) {
try {
await fileManager.deleteFile(uploadedFileName)
} catch (deleteError) {
logger.warn('[gemini/floor-plan] deleteFile failed', deleteError?.message || deleteError)
}
}
}
}

View File

@ -23,12 +23,27 @@ import { useRoofFn } from '@/hooks/common/useRoofFn'
import { usePlan } from '@/hooks/usePlan'
import { normalizeDecimal } from '@/util/input-utils'
import { CalculatorInput } from '@/components/common/input/CalcInput'
import { usePdfImport } from '@/hooks/pdf-import/usePdfImport'
import { logger } from '@/util/logger'
// [LOW-PITCH-WARN 2026-05-06] + alert
import { useSwal } from '@/hooks/useSwal'
import { notifyLowPitchRestrictionForRoofs, isLowPitchRestricted, LOW_PITCH_RESTRICTED_ROOF_IDS } from '@/util/roof-pitch-warning'
// [LOW-PITCH-DIAG 2026-05-06 TEMP] import +
import { debugCapture } from '@/util/debugCapture'
const INPUT_MODE = {
SIZE_ROOF: '1',
SIZE_ACTUAL: '2',
PDF: 'pdf',
}
const PDF_MAX_FILE_BYTES = 20 * 1024 * 1024
const PDF_PAGE_MODE = {
ALL: 'all',
SPECIFY: 'specify',
}
/**
* 지붕 레이아웃
*/
@ -76,15 +91,112 @@ export default function PlacementShapeSetting({ id, pos = { x: 50, y: 180 }, pla
const { saveCanvas } = usePlan()
// [LOW-PITCH-WARN 2026-05-06]
const { swalFire } = useSwal()
const { applyOuterline } = usePdfImport()
// PDF
const [inputMode, setInputMode] = useState(INPUT_MODE.SIZE_ROOF)
const [pdfFile, setPdfFile] = useState(null)
const [pdfPageMode, setPdfPageMode] = useState(PDF_PAGE_MODE.ALL)
const [pdfFacadePages, setPdfFacadePages] = useState('')
const [pdfFloorPages, setPdfFloorPages] = useState('')
const [pdfAnalyzing, setPdfAnalyzing] = useState(false)
const pdfInputRef = useRef(null)
/**
* 치수 입력방법(복시도입력/실측값입력/육지붕)
* 치수 입력방법(복시도입력/실측값입력/도면 파일 업로드)
*/
const roofSizeSetArray = [
{ id: 'ra01', name: 'roofSizeSet', value: '1', message: 'modal.placement.initial.setting.size.roof' },
{ id: 'ra02', name: 'roofSizeSet', value: '2', message: 'modal.placement.initial.setting.size.actual' },
// { id: 'ra03', name: 'roofSizeSet', value: '3', message: 'modal.placement.initial.setting.size.none.pitch' },
{ id: 'ra01', name: 'roofSizeSet', value: INPUT_MODE.SIZE_ROOF, message: 'modal.placement.initial.setting.size.roof' },
{ id: 'ra02', name: 'roofSizeSet', value: INPUT_MODE.SIZE_ACTUAL, message: 'modal.placement.initial.setting.size.actual' },
{ id: 'ra-pdf', name: 'roofSizeSet', value: INPUT_MODE.PDF, message: 'modal.placement.initial.setting.size.pdf' },
]
const handleInputModeChange = (value) => {
setInputMode(value)
if (value === INPUT_MODE.PDF) {
// PDF (1) .
setCurrentRoof((prev) => ({ ...prev, roofSizeSet: INPUT_MODE.SIZE_ROOF }))
} else {
setCurrentRoof((prev) => ({ ...prev, roofSizeSet: value }))
}
}
const resetPdfFile = () => {
setPdfFile(null)
if (pdfInputRef.current) pdfInputRef.current.value = ''
}
const handlePdfFileChange = (e) => {
const next = e.target.files?.[0]
if (!next) return
if (next.type !== 'application/pdf') {
swalFire({ text: getMessage('modal.placement.initial.setting.size.pdf.error.mime'), type: 'alert' })
e.target.value = ''
return
}
if (next.size > PDF_MAX_FILE_BYTES) {
swalFire({ text: getMessage('modal.placement.initial.setting.size.pdf.error.too.large'), type: 'alert' })
e.target.value = ''
return
}
setPdfFile(next)
}
const analyzePdfAndApply = async () => {
if (!pdfFile) {
swalFire({ text: getMessage('modal.placement.initial.setting.size.pdf.error.no.file'), type: 'alert' })
return false
}
const formData = new FormData()
formData.append('file', pdfFile)
formData.append('unitHint', 'mm')
formData.append('pageMode', pdfPageMode)
if (pdfPageMode === PDF_PAGE_MODE.SPECIFY) {
formData.append('facadePages', pdfFacadePages.trim())
formData.append('floorPages', pdfFloorPages.trim())
}
setPdfAnalyzing(true)
let response
try {
response = await fetch('/api/gemini/floor-plan', { method: 'POST', body: formData })
} catch (e) {
logger.error('[PlacementShapeSetting] PDF analyze fetch failed', e?.message || e)
setPdfAnalyzing(false)
swalFire({ text: getMessage('pdf.import.error.network'), type: 'alert' })
return false
}
let payload
try {
payload = await response.json()
} catch (e) {
logger.error('[PlacementShapeSetting] PDF analyze response parse failed', e?.message || e)
setPdfAnalyzing(false)
swalFire({ text: getMessage('pdf.import.error.analyze'), type: 'alert' })
return false
}
setPdfAnalyzing(false)
if (!response.ok) {
const msg = payload?.error?.message || getMessage('pdf.import.error.analyze')
swalFire({ text: msg, type: 'alert' })
return false
}
if (payload.empty || !Array.isArray(payload.outerline) || payload.outerline.length === 0) {
swalFire({ text: payload.notes || getMessage('pdf.import.error.no.floorplan'), type: 'alert' })
return false
}
const applied = await applyOuterline(payload.outerline, { unit: payload.unit || 'mm' })
if (!applied) {
swalFire({ text: getMessage('pdf.import.error.apply'), type: 'alert' })
return false
}
return true
}
/**
* 지붕각도 설정(경사/각도)
*/
@ -138,7 +250,7 @@ export default function PlacementShapeSetting({ id, pos = { x: 50, y: 180 }, pla
*/
useEffect(() => {
if (currentCanvasPlan?.planNo && currentRoof) {
setCurrentRoof(prev => ({ ...prev, planNo: currentCanvasPlan.planNo }))
setCurrentRoof((prev) => ({ ...prev, planNo: currentCanvasPlan.planNo }))
}
}, [currentCanvasPlan?.planNo])
@ -238,6 +350,13 @@ export default function PlacementShapeSetting({ id, pos = { x: 50, y: 180 }, pla
* 배치면초기설정 저장 버튼 클릭
*/
const handleSaveBtn = async () => {
if (pdfAnalyzing) return
if (inputMode === INPUT_MODE.PDF) {
const ok = await analyzePdfAndApply()
if (!ok) return
}
const roofInfo = {
...currentRoof,
planNo: currentCanvasPlan?.planNo || basicSetting.planNo,
@ -273,16 +392,19 @@ export default function PlacementShapeSetting({ id, pos = { x: 50, y: 180 }, pla
/**
* 배치면초기설정 저장 (메뉴 변경/useEffect 트리거 없이)
*/
basicSettingSave({
...basicSetting,
planNo: currentCanvasPlan?.planNo || basicSetting.planNo,
/**
* 선택된 지붕재 정보
*/
selectedRoofMaterial: {
...newAddedRoofs[0],
basicSettingSave(
{
...basicSetting,
planNo: currentCanvasPlan?.planNo || basicSetting.planNo,
/**
* 선택된 지붕재 정보
*/
selectedRoofMaterial: {
...newAddedRoofs[0],
},
},
}, { skipSideEffects: true })
{ skipSideEffects: true },
)
const roofs = canvas.getObjects().filter((obj) => obj.roofMaterial?.index === 0)
@ -354,15 +476,119 @@ export default function PlacementShapeSetting({ id, pos = { x: 50, y: 180 }, pla
id={item.id}
name={item.name}
value={item.value}
checked={String(currentRoof?.roofSizeSet) === item.value}
onChange={(e) => setCurrentRoof({ ...currentRoof, roofSizeSet: e.target.value })}
checked={inputMode === item.value}
onChange={(e) => handleInputModeChange(e.target.value)}
/>
<label htmlFor={item.id}>{getMessage(item.message)}</label>
</div>
))}
</div>
{inputMode === INPUT_MODE.PDF && (
<div
className="pdf-info-text"
style={{ fontSize: '12px', lineHeight: 1.5, color: '#bbb', whiteSpace: 'pre-line', marginTop: '8px' }}
>
{getMessage('modal.placement.initial.setting.size.pdf.info')}
</div>
)}
</td>
</tr>
{inputMode === INPUT_MODE.PDF && (
<>
<tr>
<th>{getMessage('modal.placement.initial.setting.size.pdf.file.label')}</th>
<td>
<div className="img-flex-box" style={{ display: 'flex', alignItems: 'center', gap: '10px' }}>
<div className="img-edit-wrap">
<label className={`img-edit-btn ${pdfAnalyzing ? 'no-click' : ''}`} htmlFor="pdf_import_file">
<span className="img-edit"></span>
{getMessage('modal.placement.initial.setting.size.pdf.file.upload')}
</label>
<input
ref={pdfInputRef}
type="file"
id="pdf_import_file"
accept="application/pdf"
style={{ display: 'none' }}
disabled={pdfAnalyzing}
onChange={handlePdfFileChange}
/>
</div>
<div className="img-name-wrap" style={{ flex: 1, display: 'flex', alignItems: 'center' }}>
<input
type="text"
className="input-origin al-l"
value={pdfFile?.name ?? ''}
readOnly
placeholder={getMessage('modal.placement.initial.setting.size.pdf.file.placeholder')}
style={{ flex: 1 }}
/>
{pdfFile && !pdfAnalyzing && <button className="img-check" onClick={resetPdfFile}></button>}
</div>
</div>
<div style={{ fontSize: '12px', color: '#bbb', marginTop: '4px' }}>
{getMessage('modal.placement.initial.setting.size.pdf.file.info')}
</div>
</td>
</tr>
<tr>
<th>{getMessage('modal.placement.initial.setting.size.pdf.page.label')}</th>
<td>
<div className="pop-form-radio" style={{ alignItems: 'center', gap: '12px' }}>
<div className="d-check-radio pop">
<input
type="radio"
id="pdf-page-all"
name="pdfPageMode"
value={PDF_PAGE_MODE.ALL}
checked={pdfPageMode === PDF_PAGE_MODE.ALL}
onChange={(e) => setPdfPageMode(e.target.value)}
/>
<label htmlFor="pdf-page-all">{getMessage('modal.placement.initial.setting.size.pdf.page.all')}</label>
</div>
<div className="d-check-radio pop">
<input
type="radio"
id="pdf-page-specify"
name="pdfPageMode"
value={PDF_PAGE_MODE.SPECIFY}
checked={pdfPageMode === PDF_PAGE_MODE.SPECIFY}
onChange={(e) => setPdfPageMode(e.target.value)}
/>
<label htmlFor="pdf-page-specify">{getMessage('modal.placement.initial.setting.size.pdf.page.specify')}</label>
</div>
{pdfPageMode === PDF_PAGE_MODE.SPECIFY && (
<>
<span style={{ fontSize: '13px' }}>{getMessage('modal.placement.initial.setting.size.pdf.page.facade.label')}</span>
<input
type="text"
className="input-origin"
style={{ width: '90px' }}
placeholder={getMessage('modal.placement.initial.setting.size.pdf.page.example.facade')}
value={pdfFacadePages}
onChange={(e) => setPdfFacadePages(e.target.value)}
/>
<span style={{ fontSize: '13px' }}>{getMessage('modal.placement.initial.setting.size.pdf.page.floor.label')}</span>
<input
type="text"
className="input-origin"
style={{ width: '90px' }}
placeholder={getMessage('modal.placement.initial.setting.size.pdf.page.example.floor')}
value={pdfFloorPages}
onChange={(e) => setPdfFloorPages(e.target.value)}
/>
</>
)}
</div>
<div style={{ fontSize: '12px', color: '#bbb', marginTop: '4px' }}>
{pdfPageMode === PDF_PAGE_MODE.SPECIFY
? getMessage('modal.placement.initial.setting.size.pdf.page.specify.info')
: getMessage('modal.placement.initial.setting.size.pdf.page.all.info')}
</div>
</td>
</tr>
</>
)}
<tr>
<th>
<div className="tip-wrap">
@ -630,10 +856,19 @@ export default function PlacementShapeSetting({ id, pos = { x: 50, y: 180 }, pla
</div>
<div className="grid-btn-wrap" style={{ display: 'flex', alignItems: 'center', justifyContent: 'flex-end', gap: '5px' }}>
<button style={{ width: 'auto' }} onClick={() => setUseCalcPad((prev) => !prev)}>
<Image src={useCalcPad ? '/static/images/common/Icon_ON_Black.png' : '/static/images/common/Icon_OFF_Black.png'} alt="toggle" width={34} height={34} />
<Image
src={useCalcPad ? '/static/images/common/Icon_ON_Black.png' : '/static/images/common/Icon_OFF_Black.png'}
alt="toggle"
width={34}
height={34}
/>
</button>
<button className="btn-frame modal act" onClick={handleSaveBtn}>
{getMessage('modal.common.save')}
<button
className={`btn-frame modal act ${pdfAnalyzing || (inputMode === INPUT_MODE.PDF && !pdfFile) ? 'no-click' : ''}`}
onClick={handleSaveBtn}
disabled={pdfAnalyzing || (inputMode === INPUT_MODE.PDF && !pdfFile)}
>
{pdfAnalyzing ? getMessage('pdf.import.button.analyzing') : getMessage('modal.common.save')}
</button>
</div>
</WithDraggable.Body>

View File

@ -0,0 +1,176 @@
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 }
}

View File

@ -16,6 +16,33 @@
"header.stem": "Stem",
"plan.menu.plan.drawing": "物件情報",
"plan.menu.placement.surface.initial.setting": "配置面初期設定",
"pdf.import.button.analyzing": "解析中...",
"pdf.import.success": "外壁線をキャンバスに反映しました。",
"pdf.import.confirm.overwrite": "既存の外壁線があります。新しい外壁線で上書きしますか?",
"pdf.import.error.network": "ネットワークエラーで解析リクエストに失敗しました。",
"pdf.import.error.analyze": "PDF 解析に失敗しました。",
"pdf.import.error.no.floorplan": "平面図を認識できませんでした。別の PDF で再度お試しください。",
"pdf.import.error.too.few.vertices": "外壁線の頂点が不足しています。別の PDF で再度お試しください。",
"pdf.import.error.unsupported.unit": "未対応の座標単位です。",
"pdf.import.error.apply": "解析結果をキャンバスに反映できませんでした。",
"modal.placement.initial.setting.size.pdf": "図面ファイルアップロード",
"modal.placement.initial.setting.size.pdf.info": "住宅図面ファイルをアップロード後、全ページ解析または立面図·平面図ページ指定方式で外壁線と屋根形状を作成できます。\n住宅図面ファイルアップロードによる自動図面作成は、必ずしも正確な結果を保証しません。\n見積作成後、必ず住宅図面と出力結果が一致するか確認してください。",
"modal.placement.initial.setting.size.pdf.file.label": "住宅図面ファイルアップロード",
"modal.placement.initial.setting.size.pdf.file.upload": "アップロード",
"modal.placement.initial.setting.size.pdf.file.placeholder": "ファイルをドラッグまたはクリックでアップロード (.PDF)",
"modal.placement.initial.setting.size.pdf.file.info": "住宅図面の解析結果に応じて外壁線と屋根形状が一緒に生成されます。屋根形状を認識できない場合は外壁線のみ生成され、屋根面は手動で作成します。",
"modal.placement.initial.setting.size.pdf.page.label": "読み込みページ設定",
"modal.placement.initial.setting.size.pdf.page.all": "全ページ",
"modal.placement.initial.setting.size.pdf.page.specify": "ページ指定",
"modal.placement.initial.setting.size.pdf.page.facade.label": "立面図",
"modal.placement.initial.setting.size.pdf.page.floor.label": "平面図",
"modal.placement.initial.setting.size.pdf.page.example.facade": "例) 1, 2",
"modal.placement.initial.setting.size.pdf.page.example.floor": "例) 3, 4",
"modal.placement.initial.setting.size.pdf.page.all.info": "全ページを解析するため処理時間が長くなる場合があります。図面内の立面図·平面図ページが特定できる場合はページ指定を推奨します。",
"modal.placement.initial.setting.size.pdf.page.specify.info": "立面図·平面図が含まれるページ番号をそれぞれ入力してください。指定したページのみ読み込み素早く屋根を自動作成します。",
"modal.placement.initial.setting.size.pdf.error.mime": "PDF ファイルのみアップロード可能です。",
"modal.placement.initial.setting.size.pdf.error.too.large": "ファイルサイズが 20MB を超えています。",
"modal.placement.initial.setting.size.pdf.error.no.file": "先に PDF ファイルをアップロードしてください。",
"modal.placement.initial.setting.plan.drawing": "図面の作成方法",
"modal.placement.initial.setting.plan.drawing.size.stuff": "寸法入力による物件作成",
"modal.placement.initial.setting.plan.drawing.size.info": "※数字は[半角]入力のみ可能です。",

View File

@ -16,6 +16,33 @@
"header.stem": "Stem",
"plan.menu.plan.drawing": "물건정보",
"plan.menu.placement.surface.initial.setting": "배치면 초기설정",
"pdf.import.button.analyzing": "분석 중...",
"pdf.import.success": "외곽선이 캔버스에 반영되었습니다.",
"pdf.import.confirm.overwrite": "기존 외곽선이 있습니다. 새 외곽선으로 덮어쓰시겠습니까?",
"pdf.import.error.network": "네트워크 오류로 분석 요청에 실패했습니다.",
"pdf.import.error.analyze": "PDF 분석에 실패했습니다.",
"pdf.import.error.no.floorplan": "평면도를 인식하지 못했습니다. 다른 PDF 로 다시 시도해 주세요.",
"pdf.import.error.too.few.vertices": "외곽선 정점이 부족합니다. 다른 PDF 로 다시 시도해 주세요.",
"pdf.import.error.unsupported.unit": "지원하지 않는 좌표 단위입니다.",
"pdf.import.error.apply": "분석 결과를 캔버스에 반영하지 못했습니다.",
"modal.placement.initial.setting.size.pdf": "도면 파일 업로드",
"modal.placement.initial.setting.size.pdf.info": "주택 도면 파일 업로드 후 전체 페이지 분석 또는 입면도·평면도 페이지 지정 방식으로 외벽선과 지붕 형상을 작성할 수 있습니다.\n주택 도면 파일 업로드를 통한 자동 도면 작성은 반드시 정확한 결과라고 보장할 수 없습니다.\n견적 작성 후 반드시 주택 도면과 출력 결과가 일치하는지 확인해 주십시오.",
"modal.placement.initial.setting.size.pdf.file.label": "주택 도면 파일 업로드",
"modal.placement.initial.setting.size.pdf.file.upload": "업로드",
"modal.placement.initial.setting.size.pdf.file.placeholder": "파일을 드래그하거나 클릭하여 업로드 (.PDF)",
"modal.placement.initial.setting.size.pdf.file.info": "주택 도면 분석 결과에 따라 외벽선과 지붕형상이 함께 생성됩니다. 지붕형상을 인식하지 못한 경우 외벽선만 생성되며, 지붕면은 수동으로 작성합니다.",
"modal.placement.initial.setting.size.pdf.page.label": "읽기 페이지 설정",
"modal.placement.initial.setting.size.pdf.page.all": "전체 페이지",
"modal.placement.initial.setting.size.pdf.page.specify": "페이지 지정",
"modal.placement.initial.setting.size.pdf.page.facade.label": "입면도",
"modal.placement.initial.setting.size.pdf.page.floor.label": "평면도",
"modal.placement.initial.setting.size.pdf.page.example.facade": "예) 1, 2",
"modal.placement.initial.setting.size.pdf.page.example.floor": "예) 3, 4",
"modal.placement.initial.setting.size.pdf.page.all.info": "모든 페이지를 분석하므로 처리 시간이 길어질 수 있습니다. 도면 내 입면도·평면도 페이지가 확인되면 페이지 지정을 권장합니다.",
"modal.placement.initial.setting.size.pdf.page.specify.info": "입면도·평면도가 포함된 페이지 번호를 각각 입력해주세요. 지정한 페이지만 읽어 빠르게 지붕을 자동 작성합니다.",
"modal.placement.initial.setting.size.pdf.error.mime": "PDF 파일만 업로드할 수 있습니다.",
"modal.placement.initial.setting.size.pdf.error.too.large": "파일 크기가 20MB 를 초과합니다.",
"modal.placement.initial.setting.size.pdf.error.no.file": "PDF 파일을 먼저 업로드해 주세요.",
"modal.placement.initial.setting.plan.drawing": "도면 작성방법",
"modal.placement.initial.setting.plan.drawing.size.stuff": "치수 입력에 의한 물건 작성",
"modal.placement.initial.setting.plan.drawing.size.info": "※숫자는 [반각] 입력만 가능합니다.",

File diff suppressed because it is too large Load Diff