Compare commits

...

14 Commits

Author SHA1 Message Date
sangwook yoo
6457817e70 feat(roof-shape): 지붕형상 자동판독 결과 로깅 추가 — 수동 QA 보조
[작업내용] :
- route.js: Gemini roofShape 원본+정규화 결과 logger.debug (서버 콘솔)
- useRoofShapeSetting: descriptor→매칭 shapeNum+패턴명 logger.info (브라우저 콘솔, pre-select 시점)
- raw console.log 대신 logger 사용(NEXT_PUBLIC_ENABLE_LOGGING=true 시 출력, 프로덕션 무음)

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-24 15:18:23 +09:00
sangwook yoo
729198b650 docs(claude): PDF 지붕형상 자동 선택(pre-select) 플로우 추가
[작업내용] :
- PDF-import 절에 roofShape descriptor 반환·normalizeRoofShape·pdfRoofShapeDescriptorState 저장·useRoofShapeSetting pre-select(consume-once) 흐름 기술
- handleSave 비자동(사용자 [적용]) 명시 + Gemini roofShape 런타임 QA 잔여 경고

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-24 15:14:03 +09:00
sangwook yoo
edeb766b3a feat(roof-shape): PDF 지붕형상 매칭 버튼 자동 선택(pre-select)
[작업내용] :
- 마운트 시 pdfRoofShapeDescriptorState → matchRoofShapePattern → setShapeNum(매칭 버튼 pre-select)
- pre-select only: handleSave 비자동(사용자 [적용])
- consume-once: 매칭 시도 후 atom 리셋(비-PDF 재진입 stale 방지)
- 매처 null(복합형) 시 강제 선택 없이 기본값 유지
- 런타임(실제 분기별 pre-select) 수동 검증 잔여

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-24 14:55:34 +09:00
sangwook yoo
28ea1e9b8e feat(roof-shape): PDF 지붕형상 descriptor atom 저장 배선
[작업내용] :
- pdfRoofShapeDescriptorState atom 추가(outerLineAtom)
- analyzePdfAndApply: 분석 시작 시 리셋 + payload.roofShape 저장
- 소비(매칭·pre-select)는 다음 task

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-24 14:51:53 +09:00
sangwook yoo
9d60948e64 feat(roof-shape): Gemini API 에 지붕형상 descriptor 산출 추가
[작업내용] :
- 응답 스키마에 roofShape{type,ridge,flows,confidence?} 필드
- BASE_PROMPT 에 [지붕형상 판독] 섹션(棟/軒先 → type/ridge/flows, 화면축)
- buildPrompt 입면도 활용을 棟방향·경사 판별 보조로 확대(외곽선 추출은 여전히 평면도만)
- 반환에 normalizeRoofShape(parsed.roofShape) 배선
- 런타임(실제 Gemini roofShape 응답) 수동 검증 잔여

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-24 14:47:13 +09:00
sangwook yoo
3f8ead0a3e feat(roof-shape): Gemini 출력 정규화 normalizeRoofShape 추가
[작업내용] :
- raw 지붕형상 출력 → RoofShapeDescriptor(type/ridge/flows/confidence?) 정규화
- 어휘 밖 type/ridge 는 ''(매처 결측 관대), flows 는 어휘만 소문자화·중복제거
- 모두 비면 null, direction 비산출(매처 미사용)
- route.js 배선은 다음 task

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-24 14:43:04 +09:00
sangwook yoo
4ebe02186f docs(roof-shape): 지붕형상 자동판별 B·C 구현 계획서 추가
[작업내용] :
- B(2 task): normalizeRoofShape 순수함수(TDD, roofShapePattern.js 동거) + route.js 스키마/프롬프트/반환 배선
- C(2 task): pdfRoofShapeDescriptorState atom 저장/리셋 + useRoofShapeSetting 마운트 consume-once pre-select
- B→C 순차, 각 독립 커밋

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-24 14:40:12 +09:00
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
sangwook yoo
bcdd630a00 feat(roof-shape): 지붕형상 패턴 매처 matchRoofShapePattern 추가
[작업내용] :
- descriptor(type/ridge/flows)→shapeNum|null 순수 매칭 함수
- flows 집합 1차 키 + type/ridge guard(모순 거부, 결측 관대)
- 폴백 null(autoMatchable=false인 4 비반환), confidence 무시
- Gemini 분석 확장(B)·자동 클릭 배선(C)은 다음 작업으로 분리

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-24 14:00:19 +09:00
sangwook yoo
cfd8e3bdef docs(roof-shape): 지붕형상 패턴 매처 구현 계획서 추가
[작업내용] :
- matchRoofShapePattern 단일 Task TDD 플랜(하니스 8불변식)
- flows 집합 키 + type/ridge guard, 폴백 null
- Gemini 분석 확장·자동 클릭은 후속 작업으로 명시

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-24 11:34:53 +09:00
sangwook yoo
cb9912b31d docs(roof-shape): 지붕형상 패턴 매처 설계 스펙 추가
[작업내용] :
- matchRoofShapePattern(descriptor)→shapeNum|null 순수 함수 설계
- 매칭 키 flows 집합(1차) + type/ridge guard(모순 거부)
- 폴백 null(4 비반환), confidence 무시 — UX 정책은 C 단계 위임
- 검증: scratchpad .mjs 불변식 하니스 8종

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-24 11:29:35 +09:00
sangwook yoo
e4045142bf docs(roof-shape): 지붕형상 패턴 카탈로그 구현 계획서 추가
[작업내용] :
- 2026-06-24 카탈로그 모듈 구현 플랜(단일 태스크, node 불변식 검증 하니스)

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-24 10:50:42 +09:00
sangwook yoo
61ffcee949 feat(roof-shape): 지붕형상 패턴 카탈로그 상수 모듈 추가
[작업내용] :
- ROOF_SHAPE_PATTERNS(8) + ROOF_FLOW/ROOF_SHAPE_TYPE/ROOF_RIDGE 상수
- 매칭 키 type+ridge+flows, flows 집합만으로 8패턴 유일 식별
- getRoofShapePatternByShapeNum 접근자
- 매칭 함수·PDF 분석 확장은 다음 작업으로 분리

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-24 10:50:35 +09:00
sangwook yoo
105c8470ad docs(roof-shape): 지붕형상 패턴 카탈로그 설계 스펙 추가
[작업내용] :
- PDF 분석→지붕형상 자동 매칭의 선행 단계로 8패턴 카탈로그 설계 정의
- 매칭 키: type+ridge+flows(화면축) 시그니처, flows 집합만으로 8패턴 유일 식별
- 5~8 화면축↔버튼 매핑을 useRoofShapeSetting case 4~8 에서 1:1 전사
- 매칭 함수·PDF 분석 확장은 다음 작업으로 분리(계약만 명시)

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-24 10:43:18 +09:00
13 changed files with 1635 additions and 10 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`. **분석 직후 외벽선은 항상 미확정으로만 그린다**(`PlacementShapeSetting.handleSaveBtn``applyOuterline(mmPoints, { autoFix:false })``useLine.addLine` 경유 outerLine 생성(attributes/planeSize/fontSize) + `outerLinePoint` 정점 circle, `outerLineFix=false`). 그 후 `handleSaveBtn``setSelectedMenu('outline')` + `setCurrentMenu(MENU.ROOF_COVERING.AUTO_OUTERLINE_FIX)` 단일 전환만 한다 — `MenuDepth01``currentMenu` 변화를 감지해 `useMenu.handleMenu``AUTO_OUTERLINE_FIX` case 로 **'외벽선 자동 확정' 팝업(`AutoOuterLineFixSetting`)** 을 자동으로 띄운다. **확정 여부 분기는 이 팝업의 스위치(`autoOuterLineFixState`, 기본 ON) + [확인]** 에서 한다(`handleConfirm`): **ON** — 외벽선/정점/points 는 이미 `applyOuterline` 이 그려둔 상태라 `canvas.outerLineFix=true` + `setOuterLineFix(true)` 플래그만 켜면 수동 확정(`useOuterLineWall.handleFix`) 직후와 동일해진다(WALL/ROOF 폴리곤은 지붕형상 설정 적용 시점에 생성). 미확정으로 저장된 직전 상태를 덮어쓰도록 `saveCanvas` 한 뒤 `setCurrentMenu(ROOF_SHAPE_SETTINGS)`. **OFF** — 미확정 유지 + `setCurrentMenu(EXTERIOR_WALL_LINE)` 로 외벽선 확정 팝업(`WallLineSetting`)으로 보낸다. **팝업 전환은 `currentMenu` 메커니즘으로만** — 직접 `addPopup` 하지 말 것(MenuDepth01 의 `closeAll` 과 충돌). 신규 메뉴 `AUTO_OUTERLINE_FIX``subMenusState` 에 노출하지 않는 일회성 팝업이다. ⚠️ `basicSettingSave({ skipSideEffects:true })` 는 메뉴를 건드리지 않아야 한다 — `setMenuByRoofSize``if (!skipSideEffects)` 밖에 있으면 async 저장(`await post`) 직후 `currentMenu``EXTERIOR_WALL_LINE` 로 덮어써, AUTO_OUTERLINE_FIX 팝업 대신 WallLineSetting 으로 되돌아간다(2026-06-21 버그). **좌표에 축소 scale 을 곱하지 말 것** — 'QLine 길이×10 = 표시 mm' 불변식이 깨진다. 화면맞춤은 `viewportTransform` 줌 + `canvasZoomState` 동기화로만 처리한다. **PDF 도면 임포트** — 배치면 초기설정 모달의 PDF 입력 모드 → `src/app/api/gemini/floor-plan/route.js`(Gemini 분석, 세션 인증 + 사용자별 분당 5회 레이트리밋) → `src/hooks/pdf-import/usePdfImport.js`. **분석 직후 외벽선은 항상 미확정으로만 그린다**(`PlacementShapeSetting.handleSaveBtn``applyOuterline(mmPoints, { autoFix:false })``useLine.addLine` 경유 outerLine 생성(attributes/planeSize/fontSize) + `outerLinePoint` 정점 circle, `outerLineFix=false`). 그 후 `handleSaveBtn``setSelectedMenu('outline')` + `setCurrentMenu(MENU.ROOF_COVERING.AUTO_OUTERLINE_FIX)` 단일 전환만 한다 — `MenuDepth01``currentMenu` 변화를 감지해 `useMenu.handleMenu``AUTO_OUTERLINE_FIX` case 로 **'외벽선 자동 확정' 팝업(`AutoOuterLineFixSetting`)** 을 자동으로 띄운다. **확정 여부 분기는 이 팝업의 스위치(`autoOuterLineFixState`, 기본 ON) + [확인]** 에서 한다(`handleConfirm`): **ON** — 외벽선/정점/points 는 이미 `applyOuterline` 이 그려둔 상태라 `canvas.outerLineFix=true` + `setOuterLineFix(true)` 플래그만 켜면 수동 확정(`useOuterLineWall.handleFix`) 직후와 동일해진다(WALL/ROOF 폴리곤은 지붕형상 설정 적용 시점에 생성). 미확정으로 저장된 직전 상태를 덮어쓰도록 `saveCanvas` 한 뒤 `setCurrentMenu(ROOF_SHAPE_SETTINGS)`. **OFF** — 미확정 유지 + `setCurrentMenu(EXTERIOR_WALL_LINE)` 로 외벽선 확정 팝업(`WallLineSetting`)으로 보낸다. **팝업 전환은 `currentMenu` 메커니즘으로만** — 직접 `addPopup` 하지 말 것(MenuDepth01 의 `closeAll` 과 충돌). 신규 메뉴 `AUTO_OUTERLINE_FIX``subMenusState` 에 노출하지 않는 일회성 팝업이다. ⚠️ `basicSettingSave({ skipSideEffects:true })` 는 메뉴를 건드리지 않아야 한다 — `setMenuByRoofSize``if (!skipSideEffects)` 밖에 있으면 async 저장(`await post`) 직후 `currentMenu``EXTERIOR_WALL_LINE` 로 덮어써, AUTO_OUTERLINE_FIX 팝업 대신 WallLineSetting 으로 되돌아간다(2026-06-21 버그). **좌표에 축소 scale 을 곱하지 말 것** — 'QLine 길이×10 = 표시 mm' 불변식이 깨진다. 화면맞춤은 `viewportTransform` 줌 + `canvasZoomState` 동기화로만 처리한다. **지붕형상 자동 선택(pre-select)** — Gemini 응답은 `roofShape`(RoofShapeDescriptor `{type,ridge,flows,confidence?}`, route.js 가 `normalizeRoofShape` 로 어휘 정규화)를 추가로 반환한다. `analyzePdfAndApply` 가 이를 `pdfRoofShapeDescriptorState`(outerLineAtom)에 저장(분석 시작 시 null 리셋)하고, ROOF_SHAPE_SETTINGS 진입 시 `useRoofShapeSetting` 마운트 effect 가 `matchRoofShapePattern`(`@/common/roofShapePattern`, flows 집합+type/ridge guard)으로 매칭해 **버튼을 `setShapeNum` 으로 pre-select 만** 한다(`handleSave` 자동호출 금지 — 적용은 사용자 [적용]) + consume-once 로 atom 리셋(비-PDF 재진입 stale 방지). 매처 null(복합형)·roofShape 누락은 강제선택 없이 graceful. ⚠️ Gemini 의 실제 `roofShape` 출력 품질(특히 片流れ flows 방향)은 런타임 수동 QA·프롬프트 튜닝 영역.
### State — Recoil, split by domain ### State — Recoil, split by domain

View File

@ -0,0 +1,311 @@
# B — Gemini 지붕형상 descriptor 산출 Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** Gemini floor-plan API 가 屋根伏図의 棟/軒先 + 立面図 보조로 판독한 지붕형상을 `RoofShapeDescriptor` 로 정규화해 응답에 포함하도록 확장한다.
**Architecture:** (1) `src/common/roofShapePattern.js` 에 순수 정규화 함수 `normalizeRoofShape(raw)` 추가(카탈로그·매처와 동거 — 무import 모듈이라 `.mjs` 하니스로 TDD 가능, route.js 는 Next/Gemini import 가 있어 직접 테스트 불가하므로 여기 둔다). (2) `route.js` 의 응답 스키마에 `roofShape` 필드, `BASE_PROMPT``[지붕형상 판독]` 섹션, `buildPrompt` 의 입면도 활용 확대, 반환 JSON 에 `normalizeRoofShape(parsed.roofShape)` 를 배선한다.
**Tech Stack:** JavaScript (ESM), Next.js 14 App Router route handler, `@google/generative-ai` SchemaType. 신규 의존성 없음.
**설계 근거 스펙:** `docs/superpowers/specs/2026-06-24-pdf-roof-shape-auto-detect-design.md` (§2·§3)
## Global Constraints
- **Prettier**: 싱글쿼트, **세미콜론 없음**, tabWidth 2, printWidth 150, trailingComma all (`.prettierrc`). 커밋 전 `npx prettier --check` 로 확인.
- **테스트 러너 없음**: `normalizeRoofShape` 는 scratchpad `.mjs` 하니스로 검증. 프롬프트/스키마/반환은 자동 테스트 불가 → build 회귀 가드 + 수동 런타임 검증(별도 테스트 프레임워크 도입 금지).
- **eslint 주의**: eslint 미설치 시 `next lint` 가 셋업 요구하면 임시 설치 금지·원복. (메모리: `eslint-build-skips-lint`)
- **Path alias**: `@/* → ./src/*`.
- **로깅**: `console.log` 금지 — route.js 는 `@/util/logger` 사용(기존 패턴 유지).
- **RoofShapeDescriptor 계약**(스펙 §2): `{ type:'hip'|'gable'|'shed'|'manual', ridge:'hip'|'horizontal'|'vertical'|'shed'|'none', flows:Array<'top'|'bottom'|'left'|'right'>, confidence?:number }`. **direction 은 산출하지 않는다**(매처 미사용).
- **외곽선 추출 제약 유지**: 立面図 에서 외곽선/정점 추출 금지 — 입면도는 棟방향·片流れ 경사방향 판별 보조로만.
- `<SCRATCH>` = `/private/tmp/claude-501/-Users-devgrr-dev-interplug-hanasys-qcast-front/749df8d5-715e-499d-9009-832aa3cf4f4d/scratchpad`
---
### Task 1: normalizeRoofShape 순수 정규화 함수
**Files:**
- Modify: `src/common/roofShapePattern.js` (현재 `matchRoofShapePattern` 까지 정의됨. 끝에 export 추가)
- Verify(scratchpad, 비커밋): `<SCRATCH>/check-normalizeRoofShape.mjs`, `<SCRATCH>/roofShapePattern.mjs`
**Interfaces:**
- Consumes (동일 모듈 기존 export): `ROOF_FLOW`, `ROOF_SHAPE_TYPE`, `ROOF_RIDGE`, `matchRoofShapePattern`.
- Produces (Task 2 가 import):
- `normalizeRoofShape(raw) => { type:string, ridge:string, flows:string[], confidence?:number } | null`
- 어휘 밖 type/ridge 는 `''`(매처가 결측 관대 처리), flows 는 ROOF_FLOW 어휘만 통과·소문자화·중복제거.
- type·ridge·flows 가 모두 비면 `null`.
- [ ] **Step 1: 검증 하니스 작성 (failing test)**
`<SCRATCH>/check-normalizeRoofShape.mjs` 생성:
```js
import { normalizeRoofShape, matchRoofShapePattern } from './roofShapePattern.mjs'
const fail = (msg) => {
console.error('FAIL:', msg)
process.exit(1)
}
const eqJson = (got, want, label) => {
if (JSON.stringify(got) !== JSON.stringify(want)) fail(`${label}: expected ${JSON.stringify(want)}, got ${JSON.stringify(got)}`)
}
// 1. 대소문자/공백 정규화 — 유효 full descriptor
eqJson(normalizeRoofShape({ type: ' GABLE ', ridge: 'Horizontal', flows: ['TOP', 'bottom'] }), { type: 'gable', ridge: 'horizontal', flows: ['top', 'bottom'] }, 'normalize valid')
// 2. 어휘 밖 type 은 '' (ridge/flows 는 유지) — 매처 결측 관대와 연동
eqJson(normalizeRoofShape({ type: '寄棟', ridge: 'hip', flows: ['top', 'bottom', 'left', 'right'] }), { type: '', ridge: 'hip', flows: ['top', 'bottom', 'left', 'right'] }, 'unknown type -> empty')
// 3. flows 어휘 밖 값 제거 + 중복 제거
eqJson(normalizeRoofShape({ type: 'hip', ridge: 'hip', flows: ['top', 'up', 'top', 'left'] }), { type: 'hip', ridge: 'hip', flows: ['top', 'left'] }, 'flows filter+dedup')
// 4. flows 비배열 → []
eqJson(normalizeRoofShape({ type: 'gable', ridge: 'horizontal', flows: 'x' }), { type: 'gable', ridge: 'horizontal', flows: [] }, 'flows not array')
// 5. 무효 입력 → null
eqJson(normalizeRoofShape(null), null, 'null input')
eqJson(normalizeRoofShape(undefined), null, 'undefined input')
eqJson(normalizeRoofShape('x'), null, 'string input')
eqJson(normalizeRoofShape(42), null, 'number input')
// 6. type·ridge·flows 모두 비면 → null
eqJson(normalizeRoofShape({ type: 'zzz', ridge: 'yyy', flows: ['up'] }), null, 'all empty -> null')
eqJson(normalizeRoofShape({}), null, 'empty object -> null')
// 7. confidence: 숫자면 보존, 아니면 생략
eqJson(normalizeRoofShape({ type: 'hip', ridge: 'hip', flows: ['top'], confidence: 0.8 }), { type: 'hip', ridge: 'hip', flows: ['top'], confidence: 0.8 }, 'confidence kept')
eqJson(normalizeRoofShape({ type: 'hip', ridge: 'hip', flows: ['top'], confidence: 'high' }), { type: 'hip', ridge: 'hip', flows: ['top'] }, 'confidence non-number dropped')
// 8. 정규화 출력이 매처와 정합 — end-to-end (B→C 다리)
if (matchRoofShapePattern(normalizeRoofShape({ type: 'GABLE', ridge: 'HORIZONTAL', flows: ['TOP', 'BOTTOM'] })) !== 2) fail('e2e normalize->match should be 2')
if (matchRoofShapePattern(normalizeRoofShape({ type: '寄棟', ridge: 'hip', flows: ['top', 'bottom', 'left', 'right'] })) !== 1) fail('e2e unknown-type->match should be 1 (flows)')
console.log('OK: all normalizeRoofShape invariants hold')
```
- [ ] **Step 2: 실패 확인 (red)**
Run:
```bash
SCRATCH=/private/tmp/claude-501/-Users-devgrr-dev-interplug-hanasys-qcast-front/749df8d5-715e-499d-9009-832aa3cf4f4d/scratchpad
cp src/common/roofShapePattern.js "$SCRATCH/roofShapePattern.mjs"
node "$SCRATCH/check-normalizeRoofShape.mjs"
```
Expected: FAIL — `normalizeRoofShape is not a function`.
- [ ] **Step 3: normalizeRoofShape 추가 (minimal implementation)**
`src/common/roofShapePattern.js` 끝(`matchRoofShapePattern` 다음 줄)에 추가:
```js
// Gemini 자유 출력(raw) → 매처 strict 어휘로 정규화한 RoofShapeDescriptor | null.
// 어휘 밖 type/ridge 는 ''(매처가 결측 관대), flows 는 ROOF_FLOW 어휘만 소문자화·중복제거.
// type·ridge·flows 가 모두 비면 null. direction 은 산출하지 않는다(매처 미사용).
export const normalizeRoofShape = (raw) => {
if (!raw || typeof raw !== 'object') return null
const norm = (value) => (typeof value === 'string' ? value.trim().toLowerCase() : '')
const TYPE_VALUES = Object.values(ROOF_SHAPE_TYPE)
const RIDGE_VALUES = Object.values(ROOF_RIDGE)
const FLOW_VALUES = Object.values(ROOF_FLOW)
const type = TYPE_VALUES.includes(norm(raw.type)) ? norm(raw.type) : ''
const ridge = RIDGE_VALUES.includes(norm(raw.ridge)) ? norm(raw.ridge) : ''
const flows = Array.isArray(raw.flows) ? [...new Set(raw.flows.map(norm).filter((flow) => FLOW_VALUES.includes(flow)))] : []
if (!type && !ridge && flows.length === 0) return null
const result = { type, ridge, flows }
if (typeof raw.confidence === 'number') result.confidence = raw.confidence
return result
}
```
- [ ] **Step 4: 통과 확인 (green)**
Run:
```bash
SCRATCH=/private/tmp/claude-501/-Users-devgrr-dev-interplug-hanasys-qcast-front/749df8d5-715e-499d-9009-832aa3cf4f4d/scratchpad
cp src/common/roofShapePattern.js "$SCRATCH/roofShapePattern.mjs"
node "$SCRATCH/check-normalizeRoofShape.mjs"
```
Expected: `OK: all normalizeRoofShape invariants hold`
- [ ] **Step 5: lint / build 검증**
```bash
npx prettier --check src/common/roofShapePattern.js # 통과 확인
yarn build # 회귀 가드
```
Expected: prettier 통과, build 통과(이 함수는 아직 route.js 에서 import 안 됨 → 다음 task).
- [ ] **Step 6: 커밋**
```bash
git add src/common/roofShapePattern.js
git commit -m "feat(roof-shape): Gemini 출력 정규화 normalizeRoofShape 추가
[작업내용] :
- raw 지붕형상 출력 → RoofShapeDescriptor(type/ridge/flows/confidence?) 정규화
- 어휘 밖 type/ridge 는 ''(매처 결측 관대), flows 는 어휘만 소문자화·중복제거
- 모두 비면 null, direction 비산출(매처 미사용)
- route.js 배선은 다음 task
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>"
```
---
### Task 2: route.js 스키마·프롬프트·반환 배선
**Files:**
- Modify: `src/app/api/gemini/floor-plan/route.js`
- import 추가(상단 import 블록, `:9` 근처)
- `FLOOR_PLAN_SCHEMA.properties` (`:53` 뒤)
- `BASE_PROMPT` (`:84` `[출력]` 앞 + `:90-97` 스키마 예시)
- `buildPrompt` 입면도 문구 (`:106`, `:112`)
- 반환 JSON (`:324-332`)
**Interfaces:**
- Consumes (Task 1): `normalizeRoofShape(raw) => RoofShapeDescriptor | null`
- Produces (플랜 C 가 소비): 응답 JSON 에 `roofShape: RoofShapeDescriptor | null` 필드 추가.
- [ ] **Step 1: import 추가**
`route.js` 상단 import 블록(`@/util/logger` 다음, `:9` 뒤)에 추가:
```js
import { normalizeRoofShape } from '@/common/roofShapePattern'
```
- [ ] **Step 2: 응답 스키마에 roofShape 추가**
`FLOOR_PLAN_SCHEMA.properties``selectedDrawingType` 다음(`:53` 뒤, `},` 닫기 전)에 추가:
```js
selectedDrawingType: { type: SchemaType.STRING },
roofShape: {
type: SchemaType.OBJECT,
properties: {
type: { type: SchemaType.STRING },
ridge: { type: SchemaType.STRING },
flows: { type: SchemaType.ARRAY, items: { type: SchemaType.STRING } },
confidence: { type: SchemaType.NUMBER },
},
required: ['type', 'ridge', 'flows'],
},
```
top-level `required`(`:55` `['outerline', 'unit']`)는 **변경하지 않는다**(roofShape 선택 반환 허용).
- [ ] **Step 3: BASE_PROMPT 에 [지붕형상 판독] 섹션 + 스키마 예시 추가**
`BASE_PROMPT``[출력]` 블록(`:84`) **앞**에 새 섹션 삽입:
```
[지붕형상 판독 — 屋根伏図 棟·軒先 + 立面図 보조]
- 屋根伏図의 棟(용마루) 위치·개수와 軒先(처마)가 흘러내리는 방향으로 지붕 유형을 판별합니다.
- type: 寄棟→'hip', 切妻→'gable', 片流れ(단일경사)→'shed', 판별 불가/복합형→'manual'.
- ridge(용마루 방향): 寄棟→'hip', 切妻 가로 용마루→'horizontal', 切妻 세로 용마루→'vertical', 片流れ→'shed', 없음/복합→'none'.
- flows: 처마가 흘러내리는 방향을 화면축 집합으로 기재합니다. 좌표계는 외곽선과 동일(좌상단 원점, 위=top·아래=bottom·왼쪽=left·오른쪽=right).
· 寄棟: ['top','bottom','left','right'] (사방 흐름)
· 切妻 가로 용마루(용마루가 좌우로 길게): ['top','bottom']
· 切妻 세로 용마루(용마루가 상하로 길게): ['left','right']
· 片流れ: 처마가 향하는 단일 방향 1개만, 예: ['bottom']
· 복합형/판별 불가: [] (이 경우 type='manual', ridge='none')
- 立面図(입면도)가 있으면 棟방향·경사 방향 판별의 보조로만 활용하세요(외곽선/정점 추출에는 절대 사용 금지).
- 확신이 없으면 roofShape.confidence 를 낮게 주거나 roofShape 자체를 생략하세요(틀린 추정보다 누락이 낫습니다).
```
그리고 같은 `BASE_PROMPT` 안 스키마 예시(`:90-97`)의 닫는 `}` 직전에 `roofShape` 항목 추가:
```
"selectedDrawingType": string (선택),
"roofShape": { "type": "hip|gable|shed|manual", "ridge": "hip|horizontal|vertical|shed|none", "flows": ["top|bottom|left|right", ...], "confidence": number } (선택)
}
```
(기존 `"selectedDrawingType": string (선택)` 줄 뒤에 콤마 추가 + roofShape 줄 추가.)
- [ ] **Step 4: buildPrompt 입면도 활용 문구 확대**
`buildPrompt` 의 splitInfo 분기(`:106`) 반환 문자열 끝부분을 교체:
기존:
```js
return `${BASE_PROMPT}\n\n${compo} 외곽선은 평면도/지붕평면도 페이지에서만 추출하고, 입면도 페이지는 외곽선 추출에 쓰지 말고 높이·층수 참고로만 사용하세요.`
```
변경:
```js
return `${BASE_PROMPT}\n\n${compo} 외곽선은 평면도/지붕평면도 페이지에서만 추출하세요. 입면도 페이지는 외곽선 추출에 쓰지 말고, 높이·층수와 지붕 棟방향·경사 방향(지붕형상 판별) 참고로만 사용하세요.`
```
specify 분기(`:112`) 반환 문자열도 교체:
기존:
```js
return `${BASE_PROMPT}\n\n페이지 힌트: ${hints.join(' / ')}. 입면도 페이지는 외곽선 추출에 쓰지 말고 높이·층수 참고로만 사용하세요. 지붕 투영 외곽선은 지정된 평면도/지붕평면도 페이지 기준으로 추출하세요.`
```
변경:
```js
return `${BASE_PROMPT}\n\n페이지 힌트: ${hints.join(' / ')}. 입면도 페이지는 외곽선 추출에 쓰지 말고 높이·층수와 지붕 棟방향·경사 방향(지붕형상 판별) 참고로만 사용하세요. 지붕 투영 외곽선은 지정된 평면도/지붕평면도 페이지 기준으로 추출하세요.`
```
- [ ] **Step 5: 반환 JSON 에 roofShape 추가**
`NextResponse.json({...})` 반환(`:324-332`)의 `selectedDrawingType` 다음 줄에 추가:
```js
selectedDrawingType: parsed.selectedDrawingType ?? null,
roofShape: normalizeRoofShape(parsed.roofShape),
empty: validation.empty,
```
- [ ] **Step 6: lint / build 검증**
```bash
npx prettier --check src/app/api/gemini/floor-plan/route.js # 통과 확인
yarn build # 회귀 가드
```
Expected: prettier 통과, build 통과.
- [ ] **Step 7: 수동 런타임 검증 (best-effort, 보고용)**
자동 테스트 불가 — 실제 Gemini 호출은 수동 확인 항목으로 보고한다(구현자는 build 통과까지만 보장하고, 아래를 "수동 검증 잔여"로 기록):
- `.env``NEXT_PUBLIC_ENABLE_LOGGING=true` 설정 후 `yarn dev`, 배치면 초기설정 모달 PDF 모드로 屋根伏図(+立面図) 샘플 업로드.
- 서버 로그 `[gemini/floor-plan] raw response``roofShape:{type,ridge,flows,...}` 가 포함되는지, 切妻 도면이 `flows:['top','bottom']`/`['left','right']`, 寄棟이 4방, 片流れ가 단일방향으로 오는지 확인.
- 응답 네트워크 탭에서 최종 JSON `roofShape` 가 정규화된 형태(소문자 어휘)인지 확인.
- [ ] **Step 8: 커밋**
```bash
git add src/app/api/gemini/floor-plan/route.js
git commit -m "feat(roof-shape): Gemini API 에 지붕형상 descriptor 산출 추가
[작업내용] :
- 응답 스키마에 roofShape{type,ridge,flows,confidence?} 필드
- BASE_PROMPT 에 [지붕형상 판독] 섹션(棟/軒先 → type/ridge/flows, 화면축)
- buildPrompt 입면도 활용을 棟방향·경사 판별 보조로 확대(외곽선 추출은 여전히 평면도만)
- 반환에 normalizeRoofShape(parsed.roofShape) 배선
- 런타임(실제 Gemini roofShape 응답) 수동 검증 잔여
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>"
```
---
## Self-Review
**1. Spec coverage:**
- §3.1 스키마 roofShape → Task 2 Step 2 ✅
- §3.2 프롬프트 [지붕형상 판독] → Task 2 Step 3 ✅
- §3.3 立面図 활용 확대 → Task 2 Step 4 ✅
- §3.4 normalizeRoofShape(어휘 정규화·null 규칙) → Task 1 Step 3 + 하니스 1~8 ✅
- §3.5 반환 배선 → Task 2 Step 5 ✅
- §3.6 confidence 별개(게이팅 없음) → 산출만, route 는 게이팅 안 함 ✅
- §2 direction 비산출 → 스키마·normalize 모두 direction 없음 ✅
- §7 B 검증(normalize TDD + 수동 런타임) → Task 1 하니스 + Task 2 Step 7 ✅
**2. Placeholder scan:** TBD/TODO 없음. 모든 코드 step 실제 코드 포함. Step 7 은 자동화 불가 항목을 명시적 수동 검증으로 분리(placeholder 아님). ✅
**3. Type consistency:** `normalizeRoofShape(raw) => {type,ridge,flows,confidence?}|null` 시그니처가 Task 1 Interfaces·Step 3·하니스, Task 2 Step 5 사용처에서 동일. 반환 필드명 `roofShape` 가 스키마(Step 2)·반환(Step 5)·플랜 C 소비 계약에서 일치. 어휘 상수명 `ROOF_FLOW/ROOF_SHAPE_TYPE/ROOF_RIDGE` 가 기존 모듈 export 와 일치. ✅

View File

@ -0,0 +1,230 @@
# C — 지붕형상 자동 선택(pre-select) wiring Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** PDF 분석이 산출한 지붕형상 descriptor 를 매처에 통과시켜, `RoofShapeSetting` 마운트 시 매칭되는 8패턴 버튼을 자동 선택(pre-select)만 한다(적용은 사용자 [적용] 클릭).
**Architecture:** 신규 Recoil atom `pdfRoofShapeDescriptorState`(outerLineAtom.js)를 매개로, `PlacementShapeSetting.analyzePdfAndApply` 가 분석 결과의 `payload.roofShape` 를 atom 에 저장하고(새 분석 시작 시 null 리셋), `useRoofShapeSetting` 마운트 effect 가 atom 을 읽어 `matchRoofShapePattern` → 매칭되면 `setShapeNum(matched)` 만 호출하고 atom 을 리셋(consume-once)한다. `handleSave` 는 호출하지 않는다.
**Tech Stack:** JavaScript, React, Recoil, Next.js 14. 신규 의존성 없음.
**설계 근거 스펙:** `docs/superpowers/specs/2026-06-24-pdf-roof-shape-auto-detect-design.md` (§4·§5)
**선행 의존:** 플랜 B(응답에 `roofShape: RoofShapeDescriptor | null` 포함). 매처 `matchRoofShapePattern`(커밋 bcdd630a, 기출하).
## Global Constraints
- **Prettier**: 싱글쿼트, **세미콜론 없음**, tabWidth 2, printWidth 150, trailingComma all (`.prettierrc`). 커밋 전 `npx prettier --check`.
- **테스트 러너 없음**: C 는 UI 배선이라 자동 단위테스트 불가(매처는 기검증). 검증 = build 회귀 가드 + 앱 수동 시나리오(별도 테스트 프레임워크 도입 금지).
- **eslint 주의**: eslint 미설치 시 셋업 요구하면 임시 설치 금지·원복.
- **Path alias**: `@/* → ./src/*`.
- **alert 금지**: 직접 `alert()` 금지(이 플랜은 alert 추가 없음).
- **pre-select ONLY**: 매칭 시 `setShapeNum(matched)` 만 — `handleSave`(백엔드 POST + 다음 모달 전환)는 **절대 자동 호출하지 않는다**. 적용은 사용자 몫.
- **consume-once**: 매칭 시도 후 descriptor atom 을 null 로 리셋해 비-PDF 재진입 시 stale 적용을 막는다.
- **null 폴백**: 매처가 null(복합형/판별불가)이면 강제 선택 없이 기본값(`shapeNum` 초기값 1) 유지.
- `<SCRATCH>` = `/private/tmp/claude-501/-Users-devgrr-dev-interplug-hanasys-qcast-front/749df8d5-715e-499d-9009-832aa3cf4f4d/scratchpad`
---
### Task 1: descriptor atom + PlacementShapeSetting 저장/리셋
**Files:**
- Modify: `src/store/outerLineAtom.js` (끝 `:79` 뒤 atom 추가)
- Modify: `src/components/floor-plan/modal/placementShape/PlacementShapeSetting.jsx` (import + setter + analyzePdfAndApply 리셋·저장)
**Interfaces:**
- Produces (Task 2 가 소비): `pdfRoofShapeDescriptorState` atom (default `null`, 값 = `RoofShapeDescriptor | null`).
- Consumes (플랜 B): 분석 응답 `payload.roofShape: RoofShapeDescriptor | null`.
- [ ] **Step 1: atom 추가**
`src/store/outerLineAtom.js` 끝(`autoOuterLineFixState` 다음, `:79` 뒤)에 추가:
```js
/**
* PDF 분석이 산출한 지붕형상 서술자(RoofShapeDescriptor | null).
* analyzePdfAndApply 가 저장 → useRoofShapeSetting 마운트 시 매칭·소비(consume-once).
*/
export const pdfRoofShapeDescriptorState = atom({
key: 'pdfRoofShapeDescriptorState',
default: null,
})
```
- [ ] **Step 2: PlacementShapeSetting import + setter 추가**
`PlacementShapeSetting.jsx` 의 import 블록에 추가(기존 `@/store/...` import 들 근처):
```js
import { pdfRoofShapeDescriptorState } from '@/store/outerLineAtom'
```
`useSetRecoilState` 는 이미 import 되어 있다(`:11`). 컴포넌트 내 setter 선언부(`setCurrentMenu` 선언 `:89` 근처)에 추가:
```js
const setPdfRoofShapeDescriptor = useSetRecoilState(pdfRoofShapeDescriptorState)
```
- [ ] **Step 3: 분석 시작 시 리셋**
`analyzePdfAndApply` 안에서 분석 시작 직전(`setPdfAnalyzing(true)``:177` 앞)에 추가:
```js
setPdfRoofShapeDescriptor(null) // 이전 분석 잔여 제거(cross-run staleness 방지)
setPdfAnalyzing(true)
```
- [ ] **Step 4: payload 검증 통과 후 저장**
`analyzePdfAndApply` 의 최종 가드(`:238-240`) 통과 후, `applyOuterline` 호출(`:243`) **앞**에 추가:
```js
// 저신뢰 confirm 대기 중 언마운트된 경우까지 커버하는 최종 가드
if (pdfRunRef.current?.controller !== controller) {
return false
}
setPdfRoofShapeDescriptor(payload.roofShape ?? null) // 지붕형상 자동 선택용(C)
// 외벽선은 항상 미확정(autoFix:false)으로만 그린다 — 확정 여부는 이후 '외벽선 자동 확정' 팝업에서 분기한다.
const applied = await applyOuterline(payload.outerline, { unit: payload.unit || 'mm', autoFix: false })
```
- [ ] **Step 5: lint / build 검증**
```bash
npx prettier --check src/store/outerLineAtom.js src/components/floor-plan/modal/placementShape/PlacementShapeSetting.jsx
yarn build
```
Expected: prettier 통과, build 통과(atom 은 아직 Task 2 에서 소비 — 저장만으로도 회귀 없음).
- [ ] **Step 6: 커밋**
```bash
git add src/store/outerLineAtom.js src/components/floor-plan/modal/placementShape/PlacementShapeSetting.jsx
git commit -m "feat(roof-shape): PDF 지붕형상 descriptor atom 저장 배선
[작업내용] :
- pdfRoofShapeDescriptorState atom 추가(outerLineAtom)
- analyzePdfAndApply: 분석 시작 시 리셋 + payload.roofShape 저장
- 소비(매칭·pre-select)는 다음 task
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>"
```
---
### Task 2: useRoofShapeSetting 마운트 consume-once pre-select
**Files:**
- Modify: `src/hooks/roofcover/useRoofShapeSetting.js`
- import (`:3` recoil, `:9` outerLineAtom, 신규 roofShapePattern)
- hooks 선언부(`:50-51` 근처)
- 마운트 effect 가드 통과 직후(`:89` 뒤, `:90` `return () => {` 앞)
**Interfaces:**
- Consumes: `pdfRoofShapeDescriptorState`(Task 1), `matchRoofShapePattern(descriptor) => shapeNum | null`(기출하), `setShapeNum`(기존 useState).
- Produces: 없음(side effect — 마운트 시 매칭 버튼 pre-select).
- [ ] **Step 1: import 추가**
`:3` 의 recoil import 에 `useResetRecoilState` 추가:
```js
import { useRecoilState, useRecoilValue, useResetRecoilState, useSetRecoilState } from 'recoil'
```
`:9` 의 outerLineAtom import 에 `pdfRoofShapeDescriptorState` 추가:
```js
import { outerLineFixState, pdfRoofShapeDescriptorState } from '@/store/outerLineAtom'
```
매처 import 추가(`:9` 다음 줄 등 import 블록 내):
```js
import { matchRoofShapePattern } from '@/common/roofShapePattern'
```
- [ ] **Step 2: hooks 선언 추가**
hooks 선언부(`outerLineFix` 선언 `:51` 근처)에 추가:
```js
const pdfRoofShapeDescriptor = useRecoilValue(pdfRoofShapeDescriptorState)
const resetPdfRoofShapeDescriptor = useResetRecoilState(pdfRoofShapeDescriptorState)
```
- [ ] **Step 3: 마운트 effect 에 consume-once pre-select 추가**
마운트 effect(`:83-124`)의 가드 통과 직후 — 가드 if 블록(`:85-89`) 다음, cleanup `return () => {`(`:90`) **앞**에 삽입:
```js
if (!canvas.outerLineFix || outerLines.length === 0) {
swalFire({ text: getMessage('wall.line.not.found') })
closePopup(id)
return
}
// PDF 분석 지붕형상 descriptor 가 있으면 매칭 버튼을 pre-select 한다(자동 선택만 — handleSave 는 사용자 [적용]).
if (pdfRoofShapeDescriptor) {
const matched = matchRoofShapePattern(pdfRoofShapeDescriptor)
if (matched != null) setShapeNum(matched)
resetPdfRoofShapeDescriptor() // consume-once: 비-PDF 재진입 시 stale 적용 방지
}
return () => {
```
(나머지 cleanup 본문은 무변경.)
- [ ] **Step 4: lint / build 검증**
```bash
npx prettier --check src/hooks/roofcover/useRoofShapeSetting.js
yarn build
```
Expected: prettier 통과, build 통과.
- [ ] **Step 5: 수동 시나리오 검증 (best-effort, 보고용)**
자동 테스트 불가 — 구현자는 build 통과까지 보장하고 아래를 "수동 검증 잔여"로 기록:
- 플랜 B 적용 + `NEXT_PUBLIC_ENABLE_LOGGING=true`, `yarn dev`.
- 切妻(가로 용마루) PDF → 외벽선 자동확정 ON → RoofShapeSetting 진입 시 **패턴A(shapeNum 2)** 버튼이 선택(하이라이트)되어 있는지. 寄棟→용마루(1), 片流れ→해당 방위(5~8) 확인.
- 복합형/판별불가 PDF → 아무 버튼도 강제 선택 안 됨(기본 1 유지).
- 적용은 자동 진행되지 않고 사용자가 [적용] 눌러야 다음 모달로 가는지(handleSave 비자동) 확인.
- 사이드바에서 RoofShapeSetting 재진입 시(비-PDF) pre-select 안 되는지(consume-once) 확인.
- [ ] **Step 6: 커밋**
```bash
git add src/hooks/roofcover/useRoofShapeSetting.js
git commit -m "feat(roof-shape): PDF 지붕형상 매칭 버튼 자동 선택(pre-select)
[작업내용] :
- 마운트 시 pdfRoofShapeDescriptorState → matchRoofShapePattern → setShapeNum(매칭 버튼 pre-select)
- pre-select only: handleSave 비자동(사용자 [적용])
- consume-once: 매칭 시도 후 atom 리셋(비-PDF 재진입 stale 방지)
- 매처 null(복합형) 시 강제 선택 없이 기본값 유지
- 런타임(실제 분기별 pre-select) 수동 검증 잔여
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>"
```
---
## Self-Review
**1. Spec coverage:**
- §4.1 atom 추가 → Task 1 Step 1 ✅
- §4.2 저장(payload.roofShape) + 새 분석 리셋 → Task 1 Step 3·4 ✅
- §4.3 consume-once pre-select(setShapeNum only, handleSave 미호출) → Task 2 Step 3 ✅
- §4.4 null 폴백(강제 선택 없음, 기본값 유지) → Task 2 Step 3(`if (matched != null)`) ✅
- §5 데이터 흐름(저장→AUTO_OUTERLINE_FIX→ON→RoofShapeSetting 마운트 매칭) → Task 1·2 합 ✅
- §6 에러/엣지(매처 null graceful, consume-once stale 차단) → Task 2 Step 3 ✅
- §7 C 검증(매처 기검증 + 앱 수동) → Task 2 Step 5 ✅
**2. Placeholder scan:** TBD/TODO 없음. 모든 코드 step 실제 코드 포함. Step 5 는 자동화 불가 항목의 명시적 수동 검증(placeholder 아님). ✅
**3. Type consistency:** atom 명 `pdfRoofShapeDescriptorState` 가 Task 1(선언·저장)·Task 2(소비)에서 동일. `matchRoofShapePattern(descriptor) => shapeNum|null` 시그니처가 매처 출하본과 일치(`!= null` 분기). `setShapeNum` 은 useRoofShapeSetting 기존 useState setter(`:26`). `payload.roofShape` 가 플랜 B 반환 필드명과 일치. recoil 훅 `useResetRecoilState` 추가 정합. ✅

View File

@ -0,0 +1,271 @@
# 지붕형상 패턴 카탈로그 Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** PDF 분석→지붕형상 자동 매칭의 선행 단계로, 지붕형상 설정 팝업의 8개 패턴을 매칭 가능한 시그니처로 정의한 카탈로그 상수 모듈을 추가한다.
**Architecture:** 의존성 없는 순수 데이터 모듈 1개(`src/common/roofShapePattern.js`)를 신규 생성한다. 각 패턴 = `{shapeNum, key, nameKey, type, ridge, flows, direction?, autoMatchable, note}`. 매칭 키는 `type + ridge + flows(집합)`이며 `flows` 집합만으로 8패턴이 상호 유일하게 식별된다. 변 역할 적용(처마/케라바 실제 그리기) 로직은 기존 `useRoofShapeSetting`에 그대로 두고, 이 모듈은 **매칭 룩업 테이블** 역할만 한다.
**Tech Stack:** JavaScript (ESM `export const`), Next.js 14. 신규 의존성 없음.
**설계 근거 스펙:** `docs/superpowers/specs/2026-06-24-roof-shape-pattern-catalog-design.md`
## Global Constraints
- **Prettier**: 싱글쿼트, **세미콜론 없음**, tabWidth 2, printWidth 150, trailingComma all (`.prettierrc`).
- **테스트 러너 없음**: `package.json`에 test runner 미연동. 검증은 scratchpad의 node 불변식 하니스로 수행한다(별도 테스트 프레임워크 도입 금지).
- **eslint 주의**: eslint 미설치 시 `next lint`가 셋업을 요구할 수 있음 — 임시 설치하더라도 **커밋 금지·원복**. (메모리: `eslint-build-skips-lint`)
- **Path alias**: `@/* → ./src/*`. 단, 이 모듈은 import가 없다.
- **로깅/alert 금지 규칙** 해당 없음(순수 데이터 모듈).
- **모듈 형태**: `export const` ESM. `package.json``"type":"module"` 없음 → 소스 `.js`는 CommonJS 취급되므로, 검증 시 `.mjs`로 복사해 import 한다(소스에 import가 없어 그대로 동작).
- **범위 경계**: 매칭 함수(`matchRoofShapePattern`)·PDF 분석 확장은 **이번 작업 제외**. 카탈로그 데이터 + 경량 접근자만.
---
### Task 1: 지붕형상 패턴 카탈로그 모듈 생성
**Files:**
- Create: `src/common/roofShapePattern.js`
- Verify(scratchpad, 비커밋): `<SCRATCH>/check-roofShapePattern.mjs`, `<SCRATCH>/roofShapePattern.mjs`
> `<SCRATCH>` = `/private/tmp/claude-501/-Users-devgrr-dev-interplug-hanasys-qcast-front/15fa1013-fdbe-447b-aa10-6e3008bd6339/scratchpad`
**Interfaces:**
- Consumes: 없음(순수 데이터).
- Produces (다음 매칭 작업이 import):
- `ROOF_FLOW = { TOP:'top', BOTTOM:'bottom', LEFT:'left', RIGHT:'right' }`
- `ROOF_SHAPE_TYPE = { HIP:'hip', GABLE:'gable', SHED:'shed', MANUAL:'manual' }`
- `ROOF_RIDGE = { HIP:'hip', HORIZONTAL:'horizontal', VERTICAL:'vertical', SHED:'shed', NONE:'none' }`
- `ROOF_SHAPE_PATTERNS: Array<{ shapeNum:number, key:string, nameKey:string, type:string, ridge:string, flows:string[], direction?:'west'|'east'|'south'|'north', autoMatchable:boolean, note?:string }>` (8개)
- `getRoofShapePatternByShapeNum(shapeNum:number) => pattern | null`
- [ ] **Step 1: 검증 하니스 작성 (failing test)**
`<SCRATCH>/check-roofShapePattern.mjs` 생성:
```js
import {
ROOF_SHAPE_PATTERNS,
ROOF_FLOW,
ROOF_SHAPE_TYPE,
ROOF_RIDGE,
getRoofShapePatternByShapeNum,
} from './roofShapePattern.mjs'
const fail = (msg) => {
console.error('FAIL:', msg)
process.exit(1)
}
// 1. 정확히 8개
if (ROOF_SHAPE_PATTERNS.length !== 8) fail(`expected 8 patterns, got ${ROOF_SHAPE_PATTERNS.length}`)
// 2. shapeNum 이 정확히 {1..8}
const nums = ROOF_SHAPE_PATTERNS.map((p) => p.shapeNum).sort((a, b) => a - b)
if (JSON.stringify(nums) !== JSON.stringify([1, 2, 3, 4, 5, 6, 7, 8])) fail(`shapeNum set mismatch: ${nums}`)
// 3. flows 집합 distinct (정렬 후 JSON 유일) — 매칭 핵심 불변식
const flowKeys = ROOF_SHAPE_PATTERNS.map((p) => JSON.stringify([...p.flows].sort()))
if (new Set(flowKeys).size !== 8) fail('flows signatures are not mutually distinct')
// 4. autoMatchable === false 는 오직 shapeNum 4
const nonMatch = ROOF_SHAPE_PATTERNS.filter((p) => !p.autoMatchable).map((p) => p.shapeNum)
if (JSON.stringify(nonMatch) !== JSON.stringify([4])) fail(`autoMatchable=false should be only [4], got ${nonMatch}`)
// 5. nameKey 비어있지 않음 / shed(type=SHED)만 direction 보유
for (const p of ROOF_SHAPE_PATTERNS) {
if (typeof p.nameKey !== 'string' || !p.nameKey) fail(`empty nameKey at shapeNum ${p.shapeNum}`)
const isShed = p.type === ROOF_SHAPE_TYPE.SHED
if (isShed && !p.direction) fail(`shed shapeNum ${p.shapeNum} missing direction`)
if (!isShed && p.direction) fail(`non-shed shapeNum ${p.shapeNum} has direction`)
}
// 6. flows 값이 ROOF_FLOW 어휘에 속함 / ridge·type 이 상수 어휘에 속함
const FLOW_VALUES = Object.values(ROOF_FLOW)
const TYPE_VALUES = Object.values(ROOF_SHAPE_TYPE)
const RIDGE_VALUES = Object.values(ROOF_RIDGE)
for (const p of ROOF_SHAPE_PATTERNS) {
if (!TYPE_VALUES.includes(p.type)) fail(`bad type at shapeNum ${p.shapeNum}: ${p.type}`)
if (!RIDGE_VALUES.includes(p.ridge)) fail(`bad ridge at shapeNum ${p.shapeNum}: ${p.ridge}`)
for (const f of p.flows) if (!FLOW_VALUES.includes(f)) fail(`bad flow at shapeNum ${p.shapeNum}: ${f}`)
}
// 7. 접근자
if (getRoofShapePatternByShapeNum(1)?.key !== 'ridge') fail('accessor(1) wrong')
if (getRoofShapePatternByShapeNum(99) !== null) fail('accessor(99) should be null')
console.log('OK: all roofShapePattern invariants hold')
```
- [ ] **Step 2: 실패 확인 (red)**
Run:
```bash
SCRATCH=/private/tmp/claude-501/-Users-devgrr-dev-interplug-hanasys-qcast-front/15fa1013-fdbe-447b-aa10-6e3008bd6339/scratchpad
node "$SCRATCH/check-roofShapePattern.mjs"
```
Expected: FAIL — `Cannot find module '.../roofShapePattern.mjs'` (소스 모듈이 아직 없음).
- [ ] **Step 3: 카탈로그 모듈 작성 (minimal implementation)**
`src/common/roofShapePattern.js` 생성:
```js
// 지붕형상 설정 팝업(RoofShapeSetting)의 8개 패턴 카탈로그.
// PDF 분석→지붕형상 자동 매칭의 룩업 테이블. 매칭 키: type + ridge + flows(집합).
// 설계 근거: docs/superpowers/specs/2026-06-24-roof-shape-pattern-catalog-design.md
// 화면축 흐름방향 (방위적용 前 — 지붕형상 설정 시점에는 실제 방위가 없다)
export const ROOF_FLOW = { TOP: 'top', BOTTOM: 'bottom', LEFT: 'left', RIGHT: 'right' }
// 지붕 유형
export const ROOF_SHAPE_TYPE = { HIP: 'hip', GABLE: 'gable', SHED: 'shed', MANUAL: 'manual' }
// 용마루 방향
export const ROOF_RIDGE = { HIP: 'hip', HORIZONTAL: 'horizontal', VERTICAL: 'vertical', SHED: 'shed', NONE: 'none' }
// shapeNum = useRoofShapeSetting.shapeMenu 의 id 이자 자동 클릭 대상(setShapeNum 값).
// flows 집합만으로 8패턴이 상호 유일하게 식별된다.
// 5~8 화면축↔버튼 매핑은 useRoofShapeSetting case 4~8 에서 전사(정규 winding: outerLines[0].direction==='bottom').
export const ROOF_SHAPE_PATTERNS = [
{
shapeNum: 1,
key: 'ridge',
nameKey: 'modal.roof.shape.setting.ridge',
type: ROOF_SHAPE_TYPE.HIP,
ridge: ROOF_RIDGE.HIP,
flows: [ROOF_FLOW.TOP, ROOF_FLOW.BOTTOM, ROOF_FLOW.LEFT, ROOF_FLOW.RIGHT],
autoMatchable: true,
note: '寄棟 — 전 변 처마, 사방 흐름',
},
{
shapeNum: 2,
key: 'patternA',
nameKey: 'modal.roof.shape.setting.patten.a',
type: ROOF_SHAPE_TYPE.GABLE,
ridge: ROOF_RIDGE.HORIZONTAL,
flows: [ROOF_FLOW.TOP, ROOF_FLOW.BOTTOM],
autoMatchable: true,
note: '切妻 — 상하 처마/좌우 케라바, 가로 용마루',
},
{
shapeNum: 3,
key: 'patternB',
nameKey: 'modal.roof.shape.setting.patten.b',
type: ROOF_SHAPE_TYPE.GABLE,
ridge: ROOF_RIDGE.VERTICAL,
flows: [ROOF_FLOW.LEFT, ROOF_FLOW.RIGHT],
autoMatchable: true,
note: '切妻 — 좌우 처마/상하 케라바, 세로 용마루',
},
{
shapeNum: 5,
key: 'west',
nameKey: 'commons.west',
type: ROOF_SHAPE_TYPE.SHED,
ridge: ROOF_RIDGE.SHED,
flows: [ROOF_FLOW.BOTTOM],
direction: 'west',
autoMatchable: true,
note: '片流れ — 처마/흐름 bottom, 케라바 좌우',
},
{
shapeNum: 6,
key: 'east',
nameKey: 'commons.east',
type: ROOF_SHAPE_TYPE.SHED,
ridge: ROOF_RIDGE.SHED,
flows: [ROOF_FLOW.TOP],
direction: 'east',
autoMatchable: true,
note: '片流れ — 처마/흐름 top, 케라바 좌우',
},
{
shapeNum: 7,
key: 'south',
nameKey: 'commons.south',
type: ROOF_SHAPE_TYPE.SHED,
ridge: ROOF_RIDGE.SHED,
flows: [ROOF_FLOW.RIGHT],
direction: 'south',
autoMatchable: true,
note: '片流れ — 처마/흐름 right, 케라바 상하',
},
{
shapeNum: 8,
key: 'north',
nameKey: 'commons.north',
type: ROOF_SHAPE_TYPE.SHED,
ridge: ROOF_RIDGE.SHED,
flows: [ROOF_FLOW.LEFT],
direction: 'north',
autoMatchable: true,
note: '片流れ — 처마/흐름 left, 케라바 상하',
},
{
// 변별로 설정 — 자동매칭 비대상(폴백). 깨끗한 매칭 실패 시 후보로만 표시.
shapeNum: 4,
key: 'side',
nameKey: 'modal.roof.shape.setting.side',
type: ROOF_SHAPE_TYPE.MANUAL,
ridge: ROOF_RIDGE.NONE,
flows: [],
autoMatchable: false,
note: '변별로 설정 — 자동매칭 비대상(폴백)',
},
]
// shapeNum 으로 카탈로그 항목 조회. 없으면 null.
export const getRoofShapePatternByShapeNum = (shapeNum) => ROOF_SHAPE_PATTERNS.find((pattern) => pattern.shapeNum === shapeNum) || null
```
- [ ] **Step 4: 통과 확인 (green)**
Run:
```bash
SCRATCH=/private/tmp/claude-501/-Users-devgrr-dev-interplug-hanasys-qcast-front/15fa1013-fdbe-447b-aa10-6e3008bd6339/scratchpad
cp src/common/roofShapePattern.js "$SCRATCH/roofShapePattern.mjs"
node "$SCRATCH/check-roofShapePattern.mjs"
```
Expected: `OK: all roofShapePattern invariants hold`
- [ ] **Step 5: lint / build 검증 (sub-agent)**
서브에이전트로 lint·build 검증을 수행한다(프로젝트 컨벤션). lint는 best-effort(eslint 미설치 시 셋업 요구하면 임시 설치 금지·Prettier 규칙 수기 준수로 대체), build는 회귀 가드.
```bash
yarn lint # eslint 미설치 시 셋업 프롬프트면 중단·미설치 유지
```
Expected: 신규 파일에 대한 에러 0. (이 모듈은 아직 어디서도 import 되지 않아 build 트리쉐이킹 대상이지만, 컨벤션상 `yarn build` 통과도 확인.)
- [ ] **Step 6: 커밋**
```bash
git add src/common/roofShapePattern.js
git commit -m "feat(roof-shape): 지붕형상 패턴 카탈로그 상수 모듈 추가
[작업내용] :
- ROOF_SHAPE_PATTERNS(8) + ROOF_FLOW/ROOF_SHAPE_TYPE/ROOF_RIDGE 상수
- 매칭 키 type+ridge+flows, flows 집합만으로 8패턴 유일 식별
- getRoofShapePatternByShapeNum 접근자
- 매칭 함수·PDF 분석 확장은 다음 작업으로 분리
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>"
```
(scratchpad 검증 파일은 리포 밖이라 커밋 대상 아님.)
---
## Self-Review
**1. Spec coverage:**
- §3 데이터 모듈 스키마 → Task 1 Step 3 ✅
- §4 8패턴 카탈로그 내용 → Task 1 Step 3 (8항목, shapeNum/flows/direction 정합) ✅
- §4 경량 접근자 `getRoofShapePatternByShapeNum` → Step 3 ✅
- §6 distinct 불변식 검증 → Step 1·4 하니스(불변식 3) ✅
- §6 lint+build → Step 5 ✅
- §5 매칭 함수 미구현(계약만) → 본 플랜에서 의도적으로 제외(범위 경계) ✅
- §2 5~8 화면축↔버튼 매핑 → Step 3 데이터 + 모듈 주석에 반영 ✅
**2. Placeholder scan:** TBD/TODO/“적절히 처리” 없음. 모든 코드 step에 실제 코드 포함. ✅
**3. Type consistency:** `ROOF_SHAPE_PATTERNS` 항목 형태(`shapeNum/key/nameKey/type/ridge/flows/direction?/autoMatchable/note`)가 Interfaces·Step 3·하니스 검증에서 동일. 접근자명 `getRoofShapePatternByShapeNum` 일관. flows 값은 `ROOF_FLOW` 어휘로 통일. ✅

View File

@ -0,0 +1,190 @@
# 지붕형상 패턴 매처(matchRoofShapePattern) Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** `RoofShapeDescriptor`를 8패턴 카탈로그와 대조해 매칭되는 `shapeNum`(없으면 `null`)을 반환하는 순수 함수 `matchRoofShapePattern`을 카탈로그 모듈에 추가한다.
**Architecture:** 기존 `src/common/roofShapePattern.js`(카탈로그 + `getRoofShapePatternByShapeNum`)에 export 1개를 추가한다. 외부 의존성 0의 순수 함수. flows 집합을 1차 매칭 키로, type/ridge를 모순 검출 guard로 사용. 매칭 실패·모순·무효 입력은 모두 `null`. 변 역할 적용(처마/케라바 그리기)은 기존 `useRoofShapeSetting`에 그대로 두고, 이 함수는 버튼 식별(shapeNum)만 한다.
**Tech Stack:** JavaScript (ESM `export const`), Next.js 14. 신규 의존성 없음.
**설계 근거 스펙:** `docs/superpowers/specs/2026-06-24-roof-shape-pattern-matcher-design.md` (선행: `...-roof-shape-pattern-catalog-design.md` §5)
## Global Constraints
- **Prettier**: 싱글쿼트, **세미콜론 없음**, tabWidth 2, printWidth 150, trailingComma all (`.prettierrc`).
- **테스트 러너 없음**: `package.json`에 test runner 미연동. 검증은 scratchpad의 node 불변식 하니스로 수행한다(별도 테스트 프레임워크 도입 금지).
- **eslint 주의**: eslint 미설치 시 `next lint`가 셋업을 요구할 수 있음 — 임시 설치하더라도 **커밋 금지·원복**. (메모리: `eslint-build-skips-lint`)
- **Path alias**: `@/* → ./src/*`. 단, 이 모듈은 import가 없다.
- **모듈 형태**: `export const` ESM. `package.json``"type":"module"` 없음 → 소스 `.js`는 CommonJS 취급되므로, 검증 시 `.mjs`로 복사해 import 한다(소스에 import가 없어 그대로 동작).
- **범위 경계**: Gemini 분석 확장(B)·UI 자동 클릭 배선(C)은 **이번 작업 제외**. 순수 매칭 함수만.
- `<SCRATCH>` = `/private/tmp/claude-501/-Users-devgrr-dev-interplug-hanasys-qcast-front/749df8d5-715e-499d-9009-832aa3cf4f4d/scratchpad`
---
### Task 1: matchRoofShapePattern 순수 함수 추가
**Files:**
- Modify: `src/common/roofShapePattern.js` (현재 1~106줄: 상수 4종 + `getRoofShapePatternByShapeNum`. 끝에 export 추가)
- Verify(scratchpad, 비커밋): `<SCRATCH>/check-matchRoofShapePattern.mjs`, `<SCRATCH>/roofShapePattern.mjs`
**Interfaces:**
- Consumes (기존 카탈로그 모듈에서):
- `ROOF_FLOW = { TOP:'top', BOTTOM:'bottom', LEFT:'left', RIGHT:'right' }`
- `ROOF_SHAPE_PATTERNS: Array<{ shapeNum, key, nameKey, type, ridge, flows, direction?, autoMatchable, note }>` (8개)
- Produces (다음 작업 C가 import):
- `matchRoofShapePattern(descriptor) => number | null`
- `descriptor = { type?, ridge?, flows, direction?, confidence? }`
- 성공 시 매칭된 `autoMatchable` 패턴의 `shapeNum`(1·2·3·5·6·7·8 중 하나), 실패 시 `null`.
- [ ] **Step 1: 검증 하니스 작성 (failing test)**
`<SCRATCH>/check-matchRoofShapePattern.mjs` 생성:
```js
import { matchRoofShapePattern, ROOF_SHAPE_PATTERNS } from './roofShapePattern.mjs'
const fail = (msg) => {
console.error('FAIL:', msg)
process.exit(1)
}
const eq = (got, want, label) => {
if (got !== want) fail(`${label}: expected ${want}, got ${got}`)
}
// 1. 자기 매칭 — autoMatchable 7패턴 각각 {type,ridge,flows} → 자기 shapeNum
for (const p of ROOF_SHAPE_PATTERNS.filter((x) => x.autoMatchable)) {
const got = matchRoofShapePattern({ type: p.type, ridge: p.ridge, flows: [...p.flows] })
eq(got, p.shapeNum, `self-match shapeNum ${p.shapeNum}`)
}
// 2. 5~8 화면축↔버튼 매핑 정합 (flows 단독)
eq(matchRoofShapePattern({ flows: ['bottom'] }), 5, 'flows[bottom]->5')
eq(matchRoofShapePattern({ flows: ['top'] }), 6, 'flows[top]->6')
eq(matchRoofShapePattern({ flows: ['right'] }), 7, 'flows[right]->7')
eq(matchRoofShapePattern({ flows: ['left'] }), 8, 'flows[left]->8')
// 3. 모순 거부 — flows 는 gable(top,bottom)인데 type=shed → null
eq(matchRoofShapePattern({ type: 'shed', ridge: 'shed', flows: ['top', 'bottom'] }), null, 'contradiction type')
// ridge 모순 — flows {top,bottom} 후보2 ridge=horizontal 인데 vertical → null
eq(matchRoofShapePattern({ type: 'gable', ridge: 'vertical', flows: ['top', 'bottom'] }), null, 'contradiction ridge')
// 4. 무효 입력 → 전부 null
eq(matchRoofShapePattern(null), null, 'null input')
eq(matchRoofShapePattern(undefined), null, 'undefined input')
eq(matchRoofShapePattern({}), null, 'no flows')
eq(matchRoofShapePattern({ flows: 'x' }), null, 'flows not array')
eq(matchRoofShapePattern({ flows: ['up'] }), null, 'flows out of vocab')
eq(matchRoofShapePattern({ flows: ['top', 'up'] }), null, 'flows partially out of vocab')
// 5. 비자동매칭 회피 — manual/none/[] 는 4를 반환하지 않고 null
eq(matchRoofShapePattern({ type: 'manual', ridge: 'none', flows: [] }), null, 'manual flows[] -> null')
eq(matchRoofShapePattern({ flows: [] }), null, 'empty flows -> null')
// 6. flows 순서/중복 무관 → 1(寄棟)
eq(matchRoofShapePattern({ flows: ['right', 'top', 'left', 'bottom'] }), 1, 'order-insensitive ->1')
eq(matchRoofShapePattern({ flows: ['top', 'top', 'bottom', 'left', 'right'] }), 1, 'dup-insensitive ->1')
// 7. type/ridge 결측 관대 → flows 만으로 매칭
eq(matchRoofShapePattern({ flows: ['top', 'bottom'] }), 2, 'missing type/ridge ->2')
eq(matchRoofShapePattern({ flows: ['left', 'right'] }), 3, 'missing type/ridge ->3')
// 8. confidence 무시 — 낮아도 매칭
eq(matchRoofShapePattern({ type: 'hip', ridge: 'hip', flows: ['top', 'bottom', 'left', 'right'], confidence: 0.01 }), 1, 'low confidence ->1')
console.log('OK: all matchRoofShapePattern invariants hold')
```
- [ ] **Step 2: 실패 확인 (red)**
Run:
```bash
SCRATCH=/private/tmp/claude-501/-Users-devgrr-dev-interplug-hanasys-qcast-front/749df8d5-715e-499d-9009-832aa3cf4f4d/scratchpad
cp src/common/roofShapePattern.js "$SCRATCH/roofShapePattern.mjs"
node "$SCRATCH/check-matchRoofShapePattern.mjs"
```
Expected: FAIL — `matchRoofShapePattern is not a function` (아직 export 없음).
- [ ] **Step 3: matchRoofShapePattern 추가 (minimal implementation)**
`src/common/roofShapePattern.js` 끝(`getRoofShapePatternByShapeNum` 다음 줄)에 추가:
```js
// descriptor(RoofShapeDescriptor) → 매칭되는 shapeNum | null.
// 매칭 키: flows 집합(1차) + type/ridge guard(모순 거부). confidence 는 무시(임계값 정책은 호출자).
// 실패·모순·무효 입력은 모두 null — autoMatchable:false(shapeNum 4)는 절대 반환하지 않는다.
export const matchRoofShapePattern = (descriptor) => {
if (!descriptor || typeof descriptor !== 'object') return null
const { type, ridge, flows } = descriptor
if (!Array.isArray(flows)) return null
const FLOW_VALUES = Object.values(ROOF_FLOW)
if (flows.some((flow) => !FLOW_VALUES.includes(flow))) return null
const flowKey = JSON.stringify([...new Set(flows)].sort())
const candidate = ROOF_SHAPE_PATTERNS.find(
(pattern) => pattern.autoMatchable && JSON.stringify([...pattern.flows].sort()) === flowKey,
)
if (!candidate) return null
// type/ridge 가 주어졌는데 후보와 모순이면 거부(결측이면 flows 만으로 통과 — 관대)
if (type && type !== candidate.type) return null
if (ridge && ridge !== candidate.ridge) return null
return candidate.shapeNum
}
```
- [ ] **Step 4: 통과 확인 (green)**
Run:
```bash
SCRATCH=/private/tmp/claude-501/-Users-devgrr-dev-interplug-hanasys-qcast-front/749df8d5-715e-499d-9009-832aa3cf4f4d/scratchpad
cp src/common/roofShapePattern.js "$SCRATCH/roofShapePattern.mjs"
node "$SCRATCH/check-matchRoofShapePattern.mjs"
```
Expected: `OK: all matchRoofShapePattern invariants hold`
- [ ] **Step 5: lint / build 검증 (sub-agent)**
서브에이전트로 lint·build 검증(프로젝트 컨벤션). lint는 best-effort(eslint 미설치 시 셋업 요구하면 임시 설치 금지·Prettier 규칙 수기 준수로 대체), build는 회귀 가드.
```bash
yarn lint # eslint 미설치 시 셋업 프롬프트면 중단·미설치 유지
yarn build # 통과 확인 (이 함수는 아직 import 안 됨 → 트리쉐이킹 대상이나 회귀 가드)
```
Expected: 신규 export에 대한 에러 0.
- [ ] **Step 6: 커밋**
```bash
git add src/common/roofShapePattern.js
git commit -m "feat(roof-shape): 지붕형상 패턴 매처 matchRoofShapePattern 추가
[작업내용] :
- descriptor(type/ridge/flows)→shapeNum|null 순수 매칭 함수
- flows 집합 1차 키 + type/ridge guard(모순 거부, 결측 관대)
- 폴백 null(autoMatchable=false인 4 비반환), confidence 무시
- Gemini 분석 확장(B)·자동 클릭 배선(C)은 다음 작업으로 분리
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>"
```
(scratchpad 검증 파일은 리포 밖이라 커밋 대상 아님.)
---
## Self-Review
**1. Spec coverage:**
- §3 위치(카탈로그 모듈)·시그니처(`shapeNum | null`) → Task 1 Step 3 ✅
- §4 알고리즘(정합성→flows 집합→type/ridge guard→shapeNum) → Step 3 구현 ✅
- §4 폴백 null(4 비반환) → Step 3 + 하니스 불변식 5 ✅
- §4 confidence 무시 → Step 3(미참조) + 불변식 8 ✅
- §4 type/ridge 결측 관대 → Step 3(`if (type && ...)`) + 불변식 7 ✅
- §5 검증 불변식 1~8 → Step 1 하니스 1~8 일대일 대응 ✅
- §5 lint+build → Step 5 ✅
- §6 무변경 대상(useRoofShapeSetting 등) → Step 3는 export 1개 추가만 ✅
**2. Placeholder scan:** TBD/TODO/"적절히 처리" 없음. 모든 코드 step에 실제 코드 포함. ✅
**3. Type consistency:** `matchRoofShapePattern(descriptor) => number | null` 시그니처가 Interfaces·Step 3·하니스에서 동일. `ROOF_FLOW`/`ROOF_SHAPE_PATTERNS` 소비명이 기존 모듈 export명과 일치(카탈로그 커밋 61ffcee9 확인). `autoMatchable`/`flows`/`shapeNum`/`type`/`ridge` 속성명이 카탈로그 데이터와 일치. ✅

View File

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

View File

@ -0,0 +1,134 @@
# 지붕형상 패턴 카탈로그 — 설계 (Design Spec)
- 일자: 2026-06-24
- 브랜치: `feature/pv-btob`
- 상태: 설계 확정 (구현 대기)
## 1. 목적 / 배경
PDF 도면 업로드 → 외벽선 미확정 분석 → **외벽선 자동 확정 팝업**(`AutoOuterLineFixSetting`)까지는 구현 완료. 다음 목표는 **분석된 도면으로부터 지붕 형상을 유추해, 지붕형상 설정 팝업(`RoofShapeSetting`)의 8개 패턴 버튼 중 매칭되는 것을 자동 클릭**하는 기능이다.
그 자동 매칭을 구현하려면, 먼저 **8개 패턴 각각이 "무엇인지"를 매칭 가능한 시그니처로 정의·저장**해 두어야 한다. 본 스펙은 그 **카탈로그 데이터 모듈**만을 다룬다.
### 범위 경계 (중요)
- **이번 작업(본 스펙)**: 8패턴 카탈로그 상수 모듈 1개 + 경량 접근자 + distinct 자기검증.
- **다음 작업(별도)**: ① PDF/Gemini 분석을 확장해 지붕형상 descriptor 산출(屋根伏図의 棟/軒先/ケラバ → 용마루·흐름), ② `matchRoofShapePattern(descriptor) → shapeNum` 매칭 로직, ③ 매칭된 버튼 자동 클릭/적용.
- 본 작업에서 **매칭 함수는 구현하지 않는다.** descriptor 계약(스키마)만 명시해 카탈로그가 다음 단계와 짝이 맞도록 한다. (CLAUDE.md *Simplicity First*)
## 2. 8패턴의 실제 구조 (코드 검증)
근거: `src/hooks/roofcover/useRoofShapeSetting.js``shapeMenu`(L164~173) 와 `handleSave`(L184~486) — `saveRidge` / `saveAPattern` / `saveBPattern` / case 4~8.
| shapeNum | 버튼 nameKey | 지붕유형 | 용마루 | 변 역할 배정 (코드 근거) | 흐름방향(화면축) |
|---|---|---|---|---|---|
| 1 | `modal.roof.shape.setting.ridge` (용마루) | 寄棟 hip | 모서리 hip | **전 변 = 처마**(`saveRidge`) | top·bottom·left·right |
| 2 | `modal.roof.shape.setting.patten.a` (패턴A) | 切妻 gable | 가로 | 상하=처마 / 좌우=케라바(`saveAPattern`) | top·bottom |
| 3 | `modal.roof.shape.setting.patten.b` (패턴B) | 切妻 gable | 세로 | 좌우=처마 / 상하=케라바(`saveBPattern`) | left·right |
| 4 | `modal.roof.shape.setting.side` (변별로 설정) | 수동 | 없음 | 변마다 수동(처마/케라바/벽/팔작/반절처/한쪽흐름) | — |
| 5 | `commons.west` (서) | 片流れ shed | shed | 처마=bottom, shed=top, 케라바=좌우 (case 5) | bottom |
| 6 | `commons.east` (동) | 片流れ shed | shed | 처마=top, shed=bottom, 케라바=좌우 (case 6) | top |
| 7 | `commons.south` (남) | 片流れ shed | shed | 처마=right, shed=left, 케라바=상하 (case 7) | right |
| 8 | `commons.north` (북) | 片流れ shed | shed | 처마=left, shed=right, 케라바=상하 (case 8) | left |
### 5~8 화면축 ↔ 버튼 정확 매핑 (전사 근거)
정규 winding(`outerLines[0].direction === 'bottom'`) 기준. `case 4` 의 SHED `direction` 분기(bottom→east, top→west, left→south, right→north)와 교차검증 일치:
- 처마(flow) **BOTTOM** → 버튼 **5 (서/west)**
- 처마 **TOP** → 버튼 **6 (동/east)**
- 처마 **RIGHT** → 버튼 **7 (남/south)**
- 처마 **LEFT** → 버튼 **8 (북/north)**
> winding 이 다르면 case 5~8 의 `else` 분기가 top/bottom(또는 left/right)을 미러링한다. `initLineSetting`(L488~516)이 `outerLines[0].direction==='right'` 일 때 top↔bottom 을 스왑해 정규화하며, PDF 임포트 좌표는 `usePdfImport.ensureCounterClockwise` 로 CCW 정규화된다. 카탈로그는 **정규 winding 기준 canonical 값**을 기록하고, 다음 단계 매칭기는 동일 정규 프레임의 flow 를 산출해야 한다.
## 3. 시그니처 어휘 (방위적용 前 → 화면축 기준)
지붕형상 설정 시점에는 실제 방위가 없다 — `방위적용`(`MENU.MODULE_CIRCUIT_SETTING.PLAN_ORIENTATION`)은 모듈/회로 단계다. 따라서:
- 흐름방향은 **화면축 4방향**(`top/bottom/left/right`)으로 인코딩한다.
- 5~8 의 `서/동/남/북`은 코드가 쓰는 **앱 고유 라벨**(`direction: 'west'|'east'|'south'|'north'`)로 그대로 보존한다(버튼 정체성).
## 4. 데이터 모듈 설계
신규 파일: **`src/common/roofShapePattern.js`**
```js
// 화면축 흐름방향 (방위적용 前)
export const ROOF_FLOW = { TOP: 'top', BOTTOM: 'bottom', LEFT: 'left', RIGHT: 'right' }
// 지붕 유형
export const ROOF_SHAPE_TYPE = { HIP: 'hip', GABLE: 'gable', SHED: 'shed', MANUAL: 'manual' }
// 용마루 방향
export const ROOF_RIDGE = { HIP: 'hip', HORIZONTAL: 'horizontal', VERTICAL: 'vertical', SHED: 'shed', NONE: 'none' }
/**
* 지붕형상 설정 팝업(RoofShapeSetting)의 8개 버튼 카탈로그.
* shapeNum 은 useRoofShapeSetting.shapeMenu 의 id 이자 자동 클릭 대상(= setShapeNum 값).
* 매칭 키: type + ridge + flows(집합). 8개가 상호 배타적이라 flows 집합만으로도 유일 식별 가능.
*/
export const ROOF_SHAPE_PATTERNS = [
{ shapeNum: 1, key: 'ridge', nameKey: 'modal.roof.shape.setting.ridge',
type: 'hip', ridge: 'hip', flows: ['top', 'bottom', 'left', 'right'], autoMatchable: true,
note: '寄棟 — 전 변 처마, 사방 흐름' },
{ shapeNum: 2, key: 'patternA', nameKey: 'modal.roof.shape.setting.patten.a',
type: 'gable', ridge: 'horizontal', flows: ['top', 'bottom'], autoMatchable: true,
note: '切妻 — 상하 처마/좌우 케라바, 가로 용마루' },
{ shapeNum: 3, key: 'patternB', nameKey: 'modal.roof.shape.setting.patten.b',
type: 'gable', ridge: 'vertical', flows: ['left', 'right'], autoMatchable: true,
note: '切妻 — 좌우 처마/상하 케라바, 세로 용마루' },
{ shapeNum: 5, key: 'west', nameKey: 'commons.west',
type: 'shed', ridge: 'shed', flows: ['bottom'], direction: 'west', autoMatchable: true },
{ shapeNum: 6, key: 'east', nameKey: 'commons.east',
type: 'shed', ridge: 'shed', flows: ['top'], direction: 'east', autoMatchable: true },
{ shapeNum: 7, key: 'south', nameKey: 'commons.south',
type: 'shed', ridge: 'shed', flows: ['right'], direction: 'south', autoMatchable: true },
{ shapeNum: 8, key: 'north', nameKey: 'commons.north',
type: 'shed', ridge: 'shed', flows: ['left'], direction: 'north', autoMatchable: true },
// 변별로 설정 — 자동매칭 비대상(폴백). 깨끗한 매칭 실패 시 후보로만 표시.
{ shapeNum: 4, key: 'side', nameKey: 'modal.roof.shape.setting.side',
type: 'manual', ridge: 'none', flows: [], autoMatchable: false },
]
// 경량 접근자
export const getRoofShapePatternByShapeNum = (shapeNum) =>
ROOF_SHAPE_PATTERNS.find((p) => p.shapeNum === shapeNum) || null
```
설계 원칙:
- 변 역할 배정 로직(처마/케라바 실제 적용)은 **`useRoofShapeSetting` 에 그대로 둔다.** 카탈로그는 **매칭 룩업 테이블** 역할만 하며 역할 배정 로직을 중복 보유하지 않는다(중복 = 유지보수 리스크).
- 자동 클릭은 다음 단계에서 `shapeNum``setShapeNum(shapeNum)` → 기존 `handleSave` 경로로 처리한다(별도 신규 적용 로직 불필요).
## 5. 다음 단계 계약 (본 작업 미구현, 스펙에만 명시)
```
// PDF 분석 확장이 산출할 지붕형상 서술자
RoofShapeDescriptor = {
type: 'hip' | 'gable' | 'shed' | 'manual',
ridge: 'hip' | 'horizontal' | 'vertical' | 'shed' | 'none',
flows: Array<'top'|'bottom'|'left'|'right'>, // 정규 winding 화면축
direction?: 'west'|'east'|'south'|'north', // shed 일 때
confidence?: number, // 0~1
}
// 다음 작업에서 구현:
matchRoofShapePattern(descriptor) -> shapeNum | null // flows 집합 + type/ridge 대조, 실패 시 4(수동) 폴백 or null
```
## 6. 검증
테스트 러너 미연동(CLAUDE.md). 따라서:
- **distinct 불변식**: 8개 항목의 `flows` 집합(+`type`,`ridge`)이 상호 유일함을 보장. 모듈 내 순수 헬퍼 또는 주석으로 문서화(불변식 명시). 별도 테스트 프레임워크는 도입하지 않는다.
- `yarn lint` 통과(에러 0, 경고 최소화) + 빌드 통과.
- Prettier: 싱글쿼트, **세미콜론 없음**, tabWidth 2, printWidth 150.
## 7. 영향 범위 / 변경 파일
- **신규**: `src/common/roofShapePattern.js`
- **무변경**: `useRoofShapeSetting.js`, `RoofShapeSetting.jsx`, Gemini route, `usePdfImport.js` — 본 작업은 데이터 추가만, 기존 플로우 미변경.
- (선택) 문서: 본 스펙으로 충분. CLAUDE.md 갱신은 다음 매칭 단계에서 함께.
## 8. 리스크 / 미해결
- 5~8 winding 의존성: canonical 값으로 기록하되, 다음 매칭기가 동일 정규 프레임의 flow 를 생성해야 정확. (분석 확장 작업의 책임으로 이관.)
- L자·凹凸 복합 footprint: 1·2·3·5~8 의 깔끔한 매칭은 직사각형 계열을 가정. 복합형은 패턴 4(수동) 폴백 — 폴백 판정 정책은 다음 단계.
- 본 카탈로그는 **버튼 식별/매칭 전용**이며, 실제 지붕 생성은 기존 `handleSave` 가 담당한다.

View File

@ -0,0 +1,112 @@
# 지붕형상 패턴 매처(matchRoofShapePattern) — 설계 (Design Spec)
- 일자: 2026-06-24
- 브랜치: `feature/pv-btob`
- 상태: 설계 확정 (구현 대기)
- 선행: `docs/superpowers/specs/2026-06-24-roof-shape-pattern-catalog-design.md` (§5 매처 계약)
## 1. 목적 / 배경
PDF 도면 분석 → 외벽선 자동확정 팝업 → **지붕형상 자동 매칭**으로 이어지는 본작업은 세 조각이다.
- (A) `matchRoofShapePattern(descriptor) → shapeNum` 순수 매칭 함수 — **본 스펙**
- (B) PDF/Gemini 분석 확장 → `RoofShapeDescriptor` 산출 — 별도 작업
- (C) 매칭 버튼 자동 클릭/적용(`setShapeNum → handleSave`) — 별도 작업
8패턴 카탈로그(`src/common/roofShapePattern.js`, 커밋 61ffcee9)는 (A)를 위해 만들어졌다. 본 스펙은 그 카탈로그를 룩업 테이블로 쓰는 **순수 매칭 함수 1개**만 다룬다. B·C는 범위 밖.
### 범위 경계 (중요)
- **이번 작업(본 스펙)**: `matchRoofShapePattern` 순수 함수 + scratchpad 불변식 하니스 검증.
- **제외**: Gemini 스키마/프롬프트 확장(B), UI 자동 클릭 배선(C), descriptor 산출 로직.
- descriptor는 **입력 계약(스키마)**으로만 받는다 — 본 작업은 그 산출 방법을 모른다.
## 2. 입력 계약 — RoofShapeDescriptor
선행 스펙 §5에서 정의된 그대로(B가 산출할 형태):
```
RoofShapeDescriptor = {
type: 'hip' | 'gable' | 'shed' | 'manual',
ridge: 'hip' | 'horizontal' | 'vertical' | 'shed' | 'none',
flows: Array<'top' | 'bottom' | 'left' | 'right'>, // 정규 winding 화면축
direction?: 'west' | 'east' | 'south' | 'north', // shed 일 때
confidence?: number, // 0~1
}
```
- `flows`**정규 winding**(`outerLines[0].direction === 'bottom'`) 기준 화면축 방향 집합. 동일 정규 프레임 생성은 B의 책임(선행 스펙 §2 전사 근거).
- `direction`·`confidence`는 매처가 **소비하지 않는다**(아래 §4 참조).
## 3. 함수 위치 / 시그니처
- **위치**: `src/common/roofShapePattern.js` — 카탈로그와 동일 모듈. 매처는 카탈로그의 1차 소비자이고 외부 의존성이 0이라, 룩업·매처를 한 import 면에 모은다(응집도). 별도 파일 미생성.
- **시그니처**: `matchRoofShapePattern(descriptor) => number | null`
- 성공: 매칭된 `autoMatchable` 패턴의 `shapeNum` (1·2·3·5·6·7·8 중 하나).
- 실패: `null`. (호출자 C가 null → 수동(4) 폴백/재질의 등 UX 정책 결정.)
- shapeNum만 반환한다. direction 등 부가정보는 호출자가 `getRoofShapePatternByShapeNum(shapeNum)`으로 재조회(합성, YAGNI).
## 4. 매칭 알고리즘 (flows 집합 + type/ridge 대조)
```
matchRoofShapePattern(descriptor):
1. 정합성 검사 → 위반 시 null
- descriptor 가 비객체/null → null
- descriptor.flows 가 배열 아님 → null
- flows 원소 중 ROOF_FLOW 어휘(top/bottom/left/right) 밖 값 존재 → null
2. flowKey = JSON.stringify(dedup(flows).sort()) // 집합 정규화
3. 후보 = ROOF_SHAPE_PATTERNS 중 autoMatchable === true 이고
JSON.stringify([...p.flows].sort()) === flowKey 인 패턴
- 후보 없음 → null (flows=[] 는 4(manual, autoMatchable=false)만 가지므로 자동매칭 후보 없음 → null)
4. type/ridge guard:
- descriptor.type 이 존재하고 후보.type 과 불일치 → null
- descriptor.ridge 가 존재하고 후보.ridge 와 불일치 → null
(모순 descriptor 거부. type/ridge 결측이면 flows 만으로 통과 — 관대)
5. 후보.shapeNum 반환
```
### 설계 판단 (확정)
- **폴백 = `null`** (4 아님). 매처는 순수 구조매칭으로 정직하게 둔다. `autoMatchable:false`인 shapeNum 4는 매칭 결과로 **절대 반환하지 않는다**. "null → 수동(4) 열기/재질의" 정책은 C 단계로 위임.
- **confidence 무시**. 임계값 게이팅은 호출자(B/C) 책임. 매처는 confidence-agnostic.
- **type/ridge guard 적용**. flows 집합이 1차 키, type/ridge는 모순 검출용 guard. flows는 gable 시그니처인데 type='shed' 같은 모순 descriptor를 거부. 단, type/ridge가 **결측(falsy)**이면 guard를 건너뛴다(flows만으로 식별 — 8패턴 flows 집합이 상호 유일하므로 안전).
### flows 집합 유일성 (카탈로그 불변식 재확인)
| shapeNum | flows 집합 | type | ridge |
|---|---|---|---|
| 1 | {top,bottom,left,right} | hip | hip |
| 2 | {top,bottom} | gable | horizontal |
| 3 | {left,right} | gable | vertical |
| 5 | {bottom} | shed | shed |
| 6 | {top} | shed | shed |
| 7 | {right} | shed | shed |
| 8 | {left} | shed | shed |
| 4 | {} (autoMatchable=false) | manual | none |
autoMatchable 7패턴의 flows 집합이 상호 유일 → flows만으로 1차 식별 성립.
## 5. 검증 (테스트 러너 미연동)
CLAUDE.md: `package.json`에 test runner 없음. Task 1과 동일하게 scratchpad `.mjs` 불변식 하니스로 검증(별도 프레임워크 도입 금지). 소스 `.js``.mjs`로 복사해 ESM import.
불변식:
1. **자기 매칭**: autoMatchable 7패턴 각각 → `{type, ridge, flows}` descriptor가 자기 `shapeNum`으로 매칭.
2. **5~8 화면축↔버튼 매핑 정합**: flows `[bottom]→5`, `[top]→6`, `[right]→7`, `[left]→8`.
3. **모순 거부**: `{type:'shed', ridge:'shed', flows:['top','bottom']}`(flows는 gable인데 type=shed) → null.
4. **무효 입력**: `null`, `{}`, `{flows: 'x'}`, `{flows: ['up']}`(어휘 밖) → 전부 null.
5. **비자동매칭 회피**: `{type:'manual', ridge:'none', flows:[]}` → null (4를 반환하지 않음).
6. **flows 순서/중복 무관**: `['right','top','left','bottom']`, `['top','top','bottom','left','right']` → 1.
7. **type/ridge 결측 관대**: `{flows:['top','bottom']}`(type/ridge 없음) → 2.
8. **confidence 무시**: `{type:'hip', ridge:'hip', flows:[...4방], confidence:0.01}` → 1 (낮아도 매칭).
추가: `yarn lint`(에러 0, 경고 최소화) + `yarn build` 통과(회귀 가드). Prettier: 싱글쿼트, **세미콜론 없음**, tabWidth 2, printWidth 150.
## 6. 영향 범위 / 변경 파일
- **수정**: `src/common/roofShapePattern.js``matchRoofShapePattern` export 1개 추가. 기존 상수·접근자 무변경.
- **무변경**: `useRoofShapeSetting.js`, `RoofShapeSetting.jsx`, Gemini route, `usePdfImport.js` — 본 작업은 순수 함수 추가만. 아직 어디서도 import 안 됨(트리쉐이킹 대상).
- 문서: 본 스펙 + 구현 플랜.
## 7. 리스크 / 미해결
- **descriptor 정규 프레임 의존성**: 매처는 정규 winding flows를 가정. 비정규 프레임 flows 산출은 B의 책임(선행 스펙 §2·§8에서 이관됨).
- **L자·凹凸 복합 footprint**: 깔끔한 8패턴 매칭은 직사각형 계열 가정. 복합형은 flows 집합이 8패턴 어느 것과도 안 맞아 자연히 null → C가 수동(4) 폴백. 폴백 판정 정책은 C.
- 본 매처는 **버튼 식별 전용**. 실제 지붕 생성은 기존 `handleSave`가 담당(선행 스펙 §4 원칙 유지).

View File

@ -7,6 +7,7 @@ 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'
import { normalizeRoofShape } from '@/common/roofShapePattern'
const MAX_FILE_BYTES = 20 * 1024 * 1024 // 20MB const MAX_FILE_BYTES = 20 * 1024 * 1024 // 20MB
const MAX_VERTICES = 500 const MAX_VERTICES = 500
@ -51,6 +52,16 @@ const FLOOR_PLAN_SCHEMA = {
confidence: { type: SchemaType.NUMBER }, confidence: { type: SchemaType.NUMBER },
notes: { type: SchemaType.STRING }, notes: { type: SchemaType.STRING },
selectedDrawingType: { type: SchemaType.STRING }, selectedDrawingType: { type: SchemaType.STRING },
roofShape: {
type: SchemaType.OBJECT,
properties: {
type: { type: SchemaType.STRING },
ridge: { type: SchemaType.STRING },
flows: { type: SchemaType.ARRAY, items: { type: SchemaType.STRING } },
confidence: { type: SchemaType.NUMBER },
},
required: ['type', 'ridge', 'flows'],
},
}, },
required: ['outerline', 'unit'], required: ['outerline', 'unit'],
} }
@ -81,6 +92,19 @@ const BASE_PROMPT = `당신은 일본 주택 건축 도면에서 "지붕 투영
6. 분리된 복수 () 있으면 가장 건물 하나만 반환하고, 별동은 notes 기재합니다. 6. 분리된 복수 () 있으면 가장 건물 하나만 반환하고, 별동은 notes 기재합니다.
7. 지붕 투영을 추출할 없으면 outerline 배열로 반환하고 notes 사유를 적습니다. 7. 지붕 투영을 추출할 없으면 outerline 배열로 반환하고 notes 사유를 적습니다.
[지붕형상 판독 屋根伏図 ·軒先 + 立面図 보조]
- 屋根伏図의 (용마루) 위치·개수와 軒先(처마) 흘러내리는 방향으로 지붕 유형을 판별합니다.
- type: 寄棟'hip', 切妻'gable', 片流れ(단일경사)'shed', 판별 불가/복합형'manual'.
- ridge(용마루 방향): 寄棟'hip', 切妻 가로 용마루'horizontal', 切妻 세로 용마루'vertical', 片流れ'shed', 없음/복합'none'.
- flows: 처마가 흘러내리는 방향을 화면축 집합으로 기재합니다. 좌표계는 외곽선과 동일(좌상단 원점, =top·아래=bottom·왼쪽=left·오른쪽=right).
· 寄棟: ['top','bottom','left','right'] (사방 흐름)
· 切妻 가로 용마루(용마루가 좌우로 길게): ['top','bottom']
· 切妻 세로 용마루(용마루가 상하로 길게): ['left','right']
· 片流れ: 처마가 향하는 단일 방향 1개만, : ['bottom']
· 복합형/판별 불가: [] ( 경우 type='manual', ridge='none')
- 立面図(입면도) 있으면 棟방향·경사 방향 판별의 보조로만 활용하세요(외곽선/정점 추출에는 절대 사용 금지).
- 확신이 없으면 roofShape.confidence 낮게 주거나 roofShape 자체를 생략하세요(틀린 추정보다 누락이 낫습니다).
[출력] [출력]
- 응답은 반드시 단일 JSON 객체만 포함하며, 다른 텍스트, 마크다운, 코드펜스를 포함하지 마세요. - 응답은 반드시 단일 JSON 객체만 포함하며, 다른 텍스트, 마크다운, 코드펜스를 포함하지 마세요.
- 좌표는 정수 또는 소수 첫째 자리까지 허용합니다. - 좌표는 정수 또는 소수 첫째 자리까지 허용합니다.
@ -93,7 +117,8 @@ const BASE_PROMPT = `당신은 일본 주택 건축 도면에서 "지붕 투영
"scale": number (선택, 축척 분모. 1/100 이면 100), "scale": number (선택, 축척 분모. 1/100 이면 100),
"confidence": number (0~1), "confidence": number (0~1),
"notes": string (선택), "notes": string (선택),
"selectedDrawingType": string (선택) "selectedDrawingType": string (선택),
"roofShape": { "type": "hip|gable|shed|manual", "ridge": "hip|horizontal|vertical|shed|none", "flows": ["top|bottom|left|right", ...], "confidence": number } (선택)
}` }`
const buildPrompt = ({ pageMode, facadePages, floorPages, splitInfo } = {}) => { const buildPrompt = ({ pageMode, facadePages, floorPages, splitInfo } = {}) => {
@ -103,13 +128,13 @@ const buildPrompt = ({ pageMode, facadePages, floorPages, splitInfo } = {}) => {
if (splitInfo.floorCount > 0) segs.push(`평면도/지붕평면도 ${splitInfo.floorCount}`) if (splitInfo.floorCount > 0) segs.push(`평면도/지붕평면도 ${splitInfo.floorCount}`)
if (splitInfo.facadeCount > 0) segs.push(`입면도 ${splitInfo.facadeCount}`) if (splitInfo.facadeCount > 0) segs.push(`입면도 ${splitInfo.facadeCount}`)
const compo = `첨부 PDF 는 ${segs.join('과 ')}으로 구성됩니다(평면도가 앞).` const compo = `첨부 PDF 는 ${segs.join('과 ')}으로 구성됩니다(평면도가 앞).`
return `${BASE_PROMPT}\n\n${compo} 외곽선은 평면도/지붕평면도 페이지에서만 추출하고, 입면도 페이지는 외곽선 추출에 쓰지 말고 높이·층수 참고로만 사용하세요.` 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}`)
if (facadePages) hints.push(`입면도(立面図) 페이지: ${facadePages}`) if (facadePages) hints.push(`입면도(立面図) 페이지: ${facadePages}`)
return `${BASE_PROMPT}\n\n페이지 힌트: ${hints.join(' / ')}. 입면도 페이지는 외곽선 추출에 쓰지 말고 높이·층수 참고로만 사용하세요. 지붕 투영 외곽선은 지정된 평면도/지붕평면도 페이지 기준으로 추출하세요.` return `${BASE_PROMPT}\n\n페이지 힌트: ${hints.join(' / ')}. 입면도 페이지는 외곽선 추출에 쓰지 말고 높이·층수와 지붕 棟방향·경사 방향(지붕형상 판별) 참고로만 사용하세요. 지붕 투영 외곽선은 지정된 평면도/지붕평면도 페이지 기준으로 추출하세요.`
} }
return BASE_PROMPT return BASE_PROMPT
} }
@ -202,10 +227,7 @@ export async function POST(req) {
return NextResponse.json({ error: { code: 'UNAUTHORIZED', message: '로그인이 필요합니다.' } }, { status: 401 }) return NextResponse.json({ error: { code: 'UNAUTHORIZED', message: '로그인이 필요합니다.' } }, { status: 401 })
} }
if (isRateLimited(session.userId || 'unknown')) { if (isRateLimited(session.userId || 'unknown')) {
return NextResponse.json( return NextResponse.json({ error: { code: 'RATE_LIMITED', message: '분석 요청이 너무 잦습니다. 잠시 후 다시 시도해주세요.' } }, { status: 429 })
{ error: { code: 'RATE_LIMITED', message: '분석 요청이 너무 잦습니다. 잠시 후 다시 시도해주세요.' } },
{ status: 429 },
)
} }
if (!apiKey) { if (!apiKey) {
@ -321,6 +343,10 @@ export async function POST(req) {
return NextResponse.json({ error: { code: validation.code, message: validation.message } }, { status: 422 }) return NextResponse.json({ error: { code: validation.code, message: validation.message } }, { status: 422 })
} }
const roofShape = normalizeRoofShape(parsed.roofShape)
// 수동 QA 용 — Gemini 가 판독한 지붕형상 원본과 정규화 결과를 함께 남긴다(NEXT_PUBLIC_ENABLE_LOGGING=true 시).
logger.debug('[gemini/floor-plan] roofShape 판독', { raw: parsed.roofShape ?? null, normalized: roofShape })
return NextResponse.json({ return NextResponse.json({
outerline: parsed.outerline, outerline: parsed.outerline,
unit: parsed.unit || 'mm', unit: parsed.unit || 'mm',
@ -328,6 +354,7 @@ export async function POST(req) {
confidence: parsed.confidence ?? null, confidence: parsed.confidence ?? null,
notes: parsed.notes ?? null, notes: parsed.notes ?? null,
selectedDrawingType: parsed.selectedDrawingType ?? null, selectedDrawingType: parsed.selectedDrawingType ?? null,
roofShape,
empty: validation.empty, empty: validation.empty,
}) })
} catch (error) { } catch (error) {

View File

@ -0,0 +1,150 @@
// 지붕형상 설정 팝업(RoofShapeSetting)의 8개 패턴 카탈로그.
// PDF 분석→지붕형상 자동 매칭의 룩업 테이블. 매칭 키: type + ridge + flows(집합).
// 설계 근거: docs/superpowers/specs/2026-06-24-roof-shape-pattern-catalog-design.md
// 화면축 흐름방향 (방위적용 前 — 지붕형상 설정 시점에는 실제 방위가 없다)
export const ROOF_FLOW = { TOP: 'top', BOTTOM: 'bottom', LEFT: 'left', RIGHT: 'right' }
// 지붕 유형
export const ROOF_SHAPE_TYPE = { HIP: 'hip', GABLE: 'gable', SHED: 'shed', MANUAL: 'manual' }
// 용마루 방향
export const ROOF_RIDGE = { HIP: 'hip', HORIZONTAL: 'horizontal', VERTICAL: 'vertical', SHED: 'shed', NONE: 'none' }
// shapeNum = useRoofShapeSetting.shapeMenu 의 id 이자 자동 클릭 대상(setShapeNum 값).
// flows 집합만으로 8패턴이 상호 유일하게 식별된다.
// 5~8 화면축↔버튼 매핑은 useRoofShapeSetting case 4~8 에서 전사(정규 winding: outerLines[0].direction==='bottom').
export const ROOF_SHAPE_PATTERNS = [
{
shapeNum: 1,
key: 'ridge',
nameKey: 'modal.roof.shape.setting.ridge',
type: ROOF_SHAPE_TYPE.HIP,
ridge: ROOF_RIDGE.HIP,
flows: [ROOF_FLOW.TOP, ROOF_FLOW.BOTTOM, ROOF_FLOW.LEFT, ROOF_FLOW.RIGHT],
autoMatchable: true,
note: '寄棟 — 전 변 처마, 사방 흐름',
},
{
shapeNum: 2,
key: 'patternA',
nameKey: 'modal.roof.shape.setting.patten.a',
type: ROOF_SHAPE_TYPE.GABLE,
ridge: ROOF_RIDGE.HORIZONTAL,
flows: [ROOF_FLOW.TOP, ROOF_FLOW.BOTTOM],
autoMatchable: true,
note: '切妻 — 상하 처마/좌우 케라바, 가로 용마루',
},
{
shapeNum: 3,
key: 'patternB',
nameKey: 'modal.roof.shape.setting.patten.b',
type: ROOF_SHAPE_TYPE.GABLE,
ridge: ROOF_RIDGE.VERTICAL,
flows: [ROOF_FLOW.LEFT, ROOF_FLOW.RIGHT],
autoMatchable: true,
note: '切妻 — 좌우 처마/상하 케라바, 세로 용마루',
},
{
shapeNum: 5,
key: 'west',
nameKey: 'commons.west',
type: ROOF_SHAPE_TYPE.SHED,
ridge: ROOF_RIDGE.SHED,
flows: [ROOF_FLOW.BOTTOM],
direction: 'west',
autoMatchable: true,
note: '片流れ — 처마/흐름 bottom, 케라바 좌우',
},
{
shapeNum: 6,
key: 'east',
nameKey: 'commons.east',
type: ROOF_SHAPE_TYPE.SHED,
ridge: ROOF_RIDGE.SHED,
flows: [ROOF_FLOW.TOP],
direction: 'east',
autoMatchable: true,
note: '片流れ — 처마/흐름 top, 케라바 좌우',
},
{
shapeNum: 7,
key: 'south',
nameKey: 'commons.south',
type: ROOF_SHAPE_TYPE.SHED,
ridge: ROOF_RIDGE.SHED,
flows: [ROOF_FLOW.RIGHT],
direction: 'south',
autoMatchable: true,
note: '片流れ — 처마/흐름 right, 케라바 상하',
},
{
shapeNum: 8,
key: 'north',
nameKey: 'commons.north',
type: ROOF_SHAPE_TYPE.SHED,
ridge: ROOF_RIDGE.SHED,
flows: [ROOF_FLOW.LEFT],
direction: 'north',
autoMatchable: true,
note: '片流れ — 처마/흐름 left, 케라바 상하',
},
{
// 변별로 설정 — 자동매칭 비대상(폴백). 깨끗한 매칭 실패 시 후보로만 표시.
shapeNum: 4,
key: 'side',
nameKey: 'modal.roof.shape.setting.side',
type: ROOF_SHAPE_TYPE.MANUAL,
ridge: ROOF_RIDGE.NONE,
flows: [],
autoMatchable: false,
note: '변별로 설정 — 자동매칭 비대상(폴백)',
},
]
// shapeNum 으로 카탈로그 항목 조회. 없으면 null.
export const getRoofShapePatternByShapeNum = (shapeNum) => ROOF_SHAPE_PATTERNS.find((pattern) => pattern.shapeNum === shapeNum) || null
// descriptor(RoofShapeDescriptor) → 매칭되는 shapeNum | null.
// 매칭 키: flows 집합(1차) + type/ridge guard(모순 거부). confidence 는 무시(임계값 정책은 호출자).
// 실패·모순·무효 입력은 모두 null — autoMatchable:false(shapeNum 4)는 절대 반환하지 않는다.
export const matchRoofShapePattern = (descriptor) => {
if (!descriptor || typeof descriptor !== 'object') return null
const { type, ridge, flows } = descriptor
if (!Array.isArray(flows)) return null
const FLOW_VALUES = Object.values(ROOF_FLOW)
if (flows.some((flow) => !FLOW_VALUES.includes(flow))) return null
const flowKey = JSON.stringify([...new Set(flows)].sort())
const candidate = ROOF_SHAPE_PATTERNS.find((pattern) => pattern.autoMatchable && JSON.stringify([...pattern.flows].sort()) === flowKey)
if (!candidate) return null
// type/ridge 가 주어졌는데 후보와 모순이면 거부(결측이면 flows 만으로 통과 — 관대)
if (type && type !== candidate.type) return null
if (ridge && ridge !== candidate.ridge) return null
return candidate.shapeNum
}
// Gemini 자유 출력(raw) → 매처 strict 어휘로 정규화한 RoofShapeDescriptor | null.
// 어휘 밖 type/ridge 는 ''(매처가 결측 관대), flows 는 ROOF_FLOW 어휘만 소문자화·중복제거.
// type·ridge·flows 가 모두 비면 null. direction 은 산출하지 않는다(매처 미사용).
export const normalizeRoofShape = (raw) => {
if (!raw || typeof raw !== 'object') return null
const norm = (value) => (typeof value === 'string' ? value.trim().toLowerCase() : '')
const TYPE_VALUES = Object.values(ROOF_SHAPE_TYPE)
const RIDGE_VALUES = Object.values(ROOF_RIDGE)
const FLOW_VALUES = Object.values(ROOF_FLOW)
const type = TYPE_VALUES.includes(norm(raw.type)) ? norm(raw.type) : ''
const ridge = RIDGE_VALUES.includes(norm(raw.ridge)) ? norm(raw.ridge) : ''
const flows = Array.isArray(raw.flows) ? [...new Set(raw.flows.map(norm).filter((flow) => FLOW_VALUES.includes(flow)))] : []
if (!type && !ridge && flows.length === 0) return null
const result = { type, ridge, flows }
if (typeof raw.confidence === 'number') result.confidence = raw.confidence
return result
}

View File

@ -24,6 +24,7 @@ import { usePlan } from '@/hooks/usePlan'
import { normalizeDecimal } from '@/util/input-utils' import { normalizeDecimal } from '@/util/input-utils'
import { CalculatorInput } from '@/components/common/input/CalcInput' import { CalculatorInput } from '@/components/common/input/CalcInput'
import { usePdfImport } from '@/hooks/pdf-import/usePdfImport' import { usePdfImport } from '@/hooks/pdf-import/usePdfImport'
import { pdfRoofShapeDescriptorState } from '@/store/outerLineAtom'
import { logger } from '@/util/logger' import { logger } from '@/util/logger'
import { QcastContext } from '@/app/QcastProvider' import { QcastContext } from '@/app/QcastProvider'
// [LOW-PITCH-WARN 2026-05-06] + alert // [LOW-PITCH-WARN 2026-05-06] + alert
@ -87,6 +88,7 @@ export default function PlacementShapeSetting({ id, pos = { x: 50, y: 180 }, pla
const { setPolygonLinesActualSize } = usePolygon() const { setPolygonLinesActualSize } = usePolygon()
const { setSelectedMenu } = useCanvasMenu() const { setSelectedMenu } = useCanvasMenu()
const setCurrentMenu = useSetRecoilState(currentMenuState) const setCurrentMenu = useSetRecoilState(currentMenuState)
const setPdfRoofShapeDescriptor = useSetRecoilState(pdfRoofShapeDescriptorState)
const roofRef = { const roofRef = {
roofCd: useRef(null), roofCd: useRef(null),
@ -174,6 +176,7 @@ export default function PlacementShapeSetting({ id, pos = { x: 50, y: 180 }, pla
formData.append('floorPages', pdfFloorPages.trim()) formData.append('floorPages', pdfFloorPages.trim())
} }
setPdfRoofShapeDescriptor(null) // (cross-run staleness )
setPdfAnalyzing(true) setPdfAnalyzing(true)
setIsGlobalLoading(true) setIsGlobalLoading(true)
const controller = new AbortController() const controller = new AbortController()
@ -239,6 +242,8 @@ export default function PlacementShapeSetting({ id, pos = { x: 50, y: 180 }, pla
return false return false
} }
setPdfRoofShapeDescriptor(payload.roofShape ?? null) // (C)
// (autoFix:false) ' ' . // (autoFix:false) ' ' .
const applied = await applyOuterline(payload.outerline, { unit: payload.unit || 'mm', autoFix: false }) const applied = await applyOuterline(payload.outerline, { unit: payload.unit || 'mm', autoFix: false })
if (!applied) { if (!applied) {

View File

@ -1,12 +1,14 @@
import { useEffect, useRef, useState } from 'react' import { useEffect, useRef, useState } from 'react'
import { useMessage } from '@/hooks/useMessage' import { useMessage } from '@/hooks/useMessage'
import { useRecoilState, useRecoilValue, useSetRecoilState } from 'recoil' import { useRecoilState, useRecoilValue, useResetRecoilState, useSetRecoilState } from 'recoil'
import { ANGLE_TYPE, canvasState, currentAngleTypeSelector, currentMenuState, currentObjectState, pitchTextSelector } from '@/store/canvasAtom' import { ANGLE_TYPE, canvasState, currentAngleTypeSelector, currentMenuState, currentObjectState, pitchTextSelector } from '@/store/canvasAtom'
import { LINE_TYPE, POLYGON_TYPE } from '@/common/common' import { LINE_TYPE, POLYGON_TYPE } from '@/common/common'
import { usePolygon } from '@/hooks/usePolygon' import { usePolygon } from '@/hooks/usePolygon'
import { useMode } from '@/hooks/useMode' import { useMode } from '@/hooks/useMode'
import { useLine } from '@/hooks/useLine' import { useLine } from '@/hooks/useLine'
import { outerLineFixState } from '@/store/outerLineAtom' import { outerLineFixState, pdfRoofShapeDescriptorState } from '@/store/outerLineAtom'
import { matchRoofShapePattern, getRoofShapePatternByShapeNum } from '@/common/roofShapePattern'
import { logger } from '@/util/logger'
import { useSwal } from '@/hooks/useSwal' import { useSwal } from '@/hooks/useSwal'
import { usePopup } from '@/hooks/usePopup' import { usePopup } from '@/hooks/usePopup'
import { getChonByDegree, getDegreeByChon } from '@/util/canvas-util' import { getChonByDegree, getDegreeByChon } from '@/util/canvas-util'
@ -49,6 +51,8 @@ export function useRoofShapeSetting(id) {
const setCurrentMenu = useSetRecoilState(currentMenuState) const setCurrentMenu = useSetRecoilState(currentMenuState)
const outerLineFix = useRecoilValue(outerLineFixState) const outerLineFix = useRecoilValue(outerLineFixState)
const pdfRoofShapeDescriptor = useRecoilValue(pdfRoofShapeDescriptorState)
const resetPdfRoofShapeDescriptor = useResetRecoilState(pdfRoofShapeDescriptorState)
const isFixRef = useRef(false) const isFixRef = useRef(false)
@ -87,6 +91,22 @@ export function useRoofShapeSetting(id) {
closePopup(id) closePopup(id)
return return
} }
// PDF 분석 지붕형상 descriptor 가 있으면 매칭 버튼을 pre-select 한다(자동 선택만 — handleSave 는 사용자 [적용]).
if (pdfRoofShapeDescriptor) {
const matched = matchRoofShapePattern(pdfRoofShapeDescriptor)
const pattern = matched != null ? getRoofShapePatternByShapeNum(matched) : null
// 수동 QA 용 — Gemini 가 판독한 지붕형상과 자동 선택된 버튼을 콘솔에 남긴다(NEXT_PUBLIC_ENABLE_LOGGING=true 시).
logger.info('[지붕형상 자동판독]', {
descriptor: pdfRoofShapeDescriptor,
matchedShapeNum: matched,
pattern: pattern ? `${pattern.shapeNum}:${pattern.key}(${pattern.note})` : null,
result: matched != null ? 'pre-select(사용자 [적용] 대기)' : '매칭 실패 → 수동 선택(기본값 유지)',
})
if (matched != null) setShapeNum(matched)
resetPdfRoofShapeDescriptor() // consume-once: 비-PDF 재진입 시 stale 적용 방지
}
return () => { return () => {
if (!isFixRef.current) { if (!isFixRef.current) {
return return

View File

@ -77,3 +77,12 @@ export const autoOuterLineFixState = atom({
key: 'autoOuterLineFixState', key: 'autoOuterLineFixState',
default: true, default: true,
}) })
/**
* PDF 분석이 산출한 지붕형상 서술자(RoofShapeDescriptor | null).
* analyzePdfAndApply 저장 useRoofShapeSetting 마운트 매칭·소비(consume-once).
*/
export const pdfRoofShapeDescriptorState = atom({
key: 'pdfRoofShapeDescriptorState',
default: null,
})