qcast-front/docs/superpowers/specs/2026-06-24-pdf-roof-shape-auto-detect-design.md
sangwook yoo 4e919923d8 docs(roof-shape): PDF 기반 지붕형상 자동 판별·자동 선택 설계 스펙 추가
[작업내용] :
- B: Gemini 스키마에 roofShape 추가 + 棟/軒先/立面図 판독 프롬프트 + normalizeRoofShape 순수함수
- C: pdfRoofShapeDescriptorState atom + pre-select only(setShapeNum, handleSave 미호출) + consume-once
- 제품결정: C=자동선택만(사용자 확인), B=立面図 활용
- RoofShapeDescriptor(direction 제외) 인터페이스, 매처가 B↔C 다리
- 2 플랜(B→C) 순차 구현

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-24 14:34:07 +09:00

167 lines
12 KiB
Markdown

# PDF 기반 지붕형상 자동 판별·자동 선택 — 설계 (Design Spec)
- 일자: 2026-06-24
- 브랜치: `feature/pv-btob`
- 상태: 설계 확정 (구현 대기)
- 선행: `2026-06-24-roof-shape-pattern-catalog-design.md`(8패턴 카탈로그) + `2026-06-24-roof-shape-pattern-matcher-design.md`(매처 §5 계약, 커밋 bcdd630a)
## 1. 목적 / 배경
PDF 도면 분석 → 외벽선 자동확정 팝업 → 지붕형상 설정으로 이어지는 흐름에서, **분석된 도면으로부터 지붕 형상을 유추해 지붕형상 설정 팝업(`RoofShapeSetting`)의 8패턴 버튼 중 매칭되는 것을 자동 선택(pre-select)**한다. 사용자는 미리 선택된 버튼을 검토 후 [적용]을 직접 누른다.
이미 완료된 선행: 8패턴 카탈로그(`src/common/roofShapePattern.js`), 순수 매칭 함수 `matchRoofShapePattern(descriptor) → shapeNum | null`. 본 스펙은 남은 두 조각을 다룬다.
- **B**: Gemini 분석을 확장해 `RoofShapeDescriptor`를 산출(屋根伏図의 棟/軒先/ケラバ + 立面図 보조 → type/ridge/flows).
- **C**: descriptor → 매처 → 매칭 버튼 자동 선택(pre-select only) 배선.
`RoofShapeDescriptor`(매처 스펙 §5)가 B·C의 인터페이스이고, `matchRoofShapePattern`이 다리다.
### 제품 결정 (2026-06-24, 사용자 확정)
- **C 적용 범위 = 자동 선택만(pre-select)**. 매칭 시 `setShapeNum(matched)`로 버튼만 미리 선택·하이라이트하고, `handleSave`(백엔드 POST + 다음 모달 진행)는 사용자가 [적용]을 눌러야 실행. 매칭 오류·저신뢰 시 안전(human-in-loop).
- **B 입력 범위 = 立面図까지 활용**. 屋根伏図의 棟/軒先/ケラバ 판독에 더해 立面図를 棟방향·片流れ 경사방향 판별 보조로 사용. 단 **외곽선(outerline) 추출은 여전히 屋根伏図/平面図만**(기존 제약 유지).
### 범위 경계
- 본 스펙은 B·C만. 미리보기 오버레이·정점 보정·i18n 전면 정비는 별도 후속.
- 매처는 이미 출하됨 — 본 스펙에서 재구현하지 않는다.
## 2. 인터페이스 — RoofShapeDescriptor
매처 스펙 §5와 동일하되, **B는 `direction`을 산출하지 않는다**(아래 §3 근거):
```
RoofShapeDescriptor = {
type: 'hip' | 'gable' | 'shed' | 'manual',
ridge: 'hip' | 'horizontal' | 'vertical' | 'shed' | 'none',
flows: Array<'top' | 'bottom' | 'left' | 'right'>, // 정규 winding 화면축
confidence?: number, // 0~1
}
```
- 매처는 flows 집합(1차) + type/ridge guard로 식별하며 `direction`을 소비하지 않는다. shed의 direction은 flows로 카탈로그에서 도출 가능(`getRoofShapePatternByShapeNum`). 따라서 B는 direction을 생략해 Gemini 출력을 최소화하고 모순 여지를 줄인다.
- `flows`**정규 winding 화면축** 프레임. 이미지 좌상단 원점이 캔버스로 평행이동(scale 없음, `usePdfImport.centerOnCanvas`)으로 보존되므로, 이미지의 top/bottom/left/right가 캔버스 화면축과 일치한다. `ensureCounterClockwise`(`usePdfImport.js:33-36`)는 폴리곤 정점 순서만 정규화하며 축 방향을 뒤집지 않는다.
## 3. B — Gemini descriptor 산출 (`src/app/api/gemini/floor-plan/route.js`)
### 3.1 응답 스키마 (`FLOOR_PLAN_SCHEMA`, route.js:35-56)
`properties``roofShape` 추가:
```js
roofShape: {
type: SchemaType.OBJECT,
properties: {
type: { type: SchemaType.STRING }, // hip|gable|shed|manual
ridge: { type: SchemaType.STRING }, // hip|horizontal|vertical|shed|none
flows: { type: SchemaType.ARRAY, items: { type: SchemaType.STRING } }, // top|bottom|left|right
confidence: { type: SchemaType.NUMBER }, // 0~1
},
required: ['type', 'ridge', 'flows'],
},
```
top-level `required`(route.js:55)에는 `roofShape`**추가하지 않는다**(선택 반환 허용 — 판별 불가 시 누락 가능).
### 3.2 프롬프트 (`BASE_PROMPT`, route.js:58-97)
`[출력]` 블록(:84) 앞에 새 `[지붕형상 판독]` 섹션 추가. 지시 내용:
- 屋根伏図의 **棟(용마루)** 위치·유형으로 지붕 유형 판별: 寄棟→`type=hip, ridge=hip`, 切妻→`type=gable`, 片流れ→`type=shed, ridge=shed`.
- **軒先(처마)가 흘러내리는 방향**을 화면축 집합 `flows`로(외곽선과 동일 좌상단 원점·화면축):
- 寄棟: `flows=[top,bottom,left,right]`
- 切妻 가로 용마루(좌우로 긺): `ridge=horizontal, flows=[top,bottom]`
- 切妻 세로 용마루(상하로 긺): `ridge=vertical, flows=[left,right]`
- 片流れ: 처마면 단일 방향 `flows=[<한 방향>]`
- 복합형(L자/凹凸)·판별 불가: `type=manual, ridge=none, flows=[]` 또는 roofShape 생략.
- `confidence` 0~1.
- `BASE_PROMPT``buildPrompt` 세 분기 모두에 포함되므로 이 한 곳 수정으로 전 분기 적용.
### 3.3 立面図 활용 (`buildPrompt`, route.js:99-115)
splitInfo 분기(:101-107)와 specify 분기(:108-113)의 입면도 지시를 "높이 참고 전용"에서 **"棟방향·片流れ 경사방향 판별 보조 가능"**으로 확대. 단 "입면도에서 외곽선/정점 추출 금지"는 유지(외곽선은 屋根伏図/平面도에서만).
### 3.4 서버 정규화 `normalizeRoofShape(raw)` (route.js, 순수 함수)
Gemini 자유 출력을 매처 strict 어휘로 다리는 순수 함수. 신규 추가:
```
normalizeRoofShape(raw) -> { type, ridge, flows, confidence? } | null
- raw 가 비객체/null → null
- type, ridge: String(...).trim().toLowerCase(); 어휘(ROOF_SHAPE_TYPE/ROOF_RIDGE 값) 밖이면 해당 필드만 빈 문자열로 둔다(매처가 결측 관대 처리). 단 type·ridge 둘 다 비고 flows 도 비면 → null.
- flows: 배열 아니면 [] ; 각 원소 String().trim().toLowerCase() 후 ROOF_FLOW 어휘만 통과·중복 제거
- confidence: 숫자면 그대로, 아니면 생략
```
어휘 상수는 `@/common/roofShapePattern``ROOF_FLOW`/`ROOF_SHAPE_TYPE`/`ROOF_RIDGE`를 재사용(route.js에서 import). **순수 함수이므로 scratch `.mjs` 하니스로 TDD** — B의 유일한 자동검증 단위.
### 3.5 반환 (route.js:324-332)
`roofShape: normalizeRoofShape(parsed.roofShape)` 추가. `validateOuterline`(:117-146)은 outerline 전용이라 무변경. roofShape 누락·오염은 normalizeRoofShape→null 또는 부분필드→매처 null로 자연 흡수.
### 3.6 confidence
roofShape.confidence는 outerline confidence(0.6 게이트, PlacementShapeSetting.jsx:225)와 **별개**. C가 pre-select only라 confidence 게이팅 없음 — B는 산출만.
## 4. C — 자동 선택 wiring (pre-select only)
### 4.1 상태 atom (`src/store/outerLineAtom.js`)
```js
export const pdfRoofShapeDescriptorState = atom({
key: 'pdfRoofShapeDescriptorState',
default: null, // RoofShapeDescriptor | null
})
```
근거: outerLineAtom이 이미 PDF 임포트 핵심 상태(outerLinePointsState/outerLineFixState/autoOuterLineFixState)를 보유 — 동일 스코프가 자연스럽다.
### 4.2 저장 (`PlacementShapeSetting.jsx`, analyzePdfAndApply ~:225-243)
- 분석 시작 시 `setPdfRoofShapeDescriptor(null)`로 이전 값 리셋(cross-run staleness 방지).
- payload 수신 후 `setPdfRoofShapeDescriptor(payload.roofShape ?? null)`.
### 4.3 소비 — consume-once (`src/hooks/roofcover/useRoofShapeSetting.js`, 마운트 useEffect 가드 통과 직후 ~L88)
```
const descriptor = useRecoilValue(pdfRoofShapeDescriptorState) // 또는 useRecoilState
마운트 effect 내, canvas.outerLineFix 가드 통과 후:
if (descriptor) {
const matched = matchRoofShapePattern(descriptor) // @/common/roofShapePattern
if (matched != null) setShapeNum(matched) // pre-select ONLY — handleSave 호출 안 함
resetPdfRoofShapeDescriptor() // consume-once: null 리셋
}
```
- **pre-select only**: `setShapeNum(matched)`만 — `handleSave`는 절대 호출하지 않는다(사용자가 [적용] 클릭).
- `setShapeNum`은 useState라, 이 effect는 마운트 1회 실행으로 충분(handleSave 게이팅 불필요 — 적용은 사용자 몫).
- **consume-once**: 매칭 시도 후 descriptor를 null로 리셋해, 이후 사이드바/수동 경로로 RoofShapeSetting 재진입 시 stale 적용을 막는다.
### 4.4 null 폴백
`matchRoofShapePattern`이 null(복합형/판별불가)이면 강제 선택 없이 기본값(`shapeNum` useState 초기값 1, 用마루) 유지. 사용자가 직접 선택. (선택: 비차단 안내문 — 본 스펙 필수 아님.)
## 5. 데이터 흐름
```
analyzePdfAndApply (PlacementShapeSetting)
├─ setPdfRoofShapeDescriptor(null) // 리셋
├─ fetch /api/gemini/floor-plan → payload{outerline, ..., roofShape}
├─ setPdfRoofShapeDescriptor(payload.roofShape ?? null) // 저장
└─ applyOuterline(payload.outerline, { autoFix:false })
→ setCurrentMenu(AUTO_OUTERLINE_FIX) → AutoOuterLineFixSetting
└─ [ON] canvas.outerLineFix=true → setCurrentMenu(ROOF_SHAPE_SETTINGS)
└─ RoofShapeSetting 마운트
└─ useRoofShapeSetting 마운트 effect:
descriptor read → match → setShapeNum(matched) → reset(null)
→ 매칭 버튼 pre-selected → 사용자 검토 → [적용](기존 handleSave)
```
## 6. 에러 처리 / 엣지
- Gemini가 roofShape 누락/오염 → normalizeRoofShape가 null 또는 부분필드 → 매처 null → pre-select 없음(graceful, 외곽선 흐름 무영향).
- descriptor 있으나 매처 null(복합형) → pre-select 없음, 기본값 유지.
- consume-once → PDF 무관한 RoofShapeSetting 재진입 시 stale descriptor 미적용.
- B는 outerline 흐름과 독립 — roofShape 실패가 외곽선 자동작성을 깨지 않는다.
## 7. 테스트 / 검증
- **B**: `normalizeRoofShape` 순수 함수 → scratch `.mjs` 불변식 하니스(TDD). 스키마/프롬프트/立面図 확대 → **실제 Gemini 호출 수동 런타임 검증**(샘플 屋根伏図+立面図, `NEXT_PUBLIC_ENABLE_LOGGING=true`로 roofShape 응답 확인). 구조 검증: 스키마 파싱·반환 형태.
- **C**: 매처는 기검증(8불변식). atom consume-once + pre-select 배선 → 앱 수동 검증(테스트러너 미연동). 시나리오: 切妻/寄棟/片流れ PDF → ON 확정 → 해당 버튼 pre-select 확인 / 복합형 → 미선택·기본값 / 재진입 시 stale 미적용.
- `yarn lint`(eslint 미설치 시 스킵·Prettier 수기) + `yarn build` 회귀 가드.
## 8. 영향 범위 / 변경 파일
- **B**: `src/app/api/gemini/floor-plan/route.js`(스키마·프롬프트·buildPrompt·반환·normalizeRoofShape, `@/common/roofShapePattern` import).
- **C**: `src/store/outerLineAtom.js`(atom 1개), `src/components/floor-plan/modal/placementShape/PlacementShapeSetting.jsx`(descriptor 저장·리셋), `src/hooks/roofcover/useRoofShapeSetting.js`(마운트 effect 소비·consume-once, `@/common/roofShapePattern` import).
- **무변경**: `matchRoofShapePattern`/카탈로그(기출하), `AutoOuterLineFixSetting`(ON 분기는 이미 ROOF_SHAPE_SETTINGS로 전환 — 추가 변경 불필요), `applyOuterline`(autoFix 무변경).
## 9. 구현 순서 (2 플랜)
- **플랜 B 먼저**: route.js 스키마·프롬프트·normalizeRoofShape(TDD)·반환. 산출물 = payload.roofShape 반환.
- **플랜 C 다음**: atom + 저장 + consume-once pre-select. B의 반환을 소비.
각 플랜은 독립 실행·검증·커밋. C는 B의 반환 계약(payload.roofShape)에 의존.
## 10. 리스크 / 미해결
- **B 정확도(런타임 의존)**: Gemini의 棟/flows 판독 품질은 실제 호출 전까지 미지. 프롬프트 반복 튜닝 필요할 수 있음. confidence·매처 null·pre-select-only로 오판 영향은 제한적(사용자 검토 단계 존재).
- **정규 winding 프레임 일치**: B의 flows(이미지 화면축)가 캔버스 화면축과 일치한다는 가정. centerOnCanvas 평행이동·ensureCounterClockwise(순서만)로 성립하나, 회전된 도면/비정규 배치는 어긋날 수 있음 — 매처 null로 흡수(폴백 안전).
- **立面図 활용 효과 불확실**: 입면도가 棟방향 판별을 실제로 개선하는지는 런타임 검증으로만 확인. 개선 없으면 §3.3 확대를 축소(屋根伏図 단독)할 수 있음.