Compare commits

..

4 Commits

Author SHA1 Message Date
sangwook yoo
2e7c8769aa ```
docs(outerline): 외벽선 확정(handleFix) 흐름 및 PDF 자동 확정 가이드 문서 추가
```
- 외벽선 확정 (`handleFix`) 로직의 상세 흐름과 확정 후 캔버스/Recoil 불변 조건을 문서화합니다.
- PDF 도면 분석을 통한 외벽선 자동 확정 기능 구현 시 참고할 설계 가이드와 체크리스트를 포함합니다.
- `handleFix` 본체와 `useEffect`, `ccwCheck` 등 관련 로직의 역할 및 주의사항을 명확히 합니다.
```
2026-06-22 10:48:29 +09:00
sangwook yoo
dbaebf9e2b feat(pdf-import): 외벽선 자동 확정 토글 동작 연결 — ON 확정·지붕형상 직행 / OFF 미확정 유지
- usePdfImport.applyOuterline 에 autoFix 옵션 추가 — outerLineFix 확정 여부를 토글에 따라 분기
- PlacementShapeSetting.handleSaveBtn 에서 토글값으로 메뉴 분기(ON→ROOF_SHAPE_SETTINGS / OFF→EXTERIOR_WALL_LINE); 모달은 MenuDepth01 이 currentMenu 변화로 자동 오픈하므로 직접 addPopup 제거
- fix: basicSettingSave 의 setMenuByRoofSize 를 skipSideEffects 가드 안으로 이동 — ON 인데도 async 저장(await post) 직후 메뉴가 EXTERIOR_WALL_LINE 로 덮어써져 외벽선 작성 팝업으로 되돌아가며 확정이 풀리던 문제 수정
- docs(claude): PDF 임포트 절에 자동 확정 분기·MenuDepth01 메뉴 연동·skipSideEffects 주의 반영

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-21 23:53:25 +09:00
sangwook yoo
fafee78c8a feat(pdf-import): 외벽선 자동 확정(auto fix) 토글 추가 — 전역 옵션·기본 ON
배치면 초기설정 PDF 입력 화면에 '외벽선 자동 확정' 토글을 추가한다.
- autoOuterLineFixState 전역 atom 신설(기본값 true), outerLineFixState 와 별개의 옵션 플래그
- ko/ja i18n 라벨·설명 키 추가
- toggle-btn 스위치를 읽기 페이지 설정 행 아래 배치, 분석 중에는 비활성화

실제 PDF 자동 확정 동작 연결(usePdfImport)은 별도 작업으로 분리.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-21 21:34:55 +09:00
sangwook yoo
ce295dd8ff feat(pdf-import): 지정 페이지만 분할·인라인 전송으로 분석 속도 개선
- '읽기 페이지 설정'의 평면도/입면도 페이지 번호가 프롬프트 힌트로만 쓰이고
  PDF 전체가 Gemini 로 전송되던 문제 해결 — pdf-lib 로 지정 페이지만 잘라 전송
- 분할본이 12MB 이하면 inlineData(base64) 즉시 전송 → Files API 업로드·폴링 제거,
  초과 시에만 Files API 로 fallback
- 분할 시 buildPrompt 를 페이지번호 힌트 대신 평면도/입면도 순서 매핑으로 전환
- 유효 페이지 0개 → 422 INVALID_PAGE_RANGE, 분할 실패 → 422 PDF_SPLIT_FAILED
- 설계 문서 추가(docs/superpowers/specs/2026-06-21-pdf-page-split-design.md)

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-21 20:49:11 +09:00
11 changed files with 533 additions and 34 deletions

View File

@ -56,7 +56,7 @@ A modal under `components/floor-plan/modal/<feature>` pairs with a hook under `h
**Plan persistence** (serialize/restore) is in `src/lib/canvas.js` and `src/util/canvas-util.js`. Recent commits (`a5e794b3`, `2aef32e0`) added a three-layer guard (entry / cache / save) and `SizeSetting` input guards against 0-area apertures/shadows/dormers — when touching plan load/save or size inputs, do not weaken or remove these guards without understanding the failure mode (`InvalidStateError` from corrupted plans). **Plan persistence** (serialize/restore) is in `src/lib/canvas.js` and `src/util/canvas-util.js`. Recent commits (`a5e794b3`, `2aef32e0`) added a three-layer guard (entry / cache / save) and `SizeSetting` input guards against 0-area apertures/shadows/dormers — when touching plan load/save or size inputs, do not weaken or remove these guards without understanding the failure mode (`InvalidStateError` from corrupted plans).
**PDF 도면 임포트** — 배치면 초기설정 모달의 PDF 입력 모드 → `src/app/api/gemini/floor-plan/route.js`(Gemini 분석, 세션 인증 + 사용자별 분당 5회 레이트리밋) → `src/hooks/pdf-import/usePdfImport.js`. 임포트는 수동 외벽선 확정(`useOuterLineWall.handleFix`) 직후와 동일한 상태를 만들어야 한다: `useLine.addLine` 경유 outerLine 생성(attributes/planeSize/fontSize), `outerLinePoint` 정점 circle, `canvas.outerLineFix = true` + `outerLineFixState`, 이후 지붕형상 설정 메뉴 직행. **좌표에 축소 scale 을 곱하지 말 것** — 'QLine 길이×10 = 표시 mm' 불변식이 깨진다. 화면맞춤은 `viewportTransform` 줌 + `canvasZoomState` 동기화로만 처리한다. **PDF 도면 임포트** — 배치면 초기설정 모달의 PDF 입력 모드 → `src/app/api/gemini/floor-plan/route.js`(Gemini 분석, 세션 인증 + 사용자별 분당 5회 레이트리밋) → `src/hooks/pdf-import/usePdfImport.js`. 임포트는 **외벽선 자동 확정(`autoOuterLineFixState`, 기본 ON) 옵션**에 따라 분기한다(`PlacementShapeSetting.handleSaveBtn` → `applyOuterline(mmPoints, { autoFix })`). **ON**: 수동 외벽선 확정(`useOuterLineWall.handleFix`) 직후와 동일한 상태를 만든다 — `useLine.addLine` 경유 outerLine 생성(attributes/planeSize/fontSize), `outerLinePoint` 정점 circle, `canvas.outerLineFix = true` + `outerLineFixState`. **OFF**: 외벽선만 그려둔 미확정(`outerLineFix=false`) 상태로 둔다. **메뉴/모달 전환**: `handleSaveBtn``setSelectedMenu('outline')` + `setCurrentMenu(ON→ROOF_SHAPE_SETTINGS / OFF→EXTERIOR_WALL_LINE)` 만 하면 된다 — `MenuDepth01``currentMenu` 변화를 감지해 `handleMenu` 로 해당 모달(RoofShapeSetting / WallLineSetting)을 자동으로 띄운다. **직접 `addPopup` 하지 말 것**(MenuDepth01 의 `closeAll` 과 충돌). ⚠️ `basicSettingSave({ skipSideEffects:true })` 는 메뉴를 건드리지 않아야 한다 — `setMenuByRoofSize``if (!skipSideEffects)` 밖에 있으면 async 저장(`await post`) 직후 `currentMenu``EXTERIOR_WALL_LINE` 로 덮어써, ON 인데도 WallLineSetting 으로 되돌아가 `useOuterLineWall``outerLineFix` 를 풀어버린다(2026-06-21 버그). **좌표에 축소 scale 을 곱하지 말 것** — 'QLine 길이×10 = 표시 mm' 불변식이 깨진다. 화면맞춤은 `viewportTransform` 줌 + `canvasZoomState` 동기화로만 처리한다.
### State — Recoil, split by domain ### State — Recoil, split by domain

