diff --git a/docs/superpowers/specs/2026-06-24-pdf-roof-shape-auto-detect-design.md b/docs/superpowers/specs/2026-06-24-pdf-roof-shape-auto-detect-design.md new file mode 100644 index 00000000..42ed41e0 --- /dev/null +++ b/docs/superpowers/specs/2026-06-24-pdf-roof-shape-auto-detect-design.md @@ -0,0 +1,166 @@ +# 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 확대를 축소(屋根伏図 단독)할 수 있음.