diff --git a/CLAUDE.md b/CLAUDE.md index 72f88dd7..d8a4705d 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -56,7 +56,7 @@ A modal under `components/floor-plan/modal/` 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` 동기화로만 처리한다. **지붕형상 자동 선택(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·프롬프트 튜닝 영역. +**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` 동기화로만 처리한다. **지붕형상 자동 선택 + 설정완료 자동 적용** — 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` 으로 선택**하고, `shapeNum` 이 matched 로 커밋된 뒤 별도 effect(`autoSaveShapeNumRef` 가드, deps `[shapeNum]`)가 **`handleSave`(설정완료)를 자동 클릭**해 지붕형상까지 적용한다(2026-06-25 사용자 지시로 'pre-select만 → 설정완료 자동 적용'으로 전환). ⚠️ **마운트 effect 에서 `setShapeNum(matched)` 직후 같은 틱에 `handleSave` 를 호출하지 말 것** — `handleSave` 는 `shapeNum` 을 클로저로 읽어 stale(기본값 1) 로 잘못된 패턴이 적용된다. 반드시 `[shapeNum]` effect 로 커밋 후 실행. consume-once 로 atom 리셋(비-PDF 재진입 stale 방지). 매처 null(복합형)·roofShape 누락은 자동선택/적용 없이 graceful(수동 선택). 매칭 결과는 콘솔에 `✅ N번 버튼 「…」 매칭 → 자동 선택 + 설정완료` / `❌ 매칭되는 버튼 없음`(NEXT_PUBLIC_ENABLE_LOGGING=true)으로 명시. ⚠️ Gemini 의 실제 `roofShape` 출력 품질(특히 片流れ flows 방향)은 런타임 수동 QA·프롬프트 튜닝 영역. ### State — Recoil, split by domain diff --git a/src/hooks/roofcover/useRoofShapeSetting.js b/src/hooks/roofcover/useRoofShapeSetting.js index 7b1ae75e..cde8a3e7 100644 --- a/src/hooks/roofcover/useRoofShapeSetting.js +++ b/src/hooks/roofcover/useRoofShapeSetting.js @@ -55,6 +55,7 @@ export function useRoofShapeSetting(id) { const resetPdfRoofShapeDescriptor = useResetRecoilState(pdfRoofShapeDescriptorState) const isFixRef = useRef(false) + const autoSaveShapeNumRef = useRef(null) // 매칭 시 설정완료(handleSave) 자동 클릭 예약값(shapeNum) const pitchRef = useRef(null) const shedPitchRef = useRef(null) @@ -92,18 +93,24 @@ export function useRoofShapeSetting(id) { return } - // PDF 분석 지붕형상 descriptor 가 있으면 매칭 버튼을 pre-select 한다(자동 선택만 — handleSave 는 사용자 [적용]). + // PDF 분석 지붕형상 descriptor 가 있으면 매칭 버튼을 선택하고 설정완료(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) + // 수동 QA 용 — 매칭된 버튼 번호를 선두에 한 줄로 명시한다(NEXT_PUBLIC_ENABLE_LOGGING=true 시 콘솔 출력). + if (matched != null) { + logger.info(`[지붕형상 자동판독] ✅ ${matched}번 버튼 「${getMessage(pattern.nameKey)}」 과(와) 매칭 → 자동 선택 + 설정완료`, { + descriptor: pdfRoofShapeDescriptor, + matchedShapeNum: matched, + }) + setShapeNum(matched) // 매칭 버튼 자동 선택(클릭과 동일하게 act 하이라이트 + 속성 패널 전환) + autoSaveShapeNumRef.current = matched // shapeNum 커밋 후 effect 에서 handleSave(설정완료) 자동 클릭 + } else { + logger.info('[지붕형상 자동판독] ❌ 매칭되는 버튼 없음(복합형/판독 불일치) → 자동 선택 안 함, 수동 선택 필요', { + descriptor: pdfRoofShapeDescriptor, + matchedShapeNum: null, + }) + } resetPdfRoofShapeDescriptor() // consume-once: 비-PDF 재진입 시 stale 적용 방지 } @@ -505,6 +512,15 @@ export function useRoofShapeSetting(id) { addPopup(id, 1, ) } + // 지붕형상 매칭 시 '설정완료'(handleSave) 자동 클릭 — shapeNum 이 matched 로 커밋된 뒤 실행해야 + // handleSave 가 올바른 패턴 분기를 탄다(마운트 effect 에서 즉시 호출하면 stale shapeNum 으로 잘못 적용됨). + useEffect(() => { + if (autoSaveShapeNumRef.current != null && shapeNum === autoSaveShapeNumRef.current) { + autoSaveShapeNumRef.current = null + handleSave() + } + }, [shapeNum]) + const initLineSetting = () => { const outerLines = canvas.getObjects().filter((obj) => obj.name === 'outerLine')