Merge remote-tracking branch 'origin/dev' into dev

This commit is contained in:
sangwook yoo 2026-05-27 17:30:47 +09:00
commit dba2b16277
16 changed files with 3482 additions and 1477 deletions

15
.codex/hooks.json Normal file
View File

@ -0,0 +1,15 @@
{
"hooks": {
"PreToolUse": [
{
"matcher": "Bash",
"hooks": [
{
"type": "command",
"command": "CMD=$(python3 -c \"import json,sys; d=json.load(sys.stdin); print(d.get('tool_input',d).get('command',''))\" 2>/dev/null || true); set -- $CMD; case \"$1\" in grep|egrep|fgrep|rg|ripgrep|find|fd|ack|ag) [ -f graphify-out/graph.json ] && echo '{\"hookSpecificOutput\":{\"hookEventName\":\"PreToolUse\",\"additionalContext\":\"graphify: Knowledge graph exists. Read graphify-out/GRAPH_REPORT.md for god nodes and community structure before searching raw files.\"}}' || true ;; esac"
}
]
}
]
}
}

View File

@ -33,4 +33,8 @@ NEXT_PUBLIC_AWS_S3_BASE_URL="//files.hanasys.jp"
S3_PROFILE="dev"
#logging
NEXT_PUBLIC_ENABLE_LOGGING=true
NEXT_PUBLIC_ENABLE_LOGGING=true
#Gemini API Key
GEMINI_API_KEY="AIzaSyDDmMkTkNt12QTj7I3SUygYkOoeG_Sc28Q"
GEMINI_MODEL="gemini-3.1-pro-preview"

View File

@ -29,4 +29,8 @@ AWS_ACCESS_KEY_ID="AKIA3K4QWLZHFZRJOM2E"
AWS_SECRET_ACCESS_KEY="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"
NEXT_PUBLIC_AWS_S3_BASE_URL="//files.hanasys.jp"
S3_PROFILE="dev"
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

@ -32,4 +32,8 @@ NEXT_PUBLIC_AWS_S3_BASE_URL="//files.hanasys.jp"
S3_PROFILE="prd"
#logging
NEXT_PUBLIC_ENABLE_LOGGING=false
NEXT_PUBLIC_ENABLE_LOGGING=false
#Gemini API Key
GEMINI_API_KEY="AIzaSyDDmMkTkNt12QTj7I3SUygYkOoeG_Sc28Q"
GEMINI_MODEL="gemini-3.1-pro-preview"

6
.gitignore vendored
View File

