Compare commits
14 Commits
ca2b0c3dae
...
6457817e70
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6457817e70 | ||
|
|
729198b650 | ||
|
|
edeb766b3a | ||
|
|
28ea1e9b8e | ||
|
|
9d60948e64 | ||
|
|
3f8ead0a3e | ||
|
|
4ebe02186f | ||
|
|
4e919923d8 | ||
|
|
bcdd630a00 | ||
|
|
cfd8e3bdef | ||
|
|
cb9912b31d | ||
|
|
e4045142bf | ||
|
|
61ffcee949 | ||
|
|
105c8470ad |
@ -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).
|
||||
|
||||
**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
|
||||
|
||||
|
||||
@ -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 와 일치. ✅
|
||||
230
docs/superpowers/plans/2026-06-24-roof-shape-C-auto-preselect.md
Normal file
230
docs/superpowers/plans/2026-06-24-roof-shape-C-auto-preselect.md
Normal 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` 추가 정합. ✅
|
||||
271
docs/superpowers/plans/2026-06-24-roof-shape-pattern-catalog.md
Normal file
271
docs/superpowers/plans/2026-06-24-roof-shape-pattern-catalog.md
Normal 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` 어휘로 통일. ✅
|
||||
190
docs/superpowers/plans/2026-06-24-roof-shape-pattern-matcher.md
Normal file
190
docs/superpowers/plans/2026-06-24-roof-shape-pattern-matcher.md
Normal 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` 속성명이 카탈로그 데이터와 일치. ✅
|
||||
@ -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 확대를 축소(屋根伏図 단독)할 수 있음.
|
||||
@ -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` 가 담당한다.
|
||||
@ -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 원칙 유지).
|
||||
@ -7,6 +7,7 @@ import { PDFDocument } from 'pdf-lib'
|
||||
|
||||
import { sessionOptions } from '@/lib/session'
|
||||
import { logger } from '@/util/logger'
|
||||
import { normalizeRoofShape } from '@/common/roofShapePattern'
|
||||
|
||||
const MAX_FILE_BYTES = 20 * 1024 * 1024 // 20MB
|
||||
const MAX_VERTICES = 500
|
||||
@ -51,6 +52,16 @@ const FLOOR_PLAN_SCHEMA = {
|
||||
confidence: { type: SchemaType.NUMBER },
|
||||
notes: { 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'],
|
||||
}
|
||||
@ -81,6 +92,19 @@ const BASE_PROMPT = `당신은 일본 주택 건축 도면에서 "지붕 투영
|
||||
6. 분리된 복수 동(棟)이 있으면 가장 큰 주 건물 하나만 반환하고, 별동은 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 객체만 포함하며, 다른 텍스트, 마크다운, 코드펜스를 포함하지 마세요.
|
||||
- 좌표는 정수 또는 소수 첫째 자리까지 허용합니다.
|
||||
@ -93,7 +117,8 @@ const BASE_PROMPT = `당신은 일본 주택 건축 도면에서 "지붕 투영
|
||||
"scale": number (선택, 축척 분모. 1/100 이면 100),
|
||||
"confidence": number (0~1),
|
||||
"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 } = {}) => {
|
||||
@ -103,13 +128,13 @@ const buildPrompt = ({ pageMode, facadePages, floorPages, splitInfo } = {}) => {
|
||||
if (splitInfo.floorCount > 0) segs.push(`평면도/지붕평면도 ${splitInfo.floorCount}장`)
|
||||
if (splitInfo.facadeCount > 0) segs.push(`입면도 ${splitInfo.facadeCount}장`)
|
||||
const compo = `첨부 PDF 는 ${segs.join('과 ')}으로 구성됩니다(평면도가 앞).`
|
||||
return `${BASE_PROMPT}\n\n${compo} 외곽선은 평면도/지붕평면도 페이지에서만 추출하고, 입면도 페이지는 외곽선 추출에 쓰지 말고 높이·층수 참고로만 사용하세요.`
|
||||
return `${BASE_PROMPT}\n\n${compo} 외곽선은 평면도/지붕평면도 페이지에서만 추출하세요. 입면도 페이지는 외곽선 추출에 쓰지 말고, 높이·층수와 지붕 棟방향·경사 방향(지붕형상 판별) 참고로만 사용하세요.`
|
||||
}
|
||||
if (pageMode === 'specify' && (facadePages || floorPages)) {
|
||||
const hints = []
|
||||
if (floorPages) hints.push(`평면도(平面図)·지붕평면도(屋根伏図) 페이지: ${floorPages}`)
|
||||
if (facadePages) hints.push(`입면도(立面図) 페이지: ${facadePages}`)
|
||||
return `${BASE_PROMPT}\n\n페이지 힌트: ${hints.join(' / ')}. 입면도 페이지는 외곽선 추출에 쓰지 말고 높이·층수 참고로만 사용하세요. 지붕 투영 외곽선은 지정된 평면도/지붕평면도 페이지 기준으로 추출하세요.`
|
||||
return `${BASE_PROMPT}\n\n페이지 힌트: ${hints.join(' / ')}. 입면도 페이지는 외곽선 추출에 쓰지 말고 높이·층수와 지붕 棟방향·경사 방향(지붕형상 판별) 참고로만 사용하세요. 지붕 투영 외곽선은 지정된 평면도/지붕평면도 페이지 기준으로 추출하세요.`
|
||||
}
|
||||
return BASE_PROMPT
|
||||
}
|
||||
@ -202,10 +227,7 @@ export async function POST(req) {
|
||||
return NextResponse.json({ error: { code: 'UNAUTHORIZED', message: '로그인이 필요합니다.' } }, { status: 401 })
|
||||
}
|
||||
if (isRateLimited(session.userId || 'unknown')) {
|
||||
return NextResponse.json(
|
||||
{ error: { code: 'RATE_LIMITED', message: '분석 요청이 너무 잦습니다. 잠시 후 다시 시도해주세요.' } },
|
||||
{ status: 429 },
|
||||
)
|
||||
return NextResponse.json({ error: { code: 'RATE_LIMITED', message: '분석 요청이 너무 잦습니다. 잠시 후 다시 시도해주세요.' } }, { status: 429 })
|
||||
}
|
||||
|
||||
if (!apiKey) {
|
||||
@ -321,6 +343,10 @@ export async function POST(req) {
|
||||
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({
|
||||
outerline: parsed.outerline,
|
||||
unit: parsed.unit || 'mm',
|
||||
@ -328,6 +354,7 @@ export async function POST(req) {
|
||||
confidence: parsed.confidence ?? null,
|
||||
notes: parsed.notes ?? null,
|
||||
selectedDrawingType: parsed.selectedDrawingType ?? null,
|
||||
roofShape,
|
||||
empty: validation.empty,
|
||||
})
|
||||
} catch (error) {
|
||||
|
||||
150
src/common/roofShapePattern.js
Normal file
150
src/common/roofShapePattern.js
Normal 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
|
||||
}
|
||||
@ -24,6 +24,7 @@ import { usePlan } from '@/hooks/usePlan'
|
||||
import { normalizeDecimal } from '@/util/input-utils'
|
||||
import { CalculatorInput } from '@/components/common/input/CalcInput'
|
||||
import { usePdfImport } from '@/hooks/pdf-import/usePdfImport'
|
||||
import { pdfRoofShapeDescriptorState } from '@/store/outerLineAtom'
|
||||
import { logger } from '@/util/logger'
|
||||
import { QcastContext } from '@/app/QcastProvider'
|
||||
// [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 { setSelectedMenu } = useCanvasMenu()
|
||||
const setCurrentMenu = useSetRecoilState(currentMenuState)
|
||||
const setPdfRoofShapeDescriptor = useSetRecoilState(pdfRoofShapeDescriptorState)
|
||||
|
||||
const roofRef = {
|
||||
roofCd: useRef(null),
|
||||
@ -174,6 +176,7 @@ export default function PlacementShapeSetting({ id, pos = { x: 50, y: 180 }, pla
|
||||
formData.append('floorPages', pdfFloorPages.trim())
|
||||
}
|
||||
|
||||
setPdfRoofShapeDescriptor(null) // 이전 분석 잔여 제거(cross-run staleness 방지)
|
||||
setPdfAnalyzing(true)
|
||||
setIsGlobalLoading(true)
|
||||
const controller = new AbortController()
|
||||
@ -239,6 +242,8 @@ export default function PlacementShapeSetting({ id, pos = { x: 50, y: 180 }, pla
|
||||
return false
|
||||
}
|
||||
|
||||
setPdfRoofShapeDescriptor(payload.roofShape ?? null) // 지붕형상 자동 선택용(C)
|
||||
|
||||
// 외벽선은 항상 미확정(autoFix:false)으로만 그린다 — 확정 여부는 이후 '외벽선 자동 확정' 팝업에서 분기한다.
|
||||
const applied = await applyOuterline(payload.outerline, { unit: payload.unit || 'mm', autoFix: false })
|
||||
if (!applied) {
|
||||
|
||||
@ -1,12 +1,14 @@
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
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 { LINE_TYPE, POLYGON_TYPE } from '@/common/common'
|
||||
import { usePolygon } from '@/hooks/usePolygon'
|
||||
import { useMode } from '@/hooks/useMode'
|
||||
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 { usePopup } from '@/hooks/usePopup'
|
||||
import { getChonByDegree, getDegreeByChon } from '@/util/canvas-util'
|
||||
@ -49,6 +51,8 @@ export function useRoofShapeSetting(id) {
|
||||
|
||||
const setCurrentMenu = useSetRecoilState(currentMenuState)
|
||||
const outerLineFix = useRecoilValue(outerLineFixState)
|
||||
const pdfRoofShapeDescriptor = useRecoilValue(pdfRoofShapeDescriptorState)
|
||||
const resetPdfRoofShapeDescriptor = useResetRecoilState(pdfRoofShapeDescriptorState)
|
||||
|
||||
const isFixRef = useRef(false)
|
||||
|
||||
@ -87,6 +91,22 @@ export function useRoofShapeSetting(id) {
|
||||
closePopup(id)
|
||||
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 () => {
|
||||
if (!isFixRef.current) {
|
||||
return
|
||||
|
||||
@ -77,3 +77,12 @@ export const autoOuterLineFixState = atom({
|
||||
key: 'autoOuterLineFixState',
|
||||
default: true,
|
||||
})
|
||||
|
||||
/**
|
||||
* PDF 분석이 산출한 지붕형상 서술자(RoofShapeDescriptor | null).
|
||||
* analyzePdfAndApply 가 저장 → useRoofShapeSetting 마운트 시 매칭·소비(consume-once).
|
||||
*/
|
||||
export const pdfRoofShapeDescriptorState = atom({
|
||||
key: 'pdfRoofShapeDescriptorState',
|
||||
default: null,
|
||||
})
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user