[작업내용] : - 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>
12 KiB
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 추가:
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 생략. confidence0~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)
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/roofShapePatternimport). - 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/roofShapePatternimport). - 무변경:
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 확대를 축소(屋根伏図 단독)할 수 있음.