feat(pdf-import): 외벽선 자동 확정 스위치를 분석 후 별도 팝업으로 분리

배치면 초기설정의 자동 확정 스위치를 제거하고, PDF 분석으로 외벽선을 미확정
상태로 그린 직후 '외벽선 자동 확정' 팝업(스위치+확인)을 띄워 분기한다.
- ON: outerLineFix 확정 + saveCanvas 영속화 → 지붕형상 설정 직행
- OFF: 미확정 유지 → 외벽선 확정 팝업(WallLineSetting)
신규 메뉴 상수 AUTO_OUTERLINE_FIX + useMenu case 로 MenuDepth01 이 자동 표시

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
sangwook yoo 2026-06-23 15:48:12 +09:00
parent 2e7c8769aa
commit ca2b0c3dae
7 changed files with 81 additions and 33 deletions

View File

@ -56,7 +56,7 @@ A modal under `components/floor-plan/modal/<feature>` pairs with a hook under `h
**Plan persistence** (serialize/restore) is in `src/lib/canvas.js` and `src/util/canvas-util.js`. Recent commits (`a5e794b3`, `2aef32e0`) added a three-layer guard (entry / cache / save) and `SizeSetting` input guards against 0-area apertures/shadows/dormers — when touching plan load/save or size inputs, do not weaken or remove these guards without understanding the failure mode (`InvalidStateError` from corrupted plans). **Plan persistence** (serialize/restore) is in `src/lib/canvas.js` and `src/util/canvas-util.js`. Recent commits (`a5e794b3`, `2aef32e0`) added a three-layer guard (entry / cache / save) and `SizeSetting` input guards against 0-area apertures/shadows/dormers — when touching plan load/save or size inputs, do not weaken or remove these guards without understanding the failure mode (`InvalidStateError` from corrupted plans).
**PDF 도면 임포트** — 배치면 초기설정 모달의 PDF 입력 모드 → `src/app/api/gemini/floor-plan/route.js`(Gemini 분석, 세션 인증 + 사용자별 분당 5회 레이트리밋) → `src/hooks/pdf-import/usePdfImport.js`. 임포트는 **외벽선 자동 확정(`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 ### State — Recoil, split by domain

View File

@ -5,6 +5,7 @@ export const MENU = {
INITIAL_CANVAS_SETTING: 'initialCanvasSetting', // 배치면 초기설정 INITIAL_CANVAS_SETTING: 'initialCanvasSetting', // 배치면 초기설정
ROOF_COVERING: { ROOF_COVERING: {
EXTERIOR_WALL_LINE: 'exteriorWallLine', // 외벽선 그리기 EXTERIOR_WALL_LINE: 'exteriorWallLine', // 외벽선 그리기
AUTO_OUTERLINE_FIX: 'autoOuterLineFix', // 외벽선 자동 확정(PDF 임포트 후 분기 팝업)
ROOF_SHAPE_SETTINGS: 'roofShapeSettings', // 지붕형상 설정 ROOF_SHAPE_SETTINGS: 'roofShapeSettings', // 지붕형상 설정
ROOF_SHAPE_PASSIVITY_SETTINGS: 'roofShapePassivitySettings', // 지붕형상 수동설정 ROOF_SHAPE_PASSIVITY_SETTINGS: 'roofShapePassivitySettings', // 지붕형상 수동설정
ROOF_SHAPE_EDITING: 'roofShapeEditing', // 지붕형상 편집 ROOF_SHAPE_EDITING: 'roofShapeEditing', // 지붕형상 편집

View File

@ -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 (
<WithDraggable isShow={true} pos={{ x: 50, y: 230 }} className="r">
<WithDraggable.Header title={getMessage('modal.placement.initial.setting.size.pdf.autofix.label')} onClose={() => closePopup(id)} />
<WithDraggable.Body>
<div className="autofix-pop-wrap">
<label className="toggle-btn">
<input type="checkbox" checked={autoOuterLineFix} onChange={(e) => setAutoOuterLineFix(e.target.checked)} />
<span className="slider"></span>
</label>
<div style={{ fontSize: '12px', color: '#bbb', marginTop: '8px', whiteSpace: 'pre-line' }}>
{getMessage('modal.placement.initial.setting.size.pdf.autofix.info')}
</div>
</div>
<div className="grid-btn-wrap">
<button className="btn-frame modal act" onClick={handleConfirm}>
{getMessage('common.ok')}
</button>
</div>
</WithDraggable.Body>
</WithDraggable>
)
}

View File

@ -13,7 +13,6 @@ import { addedRoofsState, roofDisplaySelector, roofMaterialsAtom } from '@/store
import { useCommonCode } from '@/hooks/common/useCommonCode' import { useCommonCode } from '@/hooks/common/useCommonCode'
import QSelectBox from '@/components/common/select/QSelectBox' import QSelectBox from '@/components/common/select/QSelectBox'
import { globalLocaleStore } from '@/store/localeAtom' import { globalLocaleStore } from '@/store/localeAtom'
import { autoOuterLineFixState } from '@/store/outerLineAtom'
import { getChonByDegree, getDegreeByChon } from '@/util/canvas-util' import { getChonByDegree, getDegreeByChon } from '@/util/canvas-util'
import { usePolygon } from '@/hooks/usePolygon' 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 [pdfFacadePages, setPdfFacadePages] = useState('')
const [pdfFloorPages, setPdfFloorPages] = useState('') const [pdfFloorPages, setPdfFloorPages] = useState('')
const [pdfAnalyzing, setPdfAnalyzing] = useState(false) const [pdfAnalyzing, setPdfAnalyzing] = useState(false)
// (auto fix) ON
const [autoOuterLineFix, setAutoOuterLineFix] = useRecoilState(autoOuterLineFixState)
const { setIsGlobalLoading } = useContext(QcastContext) const { setIsGlobalLoading } = useContext(QcastContext)
const pdfInputRef = useRef(null) const pdfInputRef = useRef(null)
// PDF ( ) abort , // PDF ( ) abort ,
@ -242,7 +239,8 @@ export default function PlacementShapeSetting({ id, pos = { x: 50, y: 180 }, pla
return false 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) { if (!applied) {
swalFire({ text: getMessage('pdf.import.error.apply'), type: 'alert' }) swalFire({ text: getMessage('pdf.import.error.apply'), type: 'alert' })
return false return false
@ -466,16 +464,11 @@ export default function PlacementShapeSetting({ id, pos = { x: 50, y: 180 }, pla
// roofSizeSet // roofSizeSet
if (inputMode === INPUT_MODE.PDF) { if (inputMode === INPUT_MODE.PDF) {
// PDF : ' ' .
// (ON / OFF ) .
// currentMenu MenuDepth01 handleMenu AutoOuterLineFixSetting .
setSelectedMenu('outline') setSelectedMenu('outline')
if (autoOuterLineFix) { setCurrentMenu(MENU.ROOF_COVERING.AUTO_OUTERLINE_FIX)
// 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)
}
} else if (currentRoof?.roofSizeSet === '2') { } else if (currentRoof?.roofSizeSet === '2') {
setSelectedMenu('surface') setSelectedMenu('surface')
setCurrentMenu(MENU.BATCH_CANVAS.BATCH_DRAWING) setCurrentMenu(MENU.BATCH_CANVAS.BATCH_DRAWING)
@ -642,23 +635,6 @@ export default function PlacementShapeSetting({ id, pos = { x: 50, y: 180 }, pla
</div> </div>
</td> </td>
</tr> </tr>
<tr>
<th>{getMessage('modal.placement.initial.setting.size.pdf.autofix.label')}</th>
<td>
<label className="toggle-btn">
<input
type="checkbox"
checked={autoOuterLineFix}
disabled={pdfAnalyzing}
onChange={(e) => setAutoOuterLineFix(e.target.checked)}
/>
<span className="slider"></span>
</label>
<div style={{ fontSize: '12px', color: '#bbb', marginTop: '4px' }}>
{getMessage('modal.placement.initial.setting.size.pdf.autofix.info')}
</div>
</td>
</tr>
</> </>
)} )}
<tr> <tr>

View File

@ -1,4 +1,5 @@
import WallLineSetting from '@/components/floor-plan/modal/outerlinesetting/WallLineSetting' 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 RoofShapeSetting from '@/components/floor-plan/modal/roofShape/RoofShapeSetting'
import RoofShapePassivitySetting from '@/components/floor-plan/modal/roofShape/RoofShapePassivitySetting' import RoofShapePassivitySetting from '@/components/floor-plan/modal/roofShape/RoofShapePassivitySetting'
import AuxiliaryDrawing from '@/components/floor-plan/modal/auxiliary/AuxiliaryDrawing' import AuxiliaryDrawing from '@/components/floor-plan/modal/auxiliary/AuxiliaryDrawing'
@ -45,6 +46,9 @@ export default function useMenu() {
case MENU.ROOF_COVERING.EXTERIOR_WALL_LINE: case MENU.ROOF_COVERING.EXTERIOR_WALL_LINE:
addPopup(popupId, 1, <WallLineSetting id={popupId} />) addPopup(popupId, 1, <WallLineSetting id={popupId} />)
break break
case MENU.ROOF_COVERING.AUTO_OUTERLINE_FIX:
addPopup(popupId, 1, <AutoOuterLineFixSetting id={popupId} />)
break
case MENU.ROOF_COVERING.ROOF_SHAPE_SETTINGS: case MENU.ROOF_COVERING.ROOF_SHAPE_SETTINGS:
addPopup(popupId, 1, <RoofShapeSetting id={popupId} />) addPopup(popupId, 1, <RoofShapeSetting id={popupId} />)
break break

View File

@ -43,7 +43,7 @@
"modal.placement.initial.setting.size.pdf.page.all.info": "全ページを解析するため処理時間が長くなる場合があります。図面内の立面図·平面図ページが特定できる場合はページ指定を推奨します。", "modal.placement.initial.setting.size.pdf.page.all.info": "全ページを解析するため処理時間が長くなる場合があります。図面内の立面図·平面図ページが特定できる場合はページ指定を推奨します。",
"modal.placement.initial.setting.size.pdf.page.specify.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.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.mime": "PDF ファイルのみアップロード可能です。",
"modal.placement.initial.setting.size.pdf.error.too.large": "ファイルサイズが 20MB を超えています。", "modal.placement.initial.setting.size.pdf.error.too.large": "ファイルサイズが 20MB を超えています。",
"modal.placement.initial.setting.size.pdf.error.no.file": "先に PDF ファイルをアップロードしてください。", "modal.placement.initial.setting.size.pdf.error.no.file": "先に PDF ファイルをアップロードしてください。",

View File

@ -43,7 +43,7 @@
"modal.placement.initial.setting.size.pdf.page.all.info": "모든 페이지를 분석하므로 처리 시간이 길어질 수 있습니다. 도면 내 입면도·평면도 페이지가 확인되면 페이지 지정을 권장합니다.", "modal.placement.initial.setting.size.pdf.page.all.info": "모든 페이지를 분석하므로 처리 시간이 길어질 수 있습니다. 도면 내 입면도·평면도 페이지가 확인되면 페이지 지정을 권장합니다.",
"modal.placement.initial.setting.size.pdf.page.specify.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.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.mime": "PDF 파일만 업로드할 수 있습니다.",
"modal.placement.initial.setting.size.pdf.error.too.large": "파일 크기가 20MB 를 초과합니다.", "modal.placement.initial.setting.size.pdf.error.too.large": "파일 크기가 20MB 를 초과합니다.",
"modal.placement.initial.setting.size.pdf.error.no.file": "PDF 파일을 먼저 업로드해 주세요.", "modal.placement.initial.setting.size.pdf.error.no.file": "PDF 파일을 먼저 업로드해 주세요.",