@ -56,12 +56,16 @@ 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, .agents 및 .claude 하위의 md 는 추적 유지
# 단, 루트 README.md / CLAUDE.md / AGENTS.md, docs, .agents 및 .claude 하위의 md 는 추적 유지
*.md
!/README.md
!/CLAUDE.md
!/AGENTS.md
!/docs/**/*.md
!.agents/**/*.md
!.claude/**/*.md

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,166 @@
# PDF 평면도 → Gemini 분석 → 캔버스 반영 구현 계획
> 작성일: 2026-05-18
> 대상 브랜치: `claude/lucid-shaw-91e51f` (dev `f32f9187` 분기)
> 모델: `gemini-3.1-pro-preview` · 좌표 단위: `mm` · 진입 위치: floor-plan 상단 툴바
---
## 1. 기능 요구 사항
1. 기본 프로세스 상에서 특정 버튼 클릭으로 사용자가 PDF 문서를 업로드한다.
2. Gemini API 로 해당 문서의 내용 중 평면도를 분석한다.
3. 분석 내용을 응답으로 전달받고 현재 프로세스(캔버스)로 전달한다.
4. 분석이 성공적으로 마무리되면 업로드한 파일은 폐기 처리한다.
---
## 2. 결정사항
| 항목 | 선택 |
|---|---|
| 워크트리 | 현재 워크트리(`lucid-shaw-91e51f`)에서 진행 |
| 캔버스 반영 형태 | 외곽선 폴리곤 좌표(JSON) |
| 버튼 위치 | floor-plan 상단 툴바 |
| API 호출 위치 | 서버 라우트 (`/api/gemini/floor-plan`) |
| Gemini 모델 | `gemini-3.1-pro-preview` |
| 좌표 단위 | `mm` |
| 툴바 아이콘 | 신규 PDF 아이콘 추가 |
---
## 3. 전체 흐름
```
[툴바 버튼] → [PdfImportModal] → POST /api/gemini/floor-plan (multipart)
Gemini File API 업로드
외곽선 좌표 JSON 응답
Gemini 파일 삭제 (finally)
응답 좌표 → 좌표 검증/정규화 → outerLinePointsState + QLine/QPolygon 생성
```
---
## 4. 핵심 파일 (수정/신규)
| 영역 | 경로 | 변경 |
|---|---|---|
| 서버 라우트 | `src/app/api/gemini/floor-plan/route.js` | **신규** — multipart 수신 + Gemini 호출 + finally 폐기 |
| 환경설정 | `src/config/config.*.js` | `GEMINI_API_KEY`, `GEMINI_MODEL` 추가 |
| 의존성 | `package.json` | `@google/generative-ai` 추가 |
| 메뉴 등록 | `src/store/menuAtom.js` (`menusState`) | `{ type: 'pdf-import', name, icon }` 항목 |
| 툴바 분기 | `src/components/floor-plan/CanvasMenu.jsx` (`onClickNav`) | `case 'pdf-import'` 추가 |
| 업로드 모달 | `src/components/floor-plan/modal/pdfImport/PdfImportModal.jsx` | **신규** — 파일 선택/진행/에러 |
| 캔버스 반영 hook | `src/hooks/pdf-import/usePdfImport.js` | **신규** — 좌표 검증 + 캔버스 반영 |
| 다국어 메시지 | 메시지 키 추가 | `pdf.import.*` (ja/ko) |
| 아이콘 | `public/` 또는 기존 아이콘 디렉토리 | PDF 임포트용 SVG 추가 |
---
## 5. 서버 라우트 설계 (`/api/gemini/floor-plan`)
### 5-1. 입력
- `multipart/form-data`
- 필드:
- `file`: PDF (≤ 20MB, MIME `application/pdf`)
- `unitHint` (옵션): `mm` | `auto`
### 5-2. 처리 단계
1. `formData.get('file')` 로 PDF 수신 → 사이즈/MIME 검증
2. `GoogleGenerativeAI(GEMINI_API_KEY)` 클라이언트 생성
3. **Gemini File API 업로드**`fileUri` 획득
4. 모델 호출 (`gemini-3.1-pro-preview`)
- 프롬프트 핵심:
- 역할: "건축 평면도 외곽선 추출 전문가"
- 출력: JSON only, 단위 mm
- 스키마: `{ outerline: [{x,y}, ...], unit: 'mm', scale?: number, confidence: 0..1, notes?: string }`
- "평면도가 아니면 `{ outerline: [], notes: '...' }` 반환"
- `response_mime_type=application/json`, `response_schema` 강제
5. JSON 파싱 → 좌표 검증
- vertex ≥ 3
- 자기교차 검출 (turf 활용)
- 0 면적 거부
6. **finally** 블록에서 `genai.files.delete(uploadedFile.name)` — 성공/실패 무관 삭제
### 5-3. 출력
- 성공: `{ outerline: [{x,y}, ...], unit: 'mm', confidence }`
- 실패: `{ error: { code, message } }`
### 5-4. 보안 / 운영
- `GEMINI_API_KEY` 는 서버 env 만 사용, 클라이언트 노출 금지
- S3 등 영구 저장소에 절대 저장하지 않음 (요구 4번: 폐기)
- 로깅: `src/util/logger.js` 만 사용, raw 응답은 `NEXT_PUBLIC_ENABLE_LOGGING=true` 일 때만 출력
---
## 6. 클라이언트 흐름
### 6-1. 모달 (`PdfImportModal`)
- 상태머신: `idle → uploading → analyzing → success | error`
- 호출: `fetch('/api/gemini/floor-plan', { method: 'POST', body: formData })`
- 성공: `usePdfImport.applyOuterline(points, unit)` 호출 후 모달 닫기
- 실패: `useSwal` 로 에러 메시지 표시
### 6-2. 캔버스 반영 hook (`usePdfImport`)
- **단위 변환**: `unit === 'mm'` 응답 → canvas px/mm 비율로 변환
- **정규화**: 시계방향 정렬, bounding box 중앙 정렬(translate)
- **기존 외곽선 분기**:
- 존재 시 `useSwal` confirm: "기존 외곽선을 덮어쓰시겠습니까?"
- 빈 캔버스 시 즉시 적용
- **적용**:
- 점들을 `name === 'outerLine'` QLine 객체들로 생성하여 캔버스에 add
- `setOuterLinePoints(points)` (recoil `outerLinePointsState`)
- `addPolygonByLines(lines)` 흐름으로 QPolygon 생성 (기존 `useRoofShapeSetting` 자산 재사용)
- `canvas.renderAll()`
---
## 7. 검증 / 엣지 케이스
| 케이스 | 대응 |
|---|---|
| PDF 가 아닌 파일 | 라우트에서 MIME 거부 |
| 평면도가 아닌 PDF | 모델이 빈 outerline 반환 → 친화적 메시지 |
| 좌표 ≤ 2개 / 자기교차 / 0면적 | 라우트에서 거부 (`SizeSetting` 가드와 일관) |
| 모델 JSON 파싱 실패 | retry 1회 후 실패 메시지 |
| Gemini 호출 실패 | finally 에서 File API 삭제 보장 |
| 캔버스 기존 외곽선 존재 | confirm 후 덮어쓰기 |
| 응답이 거대한 폴리곤(>500 vertex) | DouglasPeucker 단순화 또는 거부 |
| 업로드 파일 크기 초과 | 클라이언트/서버 양쪽 가드 |
| 네트워크 실패 | 모달에 재시도 버튼 + 파일 폐기 보장 |
---
## 8. 컨벤션 / 운영
- Prettier: single quote, **no semicolons**, tabWidth 2, printWidth 150
- 메시지/모달: `useSwal` + `useMessage` (raw `alert()` 금지)
- 다국어: `ja/ko` 양쪽 키 추가 (`pdf.import.*`)
- 경로 alias: `@/...` 우선
- 로깅: `src/util/logger.js` 만 사용
### 커밋 단위
1. `[xxxx] feat(api) — Gemini 평면도 분석 라우트 신설`
2. `[xxxx] feat(floor-plan) — PDF 임포트 모달/툴바 진입`
3. `[xxxx] feat(floor-plan) — 분석 결과 외곽선 캔버스 반영`
4. `[xxxx] chore — 다국어 메시지/lint 정리`
마지막에 lint/type/build subagent 점검.
---
## 9. 작업 순서 (To-Do)
1. `@google/generative-ai` 의존성 추가, `GEMINI_API_KEY`/`GEMINI_MODEL` env·config 매핑
2. 서버 라우트 `src/app/api/gemini/floor-plan/route.js` 작성 (Gemini 호출 + 파일 폐기)
3. `menusState``pdf-import` 항목 + PDF 아이콘 SVG 추가
4. `CanvasMenu.jsx` `onClickNav` switch 에 케이스 추가, `modalAtom` 으로 모달 오픈
5. `PdfImportModal.jsx` 작성 (idle→uploading→analyzing→success/error 상태머신)
6. `usePdfImport.js` 작성 (단위/방향 정규화, 외곽선 덮어쓰기 분기, QLine/QPolygon 반영)
7. 다국어 메시지 키 추가 (ja/ko)
8. `yarn lint` + dev에서 실제 PDF 한두 건 manual test
9. 의미 단위로 커밋

View File

@ -14,11 +14,13 @@
"start:cluster1": "env-cmd -f .env.production next start -p 5000",
"start:cluster2": "env-cmd -f .env.production next start -p 5001",
"start:dev": "env-cmd -f .env.development next start -p 5010",
"start:btob": "env-cmd -f .env.development next start -p 5060",
"lint": "next lint",
"serve": "node server.js"
},
"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)
@ -297,7 +419,7 @@ export default function PlacementShapeSetting({ id, pos = { x: 50, y: 180 }, pla
/** 지붕면 존재 여부에 따라 메뉴 설정 */
const hasRoofs = canvas.getObjects().some((obj) => obj.name === POLYGON_TYPE.ROOF)
// roofSizeSet
if (currentRoof?.roofSizeSet === '2') {
setSelectedMenu('surface')
@ -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

2
startscript-btob.js Normal file
View File

@ -0,0 +1,2 @@
var exec = require('child_process').exec
exec('yarn start:btob', { windowsHide: true })