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

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)

propertiesroofShape 추가:

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_PROMPTbuildPrompt 세 분기 모두에 포함되므로 이 한 곳 수정으로 전 분기 적용.

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/roofShapePatternROOF_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/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 확대를 축소(屋根伏図 단독)할 수 있음.