diff --git a/CLAUDE.md b/CLAUDE.md index 62323ed8..cc0f8f79 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`. 임포트는 **외벽선 자동 확정(`autoOuterLineFixState`, 기본 ON) 옵션**에 따라 분기한다(`PlacementShapeSetting.handleSaveBtn` → `applyOuterline(mmPoints, { autoFix })`). **ON**: 수동 외벽선 확정(`useOuterLineWall.handleFix`) 직후와 동일한 상태를 만든다 — `useLine.addLine` 경유 outerLine 생성(attributes/planeSize/fontSize), `outerLinePoint` 정점 circle, `canvas.outerLineFix = true` + `outerLineFixState`. **OFF**: 외벽선만 그려둔 미확정(`outerLineFix=false`) 상태로 둔다. **메뉴/모달 전환**: `handleSaveBtn` 은 `setSelectedMenu('outline')` + `setCurrentMenu(ON→ROOF_SHAPE_SETTINGS / OFF→EXTERIOR_WALL_LINE)` 만 하면 된다 — `MenuDepth01` 이 `currentMenu` 변화를 감지해 `handleMenu` 로 해당 모달(RoofShapeSetting / WallLineSetting)을 자동으로 띄운다. **직접 `addPopup` 하지 말 것**(MenuDepth01 의 `closeAll` 과 충돌). ⚠️ `basicSettingSave({ skipSideEffects:true })` 는 메뉴를 건드리지 않아야 한다 — `setMenuByRoofSize` 가 `if (!skipSideEffects)` 밖에 있으면 async 저장(`await post`) 직후 `currentMenu` 를 `EXTERIOR_WALL_LINE` 로 덮어써, ON 인데도 WallLineSetting 으로 되돌아가 `useOuterLineWall` 이 `outerLineFix` 를 풀어버린다(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` 동기화로만 처리한다. ### State — Recoil, split by domain diff --git a/src/common/common.js b/src/common/common.js index 2e4bb98c..84fa20c8 100644 --- a/src/common/common.js +++ b/src/common/common.js @@ -5,6 +5,7 @@ export const MENU = { INITIAL_CANVAS_SETTING: 'initialCanvasSetting', // 배치면 초기설정 ROOF_COVERING: { EXTERIOR_WALL_LINE: 'exteriorWallLine', // 외벽선 그리기 + AUTO_OUTERLINE_FIX: 'autoOuterLineFix', // 외벽선 자동 확정(PDF 임포트 후 분기 팝업) ROOF_SHAPE_SETTINGS: 'roofShapeSettings', // 지붕형상 설정 ROOF_SHAPE_PASSIVITY_SETTINGS: 'roofShapePassivitySettings', // 지붕형상 수동설정 ROOF_SHAPE_EDITING: 'roofShapeEditing', // 지붕형상 편집 diff --git a/src/components/floor-plan/modal/outerlinesetting/AutoOuterLineFixSetting.jsx b/src/components/floor-plan/modal/outerlinesetting/AutoOuterLineFixSetting.jsx new file mode 100644 index 00000000..6bacff4c --- /dev/null +++ b/src/components/floor-plan/modal/outerlinesetting/AutoOuterLineFixSetting.jsx @@ -0,0 +1,67 @@ +'use client' + +import WithDraggable from '@/components/common/draggable/WithDraggable' +import { useMessage } from '@/hooks/useMessage' +import { usePopup } from '@/hooks/usePopup' +import { useRecoilState, useRecoilValue, useSetRecoilState } from 'recoil' +import { autoOuterLineFixState, outerLineFixState } from '@/store/outerLineAtom' +import { canvasState, currentMenuState } from '@/store/canvasAtom' +import { useCanvasMenu } from '@/hooks/common/useCanvasMenu' +import { usePlan } from '@/hooks/usePlan' +import { MENU } from '@/common/common' + +/** + * 외벽선 자동 확정 팝업 — PDF 임포트로 외벽선을 미확정(outerLineFix=false) 상태로 그린 직후 표시된다. + * 스위치 + [확인] 으로 분기한다. + * - ON: 수동 확정(useOuterLineWall.handleFix) 직후와 동일하게 외벽선을 확정하고 지붕형상 설정으로 진행한다. + * - OFF: 미확정 상태 그대로 외벽선 확정 팝업(WallLineSetting) 으로 이동해 사용자가 직접 확정한다. + */ +export default function AutoOuterLineFixSetting({ id }) { + const { getMessage } = useMessage() + const { closePopup } = usePopup() + const [autoOuterLineFix, setAutoOuterLineFix] = useRecoilState(autoOuterLineFixState) + const setOuterLineFix = useSetRecoilState(outerLineFixState) + const canvas = useRecoilValue(canvasState) + const setCurrentMenu = useSetRecoilState(currentMenuState) + const { setSelectedMenu } = useCanvasMenu() + const { saveCanvas } = usePlan() + + // currentMenu 를 바꾸면 MenuDepth01 이 handleMenu(closeAll 포함)로 이 팝업을 닫고 다음 모달을 띄운다. + // 직접 addPopup/closePopup 으로 전환하지 않는다(closeAll 과 충돌). + const handleConfirm = async () => { + setSelectedMenu('outline') + if (autoOuterLineFix) { + // 외벽선/정점/points 는 이미 applyOuterline 이 그려둔 상태라 확정 플래그만 켜면 된다. + // 이 플래그가 없으면 지붕형상 설정 진입이 'wall.line.not.found' 로 차단된다. + // 미확정으로 저장된 직전 상태를 확정 상태로 덮어써 재로드 시 확정이 풀리지 않도록 한다. + canvas.outerLineFix = true + setOuterLineFix(true) + await saveCanvas(false) + setCurrentMenu(MENU.ROOF_COVERING.ROOF_SHAPE_SETTINGS) + } else { + setCurrentMenu(MENU.ROOF_COVERING.EXTERIOR_WALL_LINE) + } + } + + return ( + + closePopup(id)} /> + +
+ +
+ {getMessage('modal.placement.initial.setting.size.pdf.autofix.info')} +
+
+
+ +
+
+
+ ) +} diff --git a/src/components/floor-plan/modal/placementShape/PlacementShapeSetting.jsx b/src/components/floor-plan/modal/placementShape/PlacementShapeSetting.jsx index 13b2b15d..518ee43d 100644 --- a/src/components/floor-plan/modal/placementShape/PlacementShapeSetting.jsx +++ b/src/components/floor-plan/modal/placementShape/PlacementShapeSetting.jsx @@ -13,7 +13,6 @@ import { addedRoofsState, roofDisplaySelector, roofMaterialsAtom } from '@/store import { useCommonCode } from '@/hooks/common/useCommonCode' import QSelectBox from '@/components/common/select/QSelectBox' import { globalLocaleStore } from '@/store/localeAtom' -import { autoOuterLineFixState } from '@/store/outerLineAtom' import { getChonByDegree, getDegreeByChon } from '@/util/canvas-util' import { usePolygon } from '@/hooks/usePolygon' @@ -108,8 +107,6 @@ export default function PlacementShapeSetting({ id, pos = { x: 50, y: 180 }, pla const [pdfFacadePages, setPdfFacadePages] = useState('') const [pdfFloorPages, setPdfFloorPages] = useState('') const [pdfAnalyzing, setPdfAnalyzing] = useState(false) - // 외벽선 자동 확정(auto fix) 전역 옵션 — 기본 ON - const [autoOuterLineFix, setAutoOuterLineFix] = useRecoilState(autoOuterLineFixState) const { setIsGlobalLoading } = useContext(QcastContext) const pdfInputRef = useRef(null) // 진행 중인 PDF 분석 요청 추적 — 언마운트(라우팅 이탈) 시 abort 하고, 늦게 도착한 결과가 @@ -242,7 +239,8 @@ export default function PlacementShapeSetting({ id, pos = { x: 50, y: 180 }, pla return false } - const applied = await applyOuterline(payload.outerline, { unit: payload.unit || 'mm', autoFix: autoOuterLineFix }) + // 외벽선은 항상 미확정(autoFix:false)으로만 그린다 — 확정 여부는 이후 '외벽선 자동 확정' 팝업에서 분기한다. + const applied = await applyOuterline(payload.outerline, { unit: payload.unit || 'mm', autoFix: false }) if (!applied) { swalFire({ text: getMessage('pdf.import.error.apply'), type: 'alert' }) return false @@ -466,16 +464,11 @@ export default function PlacementShapeSetting({ id, pos = { x: 50, y: 180 }, pla // roofSizeSet에 따라 메뉴 설정 if (inputMode === INPUT_MODE.PDF) { + // PDF 임포트: 외벽선을 미확정으로 그려둔 상태로 '외벽선 자동 확정' 팝업을 띄운다. + // 그 팝업의 스위치(ON→지붕형상 / OFF→외벽선 확정)에서 확정 여부를 분기한다. + // currentMenu 변경 시 MenuDepth01 이 handleMenu 로 AutoOuterLineFixSetting 모달을 자동으로 띄운다. setSelectedMenu('outline') - if (autoOuterLineFix) { - // 자동 확정 ON: 외곽선이 이미 확정(outerLineFix=true)된 상태이므로 지붕형상 설정으로 보낸다. - // currentMenu 변경 시 MenuDepth01 이 handleMenu 로 RoofShapeSetting 모달을 자동으로 띄운다. - setCurrentMenu(MENU.ROOF_COVERING.ROOF_SHAPE_SETTINGS) - } else { - // 자동 확정 OFF: 외벽선은 미확정(outerLineFix=false) 상태로 그려둔다 — 외벽선 그리기 단계로 보내면 - // MenuDepth01 이 WallLineSetting 을 띄워, 사용자가 검토 후 직접 [외벽선 확정] 할 수 있다. points 는 유지된다. - setCurrentMenu(MENU.ROOF_COVERING.EXTERIOR_WALL_LINE) - } + setCurrentMenu(MENU.ROOF_COVERING.AUTO_OUTERLINE_FIX) } else if (currentRoof?.roofSizeSet === '2') { setSelectedMenu('surface') setCurrentMenu(MENU.BATCH_CANVAS.BATCH_DRAWING) @@ -642,23 +635,6 @@ export default function PlacementShapeSetting({ id, pos = { x: 50, y: 180 }, pla - - {getMessage('modal.placement.initial.setting.size.pdf.autofix.label')} - - -
- {getMessage('modal.placement.initial.setting.size.pdf.autofix.info')} -
- - )} diff --git a/src/hooks/common/useMenu.js b/src/hooks/common/useMenu.js index 40c1b6a0..fdebbcb9 100644 --- a/src/hooks/common/useMenu.js +++ b/src/hooks/common/useMenu.js @@ -1,4 +1,5 @@ import WallLineSetting from '@/components/floor-plan/modal/outerlinesetting/WallLineSetting' +import AutoOuterLineFixSetting from '@/components/floor-plan/modal/outerlinesetting/AutoOuterLineFixSetting' import RoofShapeSetting from '@/components/floor-plan/modal/roofShape/RoofShapeSetting' import RoofShapePassivitySetting from '@/components/floor-plan/modal/roofShape/RoofShapePassivitySetting' import AuxiliaryDrawing from '@/components/floor-plan/modal/auxiliary/AuxiliaryDrawing' @@ -45,6 +46,9 @@ export default function useMenu() { case MENU.ROOF_COVERING.EXTERIOR_WALL_LINE: addPopup(popupId, 1, ) break + case MENU.ROOF_COVERING.AUTO_OUTERLINE_FIX: + addPopup(popupId, 1, ) + break case MENU.ROOF_COVERING.ROOF_SHAPE_SETTINGS: addPopup(popupId, 1, ) break diff --git a/src/locales/ja.json b/src/locales/ja.json index 7ce1eb5a..365b959e 100644 --- a/src/locales/ja.json +++ b/src/locales/ja.json @@ -43,7 +43,7 @@ "modal.placement.initial.setting.size.pdf.page.all.info": "全ページを解析するため処理時間が長くなる場合があります。図面内の立面図·平面図ページが特定できる場合はページ指定を推奨します。", "modal.placement.initial.setting.size.pdf.page.specify.info": "立面図·平面図が含まれるページ番号をそれぞれ入力してください。指定したページのみ読み込み素早く屋根を自動作成します。", "modal.placement.initial.setting.size.pdf.autofix.label": "外壁線の自動確定", - "modal.placement.initial.setting.size.pdf.autofix.info": "オンにするとPDF解析後、外壁線を自動で確定し屋根形状設定へ進みます。", + "modal.placement.initial.setting.size.pdf.autofix.info": "オンにすると外壁線を自動で確定し、屋根形状設定へ進みます。\nオフにすると外壁線の確定ステップに移動し、手動で確定できます。", "modal.placement.initial.setting.size.pdf.error.mime": "PDF ファイルのみアップロード可能です。", "modal.placement.initial.setting.size.pdf.error.too.large": "ファイルサイズが 20MB を超えています。", "modal.placement.initial.setting.size.pdf.error.no.file": "先に PDF ファイルをアップロードしてください。", diff --git a/src/locales/ko.json b/src/locales/ko.json index f213f6c1..e0494a1d 100644 --- a/src/locales/ko.json +++ b/src/locales/ko.json @@ -43,7 +43,7 @@ "modal.placement.initial.setting.size.pdf.page.all.info": "모든 페이지를 분석하므로 처리 시간이 길어질 수 있습니다. 도면 내 입면도·평면도 페이지가 확인되면 페이지 지정을 권장합니다.", "modal.placement.initial.setting.size.pdf.page.specify.info": "입면도·평면도가 포함된 페이지 번호를 각각 입력해주세요. 지정한 페이지만 읽어 빠르게 지붕을 자동 작성합니다.", "modal.placement.initial.setting.size.pdf.autofix.label": "외벽선 자동 확정", - "modal.placement.initial.setting.size.pdf.autofix.info": "켜면 PDF 분석 후 외벽선을 자동으로 확정하고 지붕형상 설정 단계로 넘어갑니다.", + "modal.placement.initial.setting.size.pdf.autofix.info": "켜면 외벽선을 자동으로 확정하고 지붕형상 설정 단계로 넘어갑니다.\n끄면 외벽선 확정 단계로 이동해 직접 확정할 수 있습니다.", "modal.placement.initial.setting.size.pdf.error.mime": "PDF 파일만 업로드할 수 있습니다.", "modal.placement.initial.setting.size.pdf.error.too.large": "파일 크기가 20MB 를 초과합니다.", "modal.placement.initial.setting.size.pdf.error.no.file": "PDF 파일을 먼저 업로드해 주세요.",