View File

@ -0,0 +1,99 @@
# PDF 도면 임포트 — 지정 페이지 분할 + 인라인 전송 설계
- 날짜: 2026-06-21
- 브랜치: feature/pv-btob
- 상태: 승인됨 (구현 대기)
- 관련 파일: `src/app/api/gemini/floor-plan/route.js`, `src/components/floor-plan/modal/placementShape/PlacementShapeSetting.jsx`
## 배경 / 문제
배치면 초기설정의 "읽기 페이지 설정"에서 평면도/입면도 페이지를 지정할 수 있으나, 현재 페이지 번호는 **프롬프트 텍스트 힌트로만** 쓰이고 PDF 는 **전체가 Gemini 에 전송**된다(`buildPrompt` 에만 사용, `file` 버퍼 전체를 `uploadFile`). 따라서:
- Gemini 가 전 페이지를 비전 토큰화 → 입력 토큰·추론 시간·비용이 페이지 수에 비례(`mediaResolution: HIGH` 로 페이지당 토큰이 큼).
- 페이지 지정은 정확도엔 도움이 되나 속도엔 거의 효과가 없다.
## 목표
지정한 페이지만 실제로 잘라서 전송하여 분석 속도·비용을 절감한다. 추가로 Files API 업로드 + 처리 폴링(최대 60초 대기) 단계를 인라인 전송으로 제거한다.
## 범위
### In scope
- `specify` 모드에서 지정한 평면도 + 입면도 페이지만 `pdf-lib` 로 추출한 새 PDF 생성.
- 분할본을 `inlineData`(base64) 로 Gemini 에 전송 — Files API 업로드·폴링·`deleteFile` 제거.
- 페이지 번호 파싱(`"1,3-5"`, 1-based) 및 PDF 범위 검증.
- 분할 시 프롬프트를 "페이지 번호 힌트" → "평면도/입면도 순서 매핑"으로 전환.
### Out of scope (후속 작업)
- **입면도를 지붕 형상 판별에 실제 활용** — 응답 스키마에 높이/지붕형태 필드 추가, 프롬프트 재설계, 결과를 캔버스 지붕형상에 연결하는 별도 작업. 이번엔 입면도 **페이지를 전송**하는 데까지만(토대 마련).
- 서버 에러 메시지 i18n (기존 잔여 과제, 기존 한국어 하드코딩 패턴 유지).
- 클라이언트 측 PDF 가공/미리보기.
## 설계
### 데이터 흐름
클라이언트(`PlacementShapeSetting.jsx`) — **변경 없음**: 기존대로 `pageMode`/`floorPages`/`facadePages` 텍스트 + 원본 PDF 전체를 multipart 로 전송.
서버(`route.js`):
```
formData 수신 (file, pageMode, floorPages, facadePages)
├─ pageMode === 'specify' AND (floorPages 또는 facadePages 비어있지 않음)
│ ├─ pdf-lib 로 원본 로드, getPageCount()
│ ├─ floorPages/facadePages 파싱 → 1-based → 0-based 인덱스
│ ├─ PDF 범위로 검증, 유효 인덱스만 (평면도 먼저, 입면도 뒤, 그룹 내 오름차순·중복제거)
│ ├─ 유효 페이지 0개 → 422 INVALID_PAGE_RANGE
│ ├─ 분할 중 예외(손상 PDF 등) → 422 PDF_SPLIT_FAILED
│ └─ 유효 페이지 ≥1 → copyPages 로 새 PDF 생성 → splitBuffer
└─ else (all 모드 또는 입력 둘 다 빈값) → 원본 buffer 사용
전송 PDF(splitBuffer 또는 원본) 크기 판정:
├─ raw 크기 ≤ INLINE_MAX_BYTES → inlineData(base64) 경로
└─ 초과 → Files API 경로 (기존: uploadFile → waitForFileActive → fileData)
generateContent([ <pdf part>, { text: prompt } ])
```
### 페이지 번호 파싱
- 형식: 쉼표 구분 + 하이픈 범위. 예) `"1,3-5"``[1,3,4,5]`. **1-based**.
- 공백/빈 토큰/숫자 아닌 토큰은 무시.
- `floorPages`, `facadePages` 각각 파싱 → PDF 총 페이지 수로 필터 → 0-based 변환.
- 분할본 페이지 순서: `[...floorIdx, ...facadeIdx]`(평면도 먼저). 각 그룹 내 오름차순·중복 제거, 그룹 간 동일 페이지가 겹치면 평면도 우선.
### 인라인 / Files API 분기
- `INLINE_MAX_BYTES` 상수: 요청 총 20MB 한도 + base64 약 +33% 를 감안해 보수적으로 raw 기준 약 12MB.
- 분할본은 거의 항상 한도 이하 → 대부분 인라인. 초과(고해상도 스캔 등) 시 Files API 로 자동 fallback(기존 경로 재사용).
- 인라인 경로: `waitForFileActive`/`deleteFile` 불필요(임시 파일 없음).
### 프롬프트 보정
- `buildPrompt` 에 분할 컨텍스트 전달: `{ splitMode: true, floorCount, facadeCount }`.
- 분할 시: 페이지 번호 힌트 대신 순서 매핑 안내. 예)
> "첨부 PDF 는 평면도/지붕평면도 {floorCount}장과 입면도 {facadeCount}장으로 구성됩니다(평면도가 앞). 외곽선은 평면도/지붕평면도에서만 추출하고 입면도는 사용하지 마세요."
- `facadeCount === 0` 이면 입면도 문구 생략.
- 비분할(all/fallback) 경로는 기존 `buildPrompt` 동작 유지.
### 에러 처리 (정책 A — 명시적 에러)
- 유효 페이지 0개(범위 밖 번호만 입력) → `422 { code: 'INVALID_PAGE_RANGE' }`. 클라이언트는 기존 `payload.error.message` 분기로 표시.
- 분할 단계 예외 → `422 { code: 'PDF_SPLIT_FAILED' }`. 조용한 전체 fallback 하지 않음(A 정책 일관).
- 입력 둘 다 빈값 + `specify``all` 처럼 전체 전송(자연스러운 기본값, 에러 아님).
### 의존성
- `pdf-lib` 추가(`yarn add pdf-lib`). 서버 라우트에서만 import. 순수 JS → `next.config.mjs` externals 수정 불필요.
## 변경 파일
- `src/app/api/gemini/floor-plan/route.js` — 분할·인라인·프롬프트 분기(핵심).
- `package.json``pdf-lib` 의존성.
- 클라이언트 변경 없음.
## 검증 (테스트 러너 없음 → 수동)
- `NEXT_PUBLIC_ENABLE_LOGGING=true``timing`(fileReady/generate) 로그 확인 — 인라인 경로에서 fileReady 단계 소멸 확인.
- 시나리오: (a) 평면도 1p 지정 (b) 평면도+입면도 지정 (c) 범위 밖 페이지 → 422 (d) all 모드 회귀 (e) 대용량 → Files API fallback.
- `yarn lint`, `yarn build` 통과(eslint 미설치 시 build 가 lint 를 스킵하는 점 주의).
## 위험 / 완화
- 인라인 한도 초과 → Files API fallback 으로 흡수.
- 분할본 순서와 프롬프트 매핑 불일치 → 순서를 `[평면도, 입면도]` 로 고정하고 프롬프트도 동일 순서로 명시.
- 입면도가 외곽선 추출을 오염 → 프롬프트에서 "입면도 미사용" 명시(기존 `BASE_PROMPT` 에도 존재).

View File

@ -0,0 +1,284 @@
# 외벽선 확정 (`handleFix`) 흐름
> Q.CAST Floor Plan — **지붕덮개 → 외벽선 그리기** 모달의 **「외벽선 확정」** 버튼이 호출하는 로직 정리.
> PDF/Gemini 도면 분석 후 캔버스에 외벽선을 그린 뒤, 옵션에 따라 이 확정 단계를 자동 실행할 때 참고한다.
코드 단일 진실 공급원:
| 역할 | 경로 |
|---|---|
| UI (버튼) | `src/components/floor-plan/modal/outerlinesetting/WallLineSetting.jsx` |
| 핵심 로직 | `src/hooks/roofcover/useOuterLineWall.js``handleFix`, `ccwCheck`, `points` `useEffect` |
| 상태 | `src/store/outerLineAtom.js``outerLinePointsState`, `outerLineFixState` |
| 라인 생성 | `src/hooks/useLine.js``addLine` (QLine + `attributes.planeSize`) |
| PDF 임포트 대응 구현 | `src/hooks/pdf-import/usePdfImport.js``applyOuterline` |
| 확정 후 다음 단계 | `src/components/floor-plan/modal/roofShape/RoofShapeSetting.jsx` |
---
## 0. 한눈에 보기
```
[외벽선 그리기 메뉴] → WallLineSetting 모달
↓ (클릭/Enter)
SweetAlert 확인 (modal.cover.outline.fix.confirm)
↓ confirm
handleFix() — 닫힘 정점 추가 + isFix.current = true
↓ (points useEffect)
outerLine QLine 재생성 + outerLinePoint circle
↓ isFix.current === true
outerLineFix 플래그 ON → 모달 닫기 → ccwCheck → 지붕형상 설정 모달 오픈
```
**확정 직후 캔버스/Recoil이 만족해야 하는 불변 조건** (PDF 자동 확정도 동일):
1. `canvas` 객체 중 `name === 'outerLine'` 인 QLine ≥ 2 (닫힌 다각형)
2. `name === 'outerLinePoint'` 인 fabric.Circle (정점, `visible: false`)
3. `canvas.outerLineFix === true` **및** Recoil `outerLineFixState === true`
4. 외벽선 정점 순서는 **반시계(CCW)**`ccwCheck` 또는 동등 전처리
5. QLine 은 반드시 `useLine.addLine` 경유 — `attributes.planeSize`, `fontSize` 없으면 지붕형상·처마/케라바·저장 복원 깨짐
6. 좌표에 **축소 scale 을 곱하지 않음**`QLine.length × 10 = 표시 mm` 불변식 유지
---
## 1. UI 진입점
### 1-1. 모달·버튼
- 컴포넌트: `WallLineSetting` (`WithDraggable`, class `r`)
- 메뉴 진입: `useMenu``currentMenu === EXTERIOR_WALL_LINE``addPopup(..., <WallLineSetting />)`
- 버튼 DOM: `div.grid-btn-wrap > button.btn-frame.modal.act`
- i18n 키: `modal.cover.outline.fix` (ko: 「외벽선 확정」)
### 1-2. 클릭 핸들러
확정 버튼은 **즉시** `handleFix` 를 부르지 않고, 확인 다이얼로그를 거친다.
```jsx
// WallLineSetting.jsx
swalFire({
type: 'confirm',
text: getMessage('modal.cover.outline.fix.confirm'),
confirmFn: handleFix,
})
```
동일 확인은 `useOuterLineWall.enterCheck` (Enter 키) 에서도 사용한다.
> **자동 확정 구현 시**: 사용자 확인이 필요 없으면 `swalFire` 를 생략하고 `handleFix` 또는 아래 §5 의 「확정 후처리」만 직접 호출하면 된다.
---
## 2. `handleFix` 본체
파일: `useOuterLineWall.js`
### 2-1. 사전 조건·검증
| 단계 | 조건 | 실패 시 |
|---|---|---|
| 1 | `points.length >= 3` | `return` (무발화) |
| 2 | 모든 꼭짓점이 첫 점 기준 직각(각도 % 90 === 0) | `return` — 축정렬 직사각형만으로 닫힌 경우 확정 불가 |
검증 통과 후:
```javascript
setPoints((prev) => [...prev, { x: prev[0].x, y: prev[0].y }]) // 폐곡선: 마지막 = 첫 점
isFix.current = true
```
- `points` 는 Recoil `outerLinePointsState`
- `isFix.current` 는 ref — **다음 `points` useEffect 한 번**에서만 소비된다 (자동으로 false 로 리셋되지 않음; 훅 unmount 전까지 true 유지)
### 2-2. `handleFix` 가 직접 하지 **않는**
다음은 모두 **`useEffect([points])`** 와 **`ccwCheck`** 에 위임된다.
- Fabric `outerLine` / `outerLinePoint` 생성
- `outerLineFixState` / `canvas.outerLineFix` 설정
- 마우스·키보드 이벤트 해제
- `WallLineSetting` 모달 닫기
- `RoofShapeSetting` 모달 열기
---
## 3. `points` 변경 시 useEffect (확정의 실질 후처리)
`points` 가 바뀔 때마다 실행된다. 확정 **이전**에도 그리기 미리보기 역할을 한다.
### 3-1. 공통 (매 `points` 변경)
1. 기존 `outerLine`, `helpGuideLine` 제거 (`removeLine`)
2. `startPoint` 제거
3. `points.length === 0``setOuterLineFix(true)` 후 return (초기화 경로)
4. `points.length === 1` → 시작점 빨간 circle (`startPoint`)
5. `points.length >= 2`:
- `setOuterLineFix(false)` — **그리는 중에는 미확정**
- `outerLinePoint` circle 생성 (`visible: false`)
- 인접 정점 쌍마다 `drawLine``addLine` (`name: 'outerLine'`, `strokeWidth: 3`, …)
- 3점 이상이면 닫힘 가이드 (`helpGuideLine`, 회색 점선)
### 3-2. 확정 분기 (`isFix.current === true`)
```javascript
if (isFix.current) {
removeAllMouseEventListeners()
removeAllDocumentEventListeners()
canvas?.renderAll()
setOuterLineFix(true)
canvas.outerLineFix = true
closePopup(id) // WallLineSetting 닫기
ccwCheck() // §4
addPopup(propertiesId, 1, <RoofShapeSetting id={propertiesId} />)
}
```
**수동 플로우 확정 후 사용자가 보는 화면**: 외벽선 그리기 모달 종료 → **지붕형상 설정** 모달 자동 오픈.
---
## 4. `ccwCheck` — 정점 순서 보정
확정 직후 호출. 외벽선이 **시계 방향**이면 **반시계**로 뒤집는다.
1. `canvas.getObjects()` 에서 `name === 'outerLine'` 필터
2. `outerLines.length < 2``wall.line.not.found` 알림 후 return
3. Shoelace signed area 로 CCW 판별 (`signedArea > 0` → CW)
4. CW 이면 각 라인을 `[x2,y2,x1,y1]`**역방향 재생성** (`addLine` + 기존 line `remove`)
PDF 임포트(`usePdfImport.ensureCounterClockwise`)는 **정점 배열** 단계에서 동일 목적을 수행한다. 캔버스에 이미 그려진 뒤라면 `ccwCheck` 와 결과가 같아야 한다.
---
## 5. PDF 자동 확정 — `handleFix` 와의 대응
현재 `usePdfImport.applyOuterline`**수동 확정 직후 캔버스 상태**까지는 맞추지만, **`handleFix` 전체를 호출하지는 않는다**.
| 수동 `handleFix` 후 | `applyOuterline` 현재 | 자동 확정 옵션 추가 시 |
|---|---|---|
| SweetAlert 확인 | PDF 덮어쓰기 확인만 | 옵션으로 생략/유지 선택 |
| `points` 에 닫힘점 추가 | `closedPoints = [...pts, pts[0]]` + `setOuterLinePoints` | 동일 |
| `useEffect` 로 QLine 재그리기 | `drawOuterLines` (`addLine` 동일 옵션) | 동일 |
| `outerLinePoint` circle | `drawOuterLinePoints` | 동일 |
| `canvas.outerLineFix` + `outerLineFixState` | ✅ 설정함 | ✅ 필수 |
| `ccwCheck` | `ensureCounterClockwise` (정점 단계) | 라인 그린 뒤 CW 이면 `ccwCheck` 또는 정점 보정 |
| `closePopup(WallLineSetting)` | 해당 모달 없음 | 불필요 |
| `RoofShapeSetting` 모달 오픈 | ❌ **안 함** | 옵션 `autoFix: true``addPopup` 으로 직행 |
| `fitViewport` + `canvasZoomState` | ✅ PDF 전용 | 수동 확정에는 없음 — PDF 유지 |
| 마우스 리스너 제거 | 그리기 모드 미사용 | 불필요 |
`PlacementShapeSetting` PDF 분기는 메뉴만 `ROOF_SHAPE_SETTINGS` 로 보내고, **지붕형상 모달 자동 오픈은 하지 않는다** — 자동 확정 옵션은 별도 구현이 필요하다.
참고: `CLAUDE.md` PDF 임포트 절 — 「수동 외벽선 확정(`useOuterLineWall.handleFix`) 직후와 동일한 상태」.
---
## 6. 자동 확정 구현 가이드 (권장)
### 6-1. `handleFix` 를 그대로 호출하지 **말**
`handleFix``useOuterLineWall` 훅 내부에 묶여 있고, 전제 조건이 다르다.
- Recoil `outerLinePointsState`**미닫힌** 정점 배열이어야 함 (마지막 ≠ 첫 점)
- `WallLineSetting` 모달이 마ounted 되어 `propertiesId``RoofShapeSetting` 을 열어야 함
- 직각-only 다각형이면 조용히 실패
PDF 경로는 이미 **닫힌 폴리곤 + 라인 객체**가 있으므로, 훅 밖에서 후처리하는 편이 안전하다.
### 6-2. 권장: 공통 「확정 후처리」 추출 (미래 리팩터)
아래를 `finalizeOuterLine({ canvas, points, options })` 같은 유틸/훅으로 묶으면 수동·PDF·자동 옵션이 한 경로를 탄다.
```text
입력: closedPoints (첫점=끝점 포함 가능, drawOuterLines 기준 인접쌍)
1. clearExisting outerLine / outerLinePoint (필요 시)
2. drawOuterLines(closedPoints) // useLine.addLine
3. drawOuterLinePoints(closedPoints)
4. CCW 보정 (정점 또는 ccwCheck)
5. canvas.outerLineFix = true; setOuterLineFix(true)
6. canvas.renderAll()
7. [옵션] fitViewport
8. [옵션] addPopup(RoofShapeSetting)
```
### 6-3. 최소 변경 (현재 코드베이스)
`applyOuterline``proceed()` 끝에 옵션 분기만 추가:
```javascript
// options.autoOpenRoofShape === true 일 때
const roofShapeId = uuidv4()
addPopup(roofShapeId, 1, <RoofShapeSetting id={roofShapeId} pos={{ x: 50, y: 230 }} />)
setCurrentMenu(MENU.ROOF_COVERING.ROOF_SHAPE_SETTINGS) // 메뉴 탭 동기화
```
`usePopup` / `useMenu` 의존은 `usePdfImport` 또는 호출측(`PdfImportModal`, `PlacementShapeSetting`)에서 주입.
### 6-4. 검증 체크리스트 (자동 확정 후)
- [ ] `canvas.getObjects().filter(o => o.name === 'outerLine').length >= 3`
- [ ] 각 QLine 에 `attributes.planeSize` 존재
- [ ] `canvas.outerLineFix === true`
- [ ] `useRoofShapeSetting` 마운트 시 `wall.line.not.found` 미발생
- [ ] 치수 라벨(mm)이 실측과 일치 (scale 미적용)
- [ ] 외벽선 그리기 메뉴 재진입 시 `setOuterLineFix(false)` 로 **재편집 모드**로 떨어지지 않도록 — PDF 플로우는 `ROOF_SHAPE_SETTINGS` 메뉴 유지 (`PlacementShapeSetting` 참고)
---
## 7. 관련 Recoil / Canvas 플래그
| 키 | 타입 | 확정 전 (그리기 중) | 확정 후 |
|---|---|---|---|
| `outerLinePointsState` | `{x,y}[]` | 미닫힘 정점들 | 닫힘(마지막=첫점) 또는 동일 좌표의 closed ring |
| `outerLineFixState` | `boolean` | `false` (2점 이상) | `true` |
| `canvas.outerLineFix` | `boolean` (canvas 커스텀) | `false` | `true` — 직렬화/가드에 사용 (`common.js` `'outerLineFix'`) |
`canvasAtom` 의 selector 는 `outerLineFix === false` 이거나 points 비어 있으면 특정 UI를 잠근다.
---
## 8. 확정 이후 파이프라인
```
handleFix 완료
→ RoofShapeSetting (useRoofShapeSetting)
→ WALL/ROOF 폴리곤 생성, pitchText, 처마/케라바 타입
→ (이후) 보조선, 처마·케라바 편집, 배치면 …
```
`useRoofShapeSetting` 진입 가드:
```javascript
if (!canvas.outerLineFix || outerLines.length === 0) {
swalFire({ text: getMessage('wall.line.not.found') })
closePopup(id)
}
```
자동 확정 없이 `outerLineFix` 만 켜 두면 메뉴는 지붕형상으로 가도 **모달을 열어 주지 않으면** 사용자는 수동으로 메뉴를 눌러야 한다.
---
## 9. 유사 이름 주의 (`handleFix` 동명이인)
| 훅 | 용도 |
|---|---|
| **`useOuterLineWall.handleFix`** | **본 문서 대상** — 외벽선 그리기 확정 |
| `usePropertiesSetting.handleFix` | 외벽선 **속성**(처마/케라바) 확정 — 다른 모달 |
| `usePlacementShapeDrawing.handleFix` | 배치면 그리기 확정 |
| `useRoofLinePropertySetting.handleFix` | 지붕선 속성 확정 |
| `useAuxiliaryDrawing.handleFix` | 보조선 확정 |
DOM class `btn-frame modal act` + 「외벽선 확定/외벽선 확정」은 **`WallLineSetting`** 과 **`PlacementShapeDrawing`** 등에서 공유 메시지 키를 쓸 수 있다. DOM path `modal-pop-wrap r` + `useOuterLineWall` 이면 **본 문서** 대상이다.
---
## 10. 참고 문서·코드
- `docs/PDF Gemini 평면도 분석 기능 구현 계획.md` — PDF → 캔버스 반영 전체
- `docs/Canvas Status.md` — §2.1 외벽선 그리기, §2.2 지붕형상 설정
- `CLAUDE.md` — PDF 임포트 불변식 (scale, `addLine`, `outerLineFix`)
---
*작성: 2026-06-19 — PDF 자동 외벽선 확정 기능 설계용*

View File

@ -37,6 +37,7 @@
"mssql": "^11.0.1", "mssql": "^11.0.1",
"next": "14.2.28", "next": "14.2.28",
"next-international": "^1.2.4", "next-international": "^1.2.4",
"pdf-lib": "^1.17.1",
"react": "^18", "react": "^18",
"react-chartjs-2": "^5.2.0", "react-chartjs-2": "^5.2.0",
"react-colorful": "^5.6.1", "react-colorful": "^5.6.1",

View File

@ -3,6 +3,7 @@ import { cookies } from 'next/headers'
import { getIronSession } from 'iron-session' import { getIronSession } from 'iron-session'
import { GoogleGenerativeAI, SchemaType } from '@google/generative-ai' import { GoogleGenerativeAI, SchemaType } from '@google/generative-ai'
import { GoogleAIFileManager, FileState } from '@google/generative-ai/server' import { GoogleAIFileManager, FileState } from '@google/generative-ai/server'
import { PDFDocument } from 'pdf-lib'
import { sessionOptions } from '@/lib/session' import { sessionOptions } from '@/lib/session'
import { logger } from '@/util/logger' import { logger } from '@/util/logger'
@ -10,6 +11,9 @@ import { logger } from '@/util/logger'
const MAX_FILE_BYTES = 20 * 1024 * 1024 // 20MB const MAX_FILE_BYTES = 20 * 1024 * 1024 // 20MB
const MAX_VERTICES = 500 const MAX_VERTICES = 500
const DEFAULT_MODEL = 'gemini-3.1-pro-preview' const DEFAULT_MODEL = 'gemini-3.1-pro-preview'
// 인라인 전송 한도 — Gemini 요청 총 20MB 한도에서 base64(+~33%) 를 감안한 보수값.
// 초과 시 Files API 경로로 자동 fallback 한다.
const INLINE_MAX_BYTES = 12 * 1024 * 1024 // 12MB (raw)
// Gemini 호출은 건당 과금이므로 사용자별 호출 빈도를 제한한다(프로세스 단위 in-memory). // Gemini 호출은 건당 과금이므로 사용자별 호출 빈도를 제한한다(프로세스 단위 in-memory).
const RATE_LIMIT_WINDOW_MS = 60 * 1000 const RATE_LIMIT_WINDOW_MS = 60 * 1000
@ -92,7 +96,15 @@ const BASE_PROMPT = `당신은 일본 주택 건축 도면에서 "지붕 투영
"selectedDrawingType": string (선택) "selectedDrawingType": string (선택)
}` }`
const buildPrompt = ({ pageMode, facadePages, floorPages } = {}) => { const buildPrompt = ({ pageMode, facadePages, floorPages, splitInfo } = {}) => {
// 분할 전송: 잘린 PDF 는 페이지가 1,2,3… 으로 재번호되므로 원본 페이지 번호 힌트 대신 순서 매핑을 안내한다.
if (splitInfo) {
const segs = []
if (splitInfo.floorCount > 0) segs.push(`평면도/지붕평면도 ${splitInfo.floorCount}`)
if (splitInfo.facadeCount > 0) segs.push(`입면도 ${splitInfo.facadeCount}`)
const compo = `첨부 PDF 는 ${segs.join('과 ')}으로 구성됩니다(평면도가 앞).`
return `${BASE_PROMPT}\n\n${compo} 외곽선은 평면도/지붕평면도 페이지에서만 추출하고, 입면도 페이지는 외곽선 추출에 쓰지 말고 높이·층수 참고로만 사용하세요.`
}
if (pageMode === 'specify' && (facadePages || floorPages)) { if (pageMode === 'specify' && (facadePages || floorPages)) {
const hints = [] const hints = []
if (floorPages) hints.push(`평면도(平面図)·지붕평면도(屋根伏図) 페이지: ${floorPages}`) if (floorPages) hints.push(`평면도(平面図)·지붕평면도(屋根伏図) 페이지: ${floorPages}`)
@ -133,6 +145,37 @@ const validateOuterline = (outerline) => {
return { ok: true, empty: false } return { ok: true, empty: false }
} }
// "1,3-5" → [1,3,4,5] (1-based). 공백·빈 토큰·숫자 아닌 토큰은 무시한다.
const parsePageList = (str) => {
const out = []
if (!str) return out
for (const token of String(str).split(',')) {
const t = token.trim()
if (!t) continue
const range = t.split('-')
if (range.length === 2) {
const a = parseInt(range[0], 10)
const b = parseInt(range[1], 10)
if (Number.isInteger(a) && Number.isInteger(b)) {
for (let i = Math.min(a, b); i <= Math.max(a, b); i++) out.push(i)
}
} else {
const n = parseInt(t, 10)
if (Number.isInteger(n)) out.push(n)
}
}
return out
}
// 1-based 페이지 번호 배열을 PDF 범위 내 0-based 인덱스로. 범위 밖 제거, 오름차순·중복 제거.
const toValidIndices = (pages, total) => {
const set = new Set()
for (const p of pages) {
if (Number.isInteger(p) && p >= 1 && p <= total) set.add(p - 1)
}
return [...set].sort((a, b) => a - b)
}
const waitForFileActive = async (fileManager, fileName, { timeoutMs = 60000, intervalMs = 1500 } = {}) => { const waitForFileActive = async (fileManager, fileName, { timeoutMs = 60000, intervalMs = 1500 } = {}) => {
const started = Date.now() const started = Date.now()
let file = await fileManager.getFile(fileName) let file = await fileManager.getFile(fileName)
@ -195,15 +238,54 @@ export async function POST(req) {
const buffer = Buffer.from(await file.arrayBuffer()) const buffer = Buffer.from(await file.arrayBuffer())
const uploadStarted = Date.now() // 지정 페이지만 잘라낸다(specify + 페이지 입력 시). 입력이 둘 다 비어있으면 원본 전체를 그대로 보낸다.
const uploadResult = await fileManager.uploadFile(buffer, { let sendBuffer = buffer
let splitInfo = null
if (pageMode === 'specify' && (floorPages || facadePages)) {
let src
try {
src = await PDFDocument.load(buffer)
} catch (e) {
return NextResponse.json({ error: { code: 'PDF_SPLIT_FAILED', message: 'PDF 를 읽지 못했습니다.' } }, { status: 422 })
}
const total = src.getPageCount()
const floorIdx = toValidIndices(parsePageList(floorPages), total)
const floorSet = new Set(floorIdx)
// 평면도가 앞, 입면도는 뒤. 그룹 간 동일 페이지가 겹치면 평면도 우선.
const facadeIdx = toValidIndices(parsePageList(facadePages), total).filter((i) => !floorSet.has(i))
const order = [...floorIdx, ...facadeIdx]
if (order.length === 0) {
return NextResponse.json(
{ error: { code: 'INVALID_PAGE_RANGE', message: '지정한 페이지 번호가 PDF 범위를 벗어났습니다. 페이지 번호를 확인해주세요.' } },
{ status: 422 },
)
}
try {
const out = await PDFDocument.create()
const copied = await out.copyPages(src, order)
copied.forEach((p) => out.addPage(p))
sendBuffer = Buffer.from(await out.save())
} catch (e) {
return NextResponse.json({ error: { code: 'PDF_SPLIT_FAILED', message: 'PDF 페이지 분할에 실패했습니다.' } }, { status: 422 })
}
splitInfo = { floorCount: floorIdx.length, facadeCount: facadeIdx.length }
}
// 분할본은 대부분 인라인 한도 이하 → 업로드·폴링 없이 즉시 전송. 초과 시에만 Files API 로 fallback.
const fileStarted = Date.now()
let pdfPart
if (sendBuffer.length <= INLINE_MAX_BYTES) {
pdfPart = { inlineData: { data: sendBuffer.toString('base64'), mimeType: 'application/pdf' } }
} else {
const uploadResult = await fileManager.uploadFile(sendBuffer, {
mimeType: 'application/pdf', mimeType: 'application/pdf',
displayName: file.name || 'floor-plan.pdf', displayName: file.name || 'floor-plan.pdf',
}) })
uploadedFileName = uploadResult.file.name uploadedFileName = uploadResult.file.name
await waitForFileActive(fileManager, uploadedFileName) await waitForFileActive(fileManager, uploadedFileName)
const fileReadyMs = Date.now() - uploadStarted pdfPart = { fileData: { fileUri: uploadResult.file.uri, mimeType: uploadResult.file.mimeType } }
}
const fileReadyMs = Date.now() - fileStarted
const genAI = new GoogleGenerativeAI(apiKey) const genAI = new GoogleGenerativeAI(apiKey)
const model = genAI.getGenerativeModel({ const model = genAI.getGenerativeModel({
@ -217,18 +299,10 @@ export async function POST(req) {
}, },
}) })
const prompt = buildPrompt({ pageMode, facadePages, floorPages }) const prompt = buildPrompt({ pageMode, facadePages, floorPages, splitInfo })
const generateStarted = Date.now() const generateStarted = Date.now()
const result = await model.generateContent([ const result = await model.generateContent([pdfPart, { text: prompt }])
{
fileData: {
fileUri: uploadResult.file.uri,
mimeType: uploadResult.file.mimeType,
},
},
{ text: prompt },
])
const raw = result.response.text() const raw = result.response.text()
logger.debug('[gemini/floor-plan] timing(ms)', { fileReady: fileReadyMs, generate: Date.now() - generateStarted }) logger.debug('[gemini/floor-plan] timing(ms)', { fileReady: fileReadyMs, generate: Date.now() - generateStarted })

View File

@ -13,6 +13,7 @@ import { addedRoofsState, roofDisplaySelector, roofMaterialsAtom } from '@/store
import { useCommonCode } from '@/hooks/common/useCommonCode' import { useCommonCode } from '@/hooks/common/useCommonCode'
import QSelectBox from '@/components/common/select/QSelectBox' import QSelectBox from '@/components/common/select/QSelectBox'
import { globalLocaleStore } from '@/store/localeAtom' import { globalLocaleStore } from '@/store/localeAtom'
import { autoOuterLineFixState } from '@/store/outerLineAtom'
import { getChonByDegree, getDegreeByChon } from '@/util/canvas-util' import { getChonByDegree, getDegreeByChon } from '@/util/canvas-util'
import { usePolygon } from '@/hooks/usePolygon' import { usePolygon } from '@/hooks/usePolygon'
@ -107,6 +108,8 @@ export default function PlacementShapeSetting({ id, pos = { x: 50, y: 180 }, pla
const [pdfFacadePages, setPdfFacadePages] = useState('') const [pdfFacadePages, setPdfFacadePages] = useState('')
const [pdfFloorPages, setPdfFloorPages] = useState('') const [pdfFloorPages, setPdfFloorPages] = useState('')
const [pdfAnalyzing, setPdfAnalyzing] = useState(false) const [pdfAnalyzing, setPdfAnalyzing] = useState(false)
// (auto fix) ON
const [autoOuterLineFix, setAutoOuterLineFix] = useRecoilState(autoOuterLineFixState)
const { setIsGlobalLoading } = useContext(QcastContext) const { setIsGlobalLoading } = useContext(QcastContext)
const pdfInputRef = useRef(null) const pdfInputRef = useRef(null)
// PDF ( ) abort , // PDF ( ) abort ,
@ -239,7 +242,7 @@ export default function PlacementShapeSetting({ id, pos = { x: 50, y: 180 }, pla
return false return false
} }
const applied = await applyOuterline(payload.outerline, { unit: payload.unit || 'mm' }) const applied = await applyOuterline(payload.outerline, { unit: payload.unit || 'mm', autoFix: autoOuterLineFix })
if (!applied) { if (!applied) {
swalFire({ text: getMessage('pdf.import.error.apply'), type: 'alert' }) swalFire({ text: getMessage('pdf.import.error.apply'), type: 'alert' })
return false return false
@ -463,10 +466,16 @@ export default function PlacementShapeSetting({ id, pos = { x: 50, y: 180 }, pla
// roofSizeSet // roofSizeSet
if (inputMode === INPUT_MODE.PDF) { if (inputMode === INPUT_MODE.PDF) {
// PDF (outerLineFix)
// useOuterLineWall .
setSelectedMenu('outline') setSelectedMenu('outline')
if (autoOuterLineFix) {
// ON: (outerLineFix=true) .
// currentMenu MenuDepth01 handleMenu RoofShapeSetting .
setCurrentMenu(MENU.ROOF_COVERING.ROOF_SHAPE_SETTINGS) setCurrentMenu(MENU.ROOF_COVERING.ROOF_SHAPE_SETTINGS)
} else {
// OFF: (outerLineFix=false)
// MenuDepth01 WallLineSetting , [ ] . points .
setCurrentMenu(MENU.ROOF_COVERING.EXTERIOR_WALL_LINE)
}
} else if (currentRoof?.roofSizeSet === '2') { } else if (currentRoof?.roofSizeSet === '2') {
setSelectedMenu('surface') setSelectedMenu('surface')
setCurrentMenu(MENU.BATCH_CANVAS.BATCH_DRAWING) setCurrentMenu(MENU.BATCH_CANVAS.BATCH_DRAWING)
@ -633,6 +642,23 @@ export default function PlacementShapeSetting({ id, pos = { x: 50, y: 180 }, pla
</div> </div>
</td> </td>
</tr> </tr>
<tr>
<th>{getMessage('modal.placement.initial.setting.size.pdf.autofix.label')}</th>
<td>
<label className="toggle-btn">
<input
type="checkbox"
checked={autoOuterLineFix}
disabled={pdfAnalyzing}
onChange={(e) => setAutoOuterLineFix(e.target.checked)}
/>
<span className="slider"></span>
</label>
<div style={{ fontSize: '12px', color: '#bbb', marginTop: '4px' }}>
{getMessage('modal.placement.initial.setting.size.pdf.autofix.info')}
</div>
</td>
</tr>
</> </>
)} )}
<tr> <tr>

View File

@ -571,11 +571,11 @@ export function useCanvasSetting(executeEffect = true) {
roofSizeSet: String(params.roofSizeSet), roofSizeSet: String(params.roofSizeSet),
}) })
/** 메뉴 설정 */ /** skipSideEffects (PlacementShapeSetting.handleSaveBtn) .
//setMenuByRoofSize(params.roofSizeSet) * if 밖에서 항상 호출하던 과거 코드는 async 저장 직후(await post 이후) 메뉴를 EXTERIOR_WALL_LINE 덮어써,
} * PDF 자동 확정 외벽선 그리기 팝업으로 되돌아가 확정이 풀리는 버그를 유발했다. */
//메뉴를 찾아가지 못하는 오류 발생으로 변경
setMenuByRoofSize(params.roofSizeSet) setMenuByRoofSize(params.roofSizeSet)
}
/** 배치면초기설정 조회 */ /** 배치면초기설정 조회 */
fetchBasicSettings(params.planNo, 'basicSettingSave') fetchBasicSettings(params.planNo, 'basicSettingSave')

View File

@ -189,7 +189,7 @@ export function usePdfImport() {
return false return false
} }
const { unit = 'mm' } = options const { unit = 'mm', autoFix = true } = options
if (unit !== 'mm') { if (unit !== 'mm') {
logger.warn('[usePdfImport] 지원하지 않는 단위:', unit) logger.warn('[usePdfImport] 지원하지 않는 단위:', unit)
swalFire({ text: getMessage('pdf.import.error.unsupported.unit'), type: 'alert' }) swalFire({ text: getMessage('pdf.import.error.unsupported.unit'), type: 'alert' })
@ -227,11 +227,13 @@ export function usePdfImport() {
drawOuterLines(closedPoints) drawOuterLines(closedPoints)
drawOuterLinePoints(closedPoints) drawOuterLinePoints(closedPoints)
// 수동 확정(useOuterLineWall.handleFix) 직후와 동일한 상태로 만든다 — 이 플래그가 없으면 // 외벽선 자동 확정(autoFix) 옵션에 따라 확정 여부를 분기한다.
// - ON: 수동 확정(useOuterLineWall.handleFix) 직후와 동일하게 outerLineFix 를 켠다. 이 플래그가 없으면
// 지붕형상 설정(useRoofShapeSetting/useRoofShapePassivitySetting) 진입이 'wall.line.not.found' 로 차단된다. // 지붕형상 설정(useRoofShapeSetting/useRoofShapePassivitySetting) 진입이 'wall.line.not.found' 로 차단된다.
// WALL/ROOF 폴리곤은 수동 플로우와 동일하게 지붕형상 설정 적용 시점에 생성된다. // WALL/ROOF 폴리곤은 수동 플로우와 동일하게 지붕형상 설정 적용 시점에 생성된다.
canvas.outerLineFix = true // - OFF: 외벽선만 그려둔 미확정 상태로 두어, 사용자가 외벽선 그리기 단계에서 직접 확정하도록 한다.
setOuterLineFix(true) canvas.outerLineFix = autoFix
setOuterLineFix(autoFix)
fitViewport(closedPoints) fitViewport(closedPoints)
canvas.renderAll() canvas.renderAll()

View File

@ -42,6 +42,8 @@
"modal.placement.initial.setting.size.pdf.page.example.floor": "例) 3, 4", "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.all.info": "全ページを解析するため処理時間が長くなる場合があります。図面内の立面図·平面図ページが特定できる場合はページ指定を推奨します。",
"modal.placement.initial.setting.size.pdf.page.specify.info": "立面図·平面図が含まれるページ番号をそれぞれ入力してください。指定したページのみ読み込み素早く屋根を自動作成します。", "modal.placement.initial.setting.size.pdf.page.specify.info": "立面図·平面図が含まれるページ番号をそれぞれ入力してください。指定したページのみ読み込み素早く屋根を自動作成します。",
"modal.placement.initial.setting.size.pdf.autofix.label": "外壁線の自動確定",
"modal.placement.initial.setting.size.pdf.autofix.info": "オンにするとPDF解析後、外壁線を自動で確定し屋根形状設定へ進みます。",
"modal.placement.initial.setting.size.pdf.error.mime": "PDF ファイルのみアップロード可能です。", "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.too.large": "ファイルサイズが 20MB を超えています。",
"modal.placement.initial.setting.size.pdf.error.no.file": "先に PDF ファイルをアップロードしてください。", "modal.placement.initial.setting.size.pdf.error.no.file": "先に PDF ファイルをアップロードしてください。",

View File

@ -42,6 +42,8 @@
"modal.placement.initial.setting.size.pdf.page.example.floor": "예) 3, 4", "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.all.info": "모든 페이지를 분석하므로 처리 시간이 길어질 수 있습니다. 도면 내 입면도·평면도 페이지가 확인되면 페이지 지정을 권장합니다.",
"modal.placement.initial.setting.size.pdf.page.specify.info": "입면도·평면도가 포함된 페이지 번호를 각각 입력해주세요. 지정한 페이지만 읽어 빠르게 지붕을 자동 작성합니다.", "modal.placement.initial.setting.size.pdf.page.specify.info": "입면도·평면도가 포함된 페이지 번호를 각각 입력해주세요. 지정한 페이지만 읽어 빠르게 지붕을 자동 작성합니다.",
"modal.placement.initial.setting.size.pdf.autofix.label": "외벽선 자동 확정",
"modal.placement.initial.setting.size.pdf.autofix.info": "켜면 PDF 분석 후 외벽선을 자동으로 확정하고 지붕형상 설정 단계로 넘어갑니다.",
"modal.placement.initial.setting.size.pdf.error.mime": "PDF 파일만 업로드할 수 있습니다.", "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.too.large": "파일 크기가 20MB 를 초과합니다.",
"modal.placement.initial.setting.size.pdf.error.no.file": "PDF 파일을 먼저 업로드해 주세요.", "modal.placement.initial.setting.size.pdf.error.no.file": "PDF 파일을 먼저 업로드해 주세요.",

View File

@ -68,3 +68,12 @@ export const outerLineFixState = atom({
key: 'outerLineFixState', key: 'outerLineFixState',
default: false, default: false,
}) })
/**
* PDF 도면 임포트 외벽선 자동 확정(auto fix) 여부 전역 설정, 기본 ON
* (런타임 확정 상태인 outerLineFixState 와는 별개의 옵션 플래그)
*/
export const autoOuterLineFixState = atom({
key: 'autoOuterLineFixState',
default: true,
})