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>
This commit is contained in:
parent
4e919923d8
commit
4ebe02186f
@ -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` 추가 정합. ✅
|
||||
Loading…
x
Reference in New Issue
Block a user