From 7e08184227b69789a365ab25724324b6d0e89a08 Mon Sep 17 00:00:00 2001 From: "hyojun.choi" Date: Wed, 18 Mar 2026 10:13:12 +0900 Subject: [PATCH 01/60] =?UTF-8?q?undo=20redo=20=EC=9E=91=EC=97=85=EC=A4=91?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/components/floor-plan/CanvasMenu.jsx | 26 ++ .../flowDirection/FlowDirectionSetting.jsx | 3 + src/hooks/common/useCommonUtils.js | 4 + src/hooks/module/useModule.js | 8 + src/hooks/module/useModuleBasicSetting.js | 35 +- src/hooks/object/useObjectBatch.js | 10 + src/hooks/roofcover/useAuxiliaryDrawing.js | 17 + src/hooks/roofcover/useEavesGableEdit.js | 3 + src/hooks/roofcover/useMovementSetting.js | 3 + src/hooks/roofcover/useOuterLineWall.js | 3 + src/hooks/roofcover/usePropertiesSetting.js | 5 + .../roofcover/useRoofAllocationSetting.js | 3 + .../roofcover/useRoofShapePassivitySetting.js | 4 + src/hooks/roofcover/useRoofShapeSetting.js | 4 + src/hooks/surface/usePlacementShapeDrawing.js | 35 +- src/hooks/surface/useSurfaceShapeBatch.js | 5 + src/hooks/useCanvasEvent.js | 31 +- src/hooks/useUndoRedo.js | 369 ++++++++++++++++++ src/store/canvasAtom.js | 13 + src/styles/_contents.scss | 27 ++ src/util/canvas-util.js | 4 +- 21 files changed, 581 insertions(+), 31 deletions(-) create mode 100644 src/hooks/useUndoRedo.js diff --git a/src/components/floor-plan/CanvasMenu.jsx b/src/components/floor-plan/CanvasMenu.jsx index be42c977..6ef889d1 100644 --- a/src/components/floor-plan/CanvasMenu.jsx +++ b/src/components/floor-plan/CanvasMenu.jsx @@ -52,6 +52,7 @@ import { QcastContext } from '@/app/QcastProvider' import { useRoofFn } from '@/hooks/common/useRoofFn' import { usePolygon } from '@/hooks/usePolygon' import { useTrestle } from '@/hooks/module/useTrestle' +import { useUndoRedo } from '@/hooks/useUndoRedo' export default function CanvasMenu(props) { const [currentCanvasPlan, setCurrentCanvasPlan] = useRecoilState(currentCanvasPlanState) @@ -73,6 +74,7 @@ export default function CanvasMenu(props) { const { handleZoomClear, handleZoom } = useCanvasEvent() const { setAllModuleSurfaceIsComplete, isAllComplete } = useTrestle() const { handleMenu } = useMenu() + const { undo, redo, canUndo, canRedo } = useUndoRedo() // const urlParams = useSearchParams() const { handleEstimateSubmit, fetchSetting, estimateContextState, setEstimateContextState } = useEstimateController() const estimateRecoilState = useRecoilValue(estimateState) @@ -569,6 +571,30 @@ export default function CanvasMenu(props) {
{!['estimate', 'simulation'].some((num) => num === selectedMenu) && ( <> +
+ + +
{
{getMessage('plan.mode.vertical.horizontal')} diff --git a/src/components/floor-plan/modal/flowDirection/FlowDirectionSetting.jsx b/src/components/floor-plan/modal/flowDirection/FlowDirectionSetting.jsx index e9f57869..8c30afe1 100644 --- a/src/components/floor-plan/modal/flowDirection/FlowDirectionSetting.jsx +++ b/src/components/floor-plan/modal/flowDirection/FlowDirectionSetting.jsx @@ -9,6 +9,7 @@ import { canvasState } from '@/store/canvasAtom' import { usePolygon } from '@/hooks/usePolygon' import { useSurfaceShapeBatch } from '@/hooks/surface/useSurfaceShapeBatch' import { useRoofFn } from '@/hooks/common/useRoofFn' +import { useUndoRedo } from '@/hooks/useUndoRedo' const FLOW_DIRECTION_TYPE = { EIGHT_AZIMUTH: 'eightAzimuth', @@ -24,6 +25,7 @@ export default function FlowDirectionSetting(props) { const { setPolygonLinesActualSize } = usePolygon() const { changeSurfaceLineType } = useSurfaceShapeBatch({}) + const { saveSnapshot } = useUndoRedo() useEffect(() => { return () => { @@ -77,6 +79,7 @@ export default function FlowDirectionSetting(props) { const [type, setType] = useState(target.surfaceCompassType ?? FLOW_DIRECTION_TYPE.EIGHT_AZIMUTH) const changeSurfaceFlowDirection = (roof, direction, orientation) => { + saveSnapshot() roof.set({ direction: direction, surfaceCompass: orientation, diff --git a/src/hooks/common/useCommonUtils.js b/src/hooks/common/useCommonUtils.js index fc4c907b..db6f4b5b 100644 --- a/src/hooks/common/useCommonUtils.js +++ b/src/hooks/common/useCommonUtils.js @@ -14,6 +14,7 @@ import { useObjectBatch } from '@/hooks/object/useObjectBatch' import { BATCH_TYPE } from '@/common/common' import { useMouse } from '@/hooks/useMouse' import { QPolygon } from '@/components/fabric/QPolygon' +import { useUndoRedo } from '@/hooks/useUndoRedo' export function useCommonUtils() { const canvas = useRecoilValue(canvasState) @@ -28,6 +29,7 @@ export function useCommonUtils() { const { drawDirectionArrow, addLengthText } = usePolygon() const { applyDormers } = useObjectBatch({}) const { getIntersectMousePoint } = useMouse() + const { saveSnapshot } = useUndoRedo() useEffect(() => { commonTextMode() @@ -834,6 +836,7 @@ export function useCommonUtils() { } const deleteObject = () => { + saveSnapshot() const selectedObj = canvas?.getActiveObjects() if (selectedObj) { selectedObj.forEach((obj) => { @@ -863,6 +866,7 @@ export function useCommonUtils() { } const copyObject = () => { + saveSnapshot() const obj = canvas?.getActiveObject() commonCopyObject(obj) } diff --git a/src/hooks/module/useModule.js b/src/hooks/module/useModule.js index d4a0e7de..41f014f9 100644 --- a/src/hooks/module/useModule.js +++ b/src/hooks/module/useModule.js @@ -12,6 +12,7 @@ import { selectedModuleState } from '@/store/selectedModuleOptions' import { useCircuitTrestle } from '../useCirCuitTrestle' import { useTrestle } from './useTrestle' import { useTurf } from '@/hooks/common/useTurf' +import { useUndoRedo } from '@/hooks/useUndoRedo' export const MODULE_REMOVE_TYPE = { LEFT: 'left', @@ -44,6 +45,7 @@ export function useModule() { const { setModuleStatisticsData, resetCircuits } = useCircuitTrestle() const { clear: removeTrestleMaterials } = useTrestle() const { checkModuleDisjointSurface } = useTurf() + const { saveSnapshot } = useUndoRedo() // clone() 대신 직접 새 QPolygon을 생성하여 Maximum call stack 방지 const createModuleCopy = (module, newLeft, newTop, overrides = {}) => { @@ -92,6 +94,7 @@ export function useModule() { }) return } + saveSnapshot() // canvas.discardActiveObject() //선택해제 const isSetupModules = getOtherModules(selectedObj) @@ -171,6 +174,7 @@ export function useModule() { removeTrestleMaterials() } + saveSnapshot() const modules = type === 'row' ? getRowModules(activeModule) : getColumnModules(activeModule) const otherModules = getOtherModules(modules) const objects = getObjects() @@ -221,6 +225,7 @@ export function useModule() { } const moduleMoveAll = (length, direction, surfaceArray) => { + saveSnapshot() surfaceArray.forEach((surface) => { const modules = canvas .getObjects() @@ -471,6 +476,7 @@ export function useModule() { if (surfaces.some((surface) => surface.isComplete)) { removeTrestleMaterials() } + saveSnapshot() canvas.discardActiveObject() moduleSetupSurface.set({ modules: otherModules }) canvas.remove(...columnModules) @@ -605,6 +611,7 @@ export function useModule() { if (surfaces.some((surface) => surface.isComplete)) { removeTrestleMaterials() } + saveSnapshot() canvas.discardActiveObject() moduleSetupSurface.set({ modules: otherModules }) canvas.remove(...rowModules) @@ -969,6 +976,7 @@ export function useModule() { return } + saveSnapshot() surface.set({ modules: modules }) canvas.remove(activeModule) canvas.renderAll() diff --git a/src/hooks/module/useModuleBasicSetting.js b/src/hooks/module/useModuleBasicSetting.js index c48c2d21..9adb82be 100644 --- a/src/hooks/module/useModuleBasicSetting.js +++ b/src/hooks/module/useModuleBasicSetting.js @@ -33,6 +33,7 @@ import { isObjectNotEmpty } from '@/util/common-utils' import { useCircuitTrestle } from '@/hooks/useCirCuitTrestle' import { useMode } from '@/hooks/useMode' import { useTurf } from '@/hooks/common/useTurf' +import { useUndoRedo } from '@/hooks/useUndoRedo' export function useModuleBasicSetting(tabNum) { const canvas = useRecoilValue(canvasState) @@ -62,6 +63,7 @@ export function useModuleBasicSetting(tabNum) { const setModuleRowColArray = useSetRecoilState(moduleRowColArrayState) const { checkModuleDisjointSurface } = useTurf() + const { saveSnapshot } = useUndoRedo() useEffect(() => { initEvent() @@ -104,6 +106,7 @@ export function useModuleBasicSetting(tabNum) { } } else { //육지붕 일경우에는 바로 배치면 설치LL + saveSnapshot() canvas .getObjects() .filter((roof) => roof.name === POLYGON_TYPE.ROOF) @@ -117,6 +120,7 @@ export function useModuleBasicSetting(tabNum) { //가대 상세 데이터 들어오면 실행 useEffect(() => { if (trestleDetailList.length > 0) { + saveSnapshot() let rowColArray = [] //지붕을 가져옴 canvas @@ -570,7 +574,7 @@ export function useModuleBasicSetting(tabNum) { */ const manualModuleSetup = () => { //레이아웃 수동설치 토글 - const moduleSetupSurfaces = canvas?.getObjects().filter((obj) => obj.name === POLYGON_TYPE.MODULE_SETUP_SURFACE) //모듈설치면를 가져옴 + let moduleSetupSurfaces = canvas?.getObjects().filter((obj) => obj.name === POLYGON_TYPE.MODULE_SETUP_SURFACE) //모듈설치면를 가져옴 const isChidori = moduleSetupOption.isChidori if (isManualModuleSetup) { @@ -626,6 +630,9 @@ export function useModuleBasicSetting(tabNum) { //마우스 이벤트 삭제 후 재추가 const mousePoint = canvas.getPointer(e.e) + // undo 후 캔버스 객체가 교체될 수 있으므로 매번 다시 가져옴 + moduleSetupSurfaces = canvas?.getObjects().filter((obj) => obj.name === POLYGON_TYPE.MODULE_SETUP_SURFACE) || [] + for (let i = 0; i < moduleSetupSurfaces.length; i++) { //배치면이 여러개 일때 옮겨가면서 동작해야함 const moduleSetupSurface = moduleSetupSurfaces[i] @@ -916,8 +923,10 @@ export function useModuleBasicSetting(tabNum) { addCanvasMouseEventListener('mouse:up', (e) => { let isIntersection = true + if (!trestlePolygon) return + // 혼합 설치 불가능한 모듈인지 확인 Y는 Y끼리 N은 N끼리만 설치 가능 - if (trestlePolygon.modules.length > 0) { + if (trestlePolygon.modules && trestlePolygon.modules.length > 0) { //이미 설치된 모듈중에 한개만 가져옴 const mixAsgYn = trestlePolygon.modules[0].moduleInfo.mixAsgYn //현재 체크된 모듈기준으로 혼합가능인지 확인 Y === Y, N === N 일때만 설치 가능 @@ -951,9 +960,10 @@ export function useModuleBasicSetting(tabNum) { tempModule.set({ points: rectPoints }) const tempTurfModule = polygonToTurfPolygon(tempModule) - //도머 객체를 가져옴 - if (batchObjects) { - batchObjects.forEach((object) => { + //도머 객체를 가져옴 (undo 후 캔버스 객체가 교체될 수 있으므로 다시 가져옴) + const currentBatchObjects = canvas?.getObjects().filter((obj) => obj.name === POLYGON_TYPE.OBJECT_SURFACE) + if (currentBatchObjects) { + currentBatchObjects.forEach((object) => { let dormerTurfPolygon = polygonToTurfPolygon(object, true) const intersection = turf.intersect(turf.featureCollection([dormerTurfPolygon, tempTurfModule])) //겹치는지 확인 //겹치면 안됨 @@ -989,6 +999,7 @@ export function useModuleBasicSetting(tabNum) { toFixed: 2, }) + saveSnapshot() canvas?.add(manualModule) canvas.bringToFront(manualModule) manualDrawModules.push(manualModule) @@ -1026,7 +1037,7 @@ export function useModuleBasicSetting(tabNum) { //단 열수 지정배치 const manualModuleLayoutSetup = (layoutSetupRef) => { - const moduleSetupSurfaces = canvas?.getObjects().filter((obj) => obj.name === POLYGON_TYPE.MODULE_SETUP_SURFACE) //모듈설치면를 가져옴 + let moduleSetupSurfaces = canvas?.getObjects().filter((obj) => obj.name === POLYGON_TYPE.MODULE_SETUP_SURFACE) //모듈설치면를 가져옴 const isChidori = moduleSetupOption.isChidori //치도리 여부 const setupLocation = moduleSetupOption.setupLocation //모듈 설치 위치 처마, 용마루 @@ -1124,6 +1135,9 @@ export function useModuleBasicSetting(tabNum) { //마우스 이벤트 삭제 후 재추가 const mousePoint = canvas.getPointer(e.e) + // undo 후 캔버스 객체가 교체될 수 있으므로 매번 다시 가져옴 + moduleSetupSurfaces = canvas?.getObjects().filter((obj) => obj.name === POLYGON_TYPE.MODULE_SETUP_SURFACE) || [] + for (let i = 0; i < moduleSetupSurfaces.length; i++) { //배치면이 여러개 일때 옮겨가면서 동작해야함 const moduleSetupSurface = moduleSetupSurfaces[i] @@ -1401,7 +1415,9 @@ export function useModuleBasicSetting(tabNum) { }) addCanvasMouseEventListener('mouse:up', (e) => { - if (trestlePolygon.modules.length > 0) { + if (!trestlePolygon) return + + if (trestlePolygon.modules && trestlePolygon.modules.length > 0) { //이미 설치된 모듈중에 한개만 가져옴 const mixAsgYn = trestlePolygon.modules[0].moduleInfo.mixAsgYn //현재 체크된 모듈기준으로 혼합가능인지 확인 Y === Y, N === N 일때만 설치 가능 @@ -1455,6 +1471,7 @@ export function useModuleBasicSetting(tabNum) { } if (tempModule) { + saveSnapshot() let startX, startY let installedLastHeightCoord = 0 //마지막으로 설치된 모듈의 좌표 let installedHeightModuleCount = 0 @@ -2913,6 +2930,7 @@ export function useModuleBasicSetting(tabNum) { return orderA - orderB }) + saveSnapshot() sortedModuleSetupSurfaces.forEach((moduleSetupSurface, index) => { moduleSetupSurface.fire('mousedown') const moduleSetupArray = [] @@ -3740,6 +3758,7 @@ export function useModuleBasicSetting(tabNum) { canvas?.add(manualModule) manualDrawModules.push(manualModule) setModuleStatisticsData() + saveSnapshot() } else { swalFire({ text: getMessage('module.place.overlab'), icon: 'warning' }) } @@ -3751,6 +3770,7 @@ export function useModuleBasicSetting(tabNum) { } // getModuleStatistics() setModuleStatisticsData() + saveSnapshot() } else { if (moduleSetupSurfaces) { //수동모드 해제시 모듈 설치면 선택 잠금 @@ -4059,6 +4079,7 @@ export function useModuleBasicSetting(tabNum) { }) } + saveSnapshot() moduleSetupSurfaces.forEach((moduleSetupSurface, index) => { moduleSetupSurface.fire('mousedown') const moduleSetupArray = [] diff --git a/src/hooks/object/useObjectBatch.js b/src/hooks/object/useObjectBatch.js index 2af636a5..19f74556 100644 --- a/src/hooks/object/useObjectBatch.js +++ b/src/hooks/object/useObjectBatch.js @@ -25,6 +25,7 @@ import { usePopup } from '@/hooks/usePopup' import { useMouse } from '@/hooks/useMouse' import { useTurf } from '@/hooks/common/useTurf' import SizeSetting from '@/components/floor-plan/modal/object/SizeSetting' +import { useUndoRedo } from '@/hooks/useUndoRedo' export function useObjectBatch({ isHidden, setIsHidden }) { const { getMessage } = useMessage() @@ -41,6 +42,7 @@ export function useObjectBatch({ isHidden, setIsHidden }) { const { getIntersectMousePoint } = useMouse() const { checkModuleDisjointSurface } = useTurf() const currentObject = useRecoilValue(currentObjectState) + const { saveSnapshot } = useUndoRedo() useEffect(() => { if (canvas) { @@ -99,6 +101,8 @@ export function useObjectBatch({ isHidden, setIsHidden }) { let rect, isDown, origX, origY let selectedSurface + + saveSnapshot() //프리입력 if (selectedType === INPUT_TYPE.FREE) { @@ -321,6 +325,8 @@ export function useObjectBatch({ isHidden, setIsHidden }) { return } + saveSnapshot() + let theta = 0 let bottomLength = 0, @@ -1296,6 +1302,7 @@ export function useObjectBatch({ isHidden, setIsHidden }) { } const resizeObjectBatch = (side, target, width, height, popupId) => { + saveSnapshot() const objectWidth = target.width const objectHeight = target.height @@ -1417,6 +1424,7 @@ export function useObjectBatch({ isHidden, setIsHidden }) { const parentSurface = canvas?.getObjects().filter((item) => item.name === POLYGON_TYPE.ROOF && item.id === obj.parentId)[0] if (obj) { + saveSnapshot() obj.set({ lockMovementX: false, lockMovementY: false, @@ -1505,6 +1513,7 @@ export function useObjectBatch({ isHidden, setIsHidden }) { const copyObjectBatch = () => { const obj = canvas.getActiveObject() if (obj) { + saveSnapshot() let clonedObj = null const parentSurface = canvas?.getObjects().filter((item) => item.name === POLYGON_TYPE.ROOF && item.id === obj.parentId)[0] @@ -1583,6 +1592,7 @@ export function useObjectBatch({ isHidden, setIsHidden }) { } const dormerOffset = (arrow1, arrow2, length1, length2) => { + saveSnapshot() length1 = parseInt(length1) / 10 length2 = parseInt(length2) / 10 diff --git a/src/hooks/roofcover/useAuxiliaryDrawing.js b/src/hooks/roofcover/useAuxiliaryDrawing.js index c84e00a7..1e11c935 100644 --- a/src/hooks/roofcover/useAuxiliaryDrawing.js +++ b/src/hooks/roofcover/useAuxiliaryDrawing.js @@ -13,6 +13,7 @@ import { usePopup } from '@/hooks/usePopup' import { calculateAngle, isSamePoint } from '@/util/qpolygon-utils' import { POLYGON_TYPE } from '@/common/common' import { useMessage } from '../useMessage' +import { useUndoRedo } from '@/hooks/useUndoRedo' import { auxiliaryLineAngle1State, auxiliaryLineAngle2State, @@ -36,6 +37,7 @@ export function useAuxiliaryDrawing(id, isUseEffect = true) { const { getAdsorptionPoints } = useAdsorptionPoint() const { closePopup } = usePopup() const { getMessage } = useMessage() + const { saveSnapshot } = useUndoRedo() const adsorptionRange = useRecoilValue(adsorptionRangeState) @@ -652,6 +654,8 @@ export function useAuxiliaryDrawing(id, isUseEffect = true) { return } + saveSnapshot() + const roofBases = canvas.getObjects().filter((obj) => obj.name === POLYGON_TYPE.ROOF) /*const allLines = [...auxiliaryLines] @@ -883,6 +887,18 @@ export function useAuxiliaryDrawing(id, isUseEffect = true) { return } + // lineHistory가 비어있으면 캔버스에서 직접 미확정 보조선을 찾아 제거 + if (lineHistory.current.length === 0) { + const unfixedLines = canvas.getObjects().filter((obj) => obj.name === 'auxiliaryLine' && !obj.isAuxiliaryFixed) + if (unfixedLines.length > 0) { + const lastLine = unfixedLines[unfixedLines.length - 1] + canvas.remove(lastLine) + canvas.renderAll() + } + addCanvasMouseEventListener('mouse:move', mouseMove) + return + } + const lastLine = lineHistory.current.pop() if (lastLine) { roofAdsorptionPoints.current = roofAdsorptionPoints.current.filter( @@ -901,6 +917,7 @@ export function useAuxiliaryDrawing(id, isUseEffect = true) { if (!confirm(getMessage('want.to.complete.auxiliary.creation'))) { return } + saveSnapshot() // cutAuxiliary() diff --git a/src/hooks/roofcover/useEavesGableEdit.js b/src/hooks/roofcover/useEavesGableEdit.js index e6397091..e83d5060 100644 --- a/src/hooks/roofcover/useEavesGableEdit.js +++ b/src/hooks/roofcover/useEavesGableEdit.js @@ -11,6 +11,7 @@ import { useSwal } from '@/hooks/useSwal' import { usePopup } from '@/hooks/usePopup' import { getChonByDegree } from '@/util/canvas-util' import { settingModalFirstOptionsState } from '@/store/settingAtom' +import { useUndoRedo } from '@/hooks/useUndoRedo' // 처마.케라바 변경 export function useEavesGableEdit(id) { @@ -29,6 +30,7 @@ export function useEavesGableEdit(id) { const typeRef = useRef(TYPES.EAVES) const { removeLine, addPitchTextsByOuterLines } = useLine() const { swalFire } = useSwal() + const { saveSnapshot } = useUndoRedo() const { drawRoofPolygon } = useMode() const currentAngleType = useRecoilValue(currentAngleTypeSelector) @@ -210,6 +212,7 @@ export function useEavesGableEdit(id) { break } + saveSnapshot() target.set({ attributes, }) diff --git a/src/hooks/roofcover/useMovementSetting.js b/src/hooks/roofcover/useMovementSetting.js index 92f2e2c0..c5085c6c 100644 --- a/src/hooks/roofcover/useMovementSetting.js +++ b/src/hooks/roofcover/useMovementSetting.js @@ -10,6 +10,7 @@ import Big from 'big.js' import { calcLinePlaneSize } from '@/util/qpolygon-utils' import { getSelectLinePosition } from '@/util/skeleton-utils' import { useMouse } from '@/hooks/useMouse' +import { useUndoRedo } from '@/hooks/useUndoRedo' //동선이동 형 올림 내림 export function useMovementSetting(id) { @@ -31,6 +32,7 @@ export function useMovementSetting(id) { const [type, setType] = useState(TYPE.FLOW_LINE) const typeRef = useRef(type) const { swalFire } = useSwal() + const { saveSnapshot } = useUndoRedo() const FLOW_LINE_REF = { POINTER_INPUT_REF: useRef(null), @@ -464,6 +466,7 @@ export function useMovementSetting(id) { canvas.renderAll() } + saveSnapshot() const target = selectedObject.current !== null ? selectedObject.current : CONFIRM_LINE_REF.current?.target if (!target) return diff --git a/src/hooks/roofcover/useOuterLineWall.js b/src/hooks/roofcover/useOuterLineWall.js index dbd9f104..0c49fccb 100644 --- a/src/hooks/roofcover/useOuterLineWall.js +++ b/src/hooks/roofcover/useOuterLineWall.js @@ -33,6 +33,7 @@ import { usePopup } from '@/hooks/usePopup' import Big from 'big.js' import RoofShapeSetting from '@/components/floor-plan/modal/roofShape/RoofShapeSetting' import { useObject } from '@/hooks/useObject' +import { useUndoRedo } from '@/hooks/useUndoRedo' //외벽선 그리기 export function useOuterLineWall(id, propertiesId) { @@ -58,6 +59,7 @@ export function useOuterLineWall(id, propertiesId) { const { tempGridMode } = useTempGrid() const { addPolygonByLines } = usePolygon() const { handleSelectableObjects } = useObject() + const { saveSnapshot } = useUndoRedo() const verticalHorizontalMode = useRecoilValue(verticalHorizontalModeState) const adsorptionPointAddMode = useRecoilValue(adsorptionPointAddModeState) @@ -149,6 +151,7 @@ export function useOuterLineWall(id, propertiesId) { pointer = { x: Big(pointer.x).toNumber(), y: Big(pointer.y).toNumber() } if (points.length === 0) { + saveSnapshot() setPoints((prev) => [...prev, pointer]) } else { const lastPoint = points[points.length - 1] diff --git a/src/hooks/roofcover/usePropertiesSetting.js b/src/hooks/roofcover/usePropertiesSetting.js index dd5ca91a..a5868f16 100644 --- a/src/hooks/roofcover/usePropertiesSetting.js +++ b/src/hooks/roofcover/usePropertiesSetting.js @@ -13,6 +13,7 @@ import RoofAllocationSetting from '@/components/floor-plan/modal/roofAllocation/ import { settingModalFirstOptionsState } from '@/store/settingAtom' import { useSwal } from '@/hooks/useSwal' import { useMessage } from '@/hooks/useMessage' +import { useUndoRedo } from '@/hooks/useUndoRedo' // 외벽선 속성 설정 export function usePropertiesSetting(id) { @@ -29,6 +30,7 @@ export function usePropertiesSetting(id) { const { addPopup, closePopup } = usePopup() const { swalFire } = useSwal() const { getMessage } = useMessage() + const { saveSnapshot } = useUndoRedo() useEffect(() => { const lines = canvas.getObjects().filter((obj) => obj.name === 'outerLine') @@ -67,6 +69,7 @@ export function usePropertiesSetting(id) { if (!selectedLine) { return } + saveSnapshot() selectedLine.set({ stroke: '#45CD7D', strokeWidth: 4, @@ -86,6 +89,7 @@ export function usePropertiesSetting(id) { if (!selectedLine) { return } + saveSnapshot() selectedLine.set({ stroke: '#3FBAE6', strokeWidth: 4, @@ -150,6 +154,7 @@ export function usePropertiesSetting(id) { return } + saveSnapshot() lines.forEach((line) => { line.set({ attributes: line.attributes ? line.attributes : { offset: 0, type: LINE_TYPE.WALLLINE.WALL }, diff --git a/src/hooks/roofcover/useRoofAllocationSetting.js b/src/hooks/roofcover/useRoofAllocationSetting.js index c31797cf..62084977 100644 --- a/src/hooks/roofcover/useRoofAllocationSetting.js +++ b/src/hooks/roofcover/useRoofAllocationSetting.js @@ -31,6 +31,7 @@ import { usePlan } from '@/hooks/usePlan' import { roofsState } from '@/store/roofAtom' import { useText } from '@/hooks/useText' import { QLine } from '@/components/fabric/QLine' +import { useUndoRedo } from '@/hooks/useUndoRedo' export function useRoofAllocationSetting(id) { const canvas = useRecoilValue(canvasState) @@ -65,6 +66,7 @@ export function useRoofAllocationSetting(id) { const resetPoints = useResetRecoilState(outerLinePointsState) const [corridorDimension, setCorridorDimension] = useRecoilState(corridorDimensionSelector) const { changeCorridorDimensionText } = useText() + const { saveSnapshot } = useUndoRedo() const outlineDisplay = useRecoilValue(outlineDisplaySelector) useEffect(() => { @@ -440,6 +442,7 @@ export function useRoofAllocationSetting(id) { * 지붕면 할당 */ const apply = () => { + saveSnapshot() const roofBases = canvas.getObjects().filter((obj) => obj.name === POLYGON_TYPE.ROOF && !obj.roofMaterial) const wallLines = canvas.getObjects().filter((obj) => obj.name === POLYGON_TYPE.WALL) roofBases.forEach((roofBase) => { diff --git a/src/hooks/roofcover/useRoofShapePassivitySetting.js b/src/hooks/roofcover/useRoofShapePassivitySetting.js index 6149ca39..b693fe41 100644 --- a/src/hooks/roofcover/useRoofShapePassivitySetting.js +++ b/src/hooks/roofcover/useRoofShapePassivitySetting.js @@ -10,6 +10,7 @@ import { usePolygon } from '@/hooks/usePolygon' import { useSwal } from '@/hooks/useSwal' import { usePopup } from '@/hooks/usePopup' import { getChonByDegree } from '@/util/canvas-util' +import { useUndoRedo } from '@/hooks/useUndoRedo' //지붕형상 수동 설정 export function useRoofShapePassivitySetting(id) { @@ -39,6 +40,7 @@ export function useRoofShapePassivitySetting(id) { const [isLoading, setIsLoading] = useState(false) const { closePopup } = usePopup() + const { saveSnapshot } = useUndoRedo() const buttons = [ { id: 1, name: getMessage('eaves'), type: TYPES.EAVES }, { id: 2, name: getMessage('gable'), type: TYPES.GABLE }, @@ -166,6 +168,7 @@ export function useRoofShapePassivitySetting(id) { } } + saveSnapshot() currentLineRef.current.set({ attributes: { ...attributes, isFixed: true }, }) @@ -191,6 +194,7 @@ export function useRoofShapePassivitySetting(id) { } const handleSave = () => { + saveSnapshot() isFix.current = true handleLineToPolygon() diff --git a/src/hooks/roofcover/useRoofShapeSetting.js b/src/hooks/roofcover/useRoofShapeSetting.js index f5c84f5b..e7c8e260 100644 --- a/src/hooks/roofcover/useRoofShapeSetting.js +++ b/src/hooks/roofcover/useRoofShapeSetting.js @@ -20,6 +20,7 @@ import { } from '@/store/settingAtom' import { useAxios } from '@/hooks/useAxios' import { globalLocaleStore } from '@/store/localeAtom' +import { useUndoRedo } from '@/hooks/useUndoRedo' // 지붕형상 설정 export function useRoofShapeSetting(id) { @@ -69,6 +70,7 @@ export function useRoofShapeSetting(id) { const { post } = useAxios(globalLocaleState) const { addLine, removeLine } = useLine() + const { saveSnapshot } = useUndoRedo() useEffect(() => { pitchRef.current = currentAngleType === ANGLE_TYPE.SLOPE ? pitch : getChonByDegree(pitch) @@ -182,6 +184,7 @@ export function useRoofShapeSetting(id) { ] const handleSave = () => { + saveSnapshot() let outerLines = canvas.getObjects().filter((obj) => obj.name === 'outerLine') let direction @@ -661,6 +664,7 @@ export function useRoofShapeSetting(id) { return } + saveSnapshot() let attributes switch (buttonAct) { case 1: { diff --git a/src/hooks/surface/usePlacementShapeDrawing.js b/src/hooks/surface/usePlacementShapeDrawing.js index 374616be..0ba0af66 100644 --- a/src/hooks/surface/usePlacementShapeDrawing.js +++ b/src/hooks/surface/usePlacementShapeDrawing.js @@ -26,6 +26,7 @@ import { useSurfaceShapeBatch } from './useSurfaceShapeBatch' import { roofDisplaySelector } from '@/store/settingAtom' import { useRoofFn } from '@/hooks/common/useRoofFn' import { useObject } from '@/hooks/useObject' +import { useUndoRedo } from '@/hooks/useUndoRedo' // 배치면 그리기 export function usePlacementShapeDrawing(id) { @@ -74,6 +75,7 @@ export function usePlacementShapeDrawing(id) { const isFix = useRef(false) const { closePopup, addPopup } = usePopup() + const { saveSnapshot } = useUndoRedo() const globalPitch = useRecoilValue(globalPitchState) @@ -130,6 +132,7 @@ export function usePlacementShapeDrawing(id) { let pointer = getIntersectMousePoint(e) if (points.length === 0) { + saveSnapshot() setPoints((prev) => [...prev, pointer]) } else { const lastPoint = points[points.length - 1] @@ -142,9 +145,17 @@ export function usePlacementShapeDrawing(id) { const slope = Math.abs(vector.y / vector.x) if (slope >= 1) { - newPoint = { x: lastPoint.x, y: pointer.y } + // 기울기가 1 이상이면 수직으로 제한 + newPoint = { + x: lastPoint.x, + y: pointer.y, + } } else { - newPoint = { x: pointer.x, y: lastPoint.y } + // 기울기가 1 미만이면 수평으로 제한 + newPoint = { + x: pointer.x, + y: lastPoint.y, + } } } setPoints((prev) => [...prev, newPoint]) @@ -302,9 +313,9 @@ export function usePlacementShapeDrawing(id) { // 기존 도형의 변과 일치하는 경우 planeSize를 상속하는 함수 const inheritPlaneSizeFromExistingShapes = (newPolygon) => { const tolerance = 2 - const existingPolygons = canvas.getObjects().filter( - (obj) => (obj.name === POLYGON_TYPE.ROOF || obj.name === POLYGON_TYPE.WALL) && obj.id !== newPolygon.id && obj.lines, - ) + const existingPolygons = canvas + .getObjects() + .filter((obj) => (obj.name === POLYGON_TYPE.ROOF || obj.name === POLYGON_TYPE.WALL) && obj.id !== newPolygon.id && obj.lines) if (existingPolygons.length === 0) return @@ -330,15 +341,9 @@ export function usePlacementShapeDrawing(id) { // 1순위: 양 끝점이 정확히 일치 const forwardMatch = - Math.abs(x1 - ex1) < tolerance && - Math.abs(y1 - ey1) < tolerance && - Math.abs(x2 - ex2) < tolerance && - Math.abs(y2 - ey2) < tolerance + Math.abs(x1 - ex1) < tolerance && Math.abs(y1 - ey1) < tolerance && Math.abs(x2 - ex2) < tolerance && Math.abs(y2 - ey2) < tolerance const reverseMatch = - Math.abs(x1 - ex2) < tolerance && - Math.abs(y1 - ey2) < tolerance && - Math.abs(x2 - ex1) < tolerance && - Math.abs(y2 - ey1) < tolerance + Math.abs(x1 - ex2) < tolerance && Math.abs(y1 - ey2) < tolerance && Math.abs(x2 - ex1) < tolerance && Math.abs(y2 - ey1) < tolerance if (forwardMatch || reverseMatch) { line.attributes = { ...line.attributes, planeSize: edge.attributes.planeSize } @@ -423,9 +428,7 @@ export function usePlacementShapeDrawing(id) { // 기존 도형에서 매칭되는 변의 planeSize를 찾는 헬퍼 함수 const findMatchingEdgePlaneSize = (x1, y1, x2, y2) => { const tolerance = 2 - const existingPolygons = canvas.getObjects().filter( - (obj) => (obj.name === POLYGON_TYPE.ROOF || obj.name === POLYGON_TYPE.WALL) && obj.lines, - ) + const existingPolygons = canvas.getObjects().filter((obj) => (obj.name === POLYGON_TYPE.ROOF || obj.name === POLYGON_TYPE.WALL) && obj.lines) const isHorizontal = Math.abs(y1 - y2) < tolerance const isVertical = Math.abs(x1 - x2) < tolerance diff --git a/src/hooks/surface/useSurfaceShapeBatch.js b/src/hooks/surface/useSurfaceShapeBatch.js index 27a6f737..2283bcb5 100644 --- a/src/hooks/surface/useSurfaceShapeBatch.js +++ b/src/hooks/surface/useSurfaceShapeBatch.js @@ -30,6 +30,7 @@ import { useText } from '@/hooks/useText' import SizeSetting from '@/components/floor-plan/modal/object/SizeSetting' import { v4 as uuidv4 } from 'uuid' import { useState } from 'react' +import { useUndoRedo } from '@/hooks/useUndoRedo' export function useSurfaceShapeBatch({ isHidden, setIsHidden }) { const { getMessage } = useMessage() @@ -52,6 +53,7 @@ export function useSurfaceShapeBatch({ isHidden, setIsHidden }) { const { fetchSettings } = useCanvasSetting(false) const [currentObject, setCurrentObject] = useRecoilState(currentObjectState) const [popupId, setPopupId] = useState(uuidv4()) + const { saveSnapshot } = useUndoRedo() const applySurfaceShape = (surfaceRefs, selectedType, id) => { let length1, length2, length3, length4, length5 @@ -857,6 +859,7 @@ export function useSurfaceShapeBatch({ isHidden, setIsHidden }) { } const moveSurfaceShapeBatch = () => { + saveSnapshot() const roof = canvas.getActiveObject() if (roof) { @@ -937,6 +940,7 @@ export function useSurfaceShapeBatch({ isHidden, setIsHidden }) { const resizeSurfaceShapeBatch = (side, target, width, height) => { if (!target || target.type !== 'QPolygon') return + saveSnapshot() width = width / 10 height = height / 10 @@ -1454,6 +1458,7 @@ export function useSurfaceShapeBatch({ isHidden, setIsHidden }) { const rotateSurfaceShapeBatch = () => { if (currentObject) { + saveSnapshot() // 관련 객체들 찾기 // arrow는 제거 const arrow = canvas.getObjects().find((obj) => obj.parentId === currentObject.id && obj.name === 'arrow') diff --git a/src/hooks/useCanvasEvent.js b/src/hooks/useCanvasEvent.js index 7808bb5f..7925ca12 100644 --- a/src/hooks/useCanvasEvent.js +++ b/src/hooks/useCanvasEvent.js @@ -7,6 +7,7 @@ import { fontSelector } from '@/store/fontAtom' import { MENU, POLYGON_TYPE } from '@/common/common' import { useText } from '@/hooks/useText' import { usePolygon } from '@/hooks/usePolygon' +import { useUndoRedo } from '@/hooks/useUndoRedo' // 캔버스에 필요한 이벤트 export function useCanvasEvent() { @@ -19,6 +20,7 @@ export function useCanvasEvent() { const currentMenu = useRecoilValue(currentMenuState) const { changeCorridorDimensionText } = useText() const { setPolygonLinesActualSize } = usePolygon() + const { undo, redo } = useUndoRedo() useEffect(() => { canvas?.setZoom(canvasZoom / 100) @@ -28,6 +30,13 @@ export function useCanvasEvent() { attachDefaultEventOnCanvas() }, [currentMenu]) + // 키보드 이벤트 등록 (Cmd+Z / Ctrl+Z: undo, Shift+Cmd+Z / Shift+Ctrl+Z: redo) + useEffect(() => { + if (!canvas) return + document.addEventListener('keydown', handleKeyDown) + return () => document.removeEventListener('keydown', handleKeyDown) + }, [canvas, undo, redo]) + // 기본적인 이벤트 필요시 추가 const attachDefaultEventOnCanvas = () => { removeEventOnCanvas() @@ -296,12 +305,14 @@ export function useCanvasEvent() { break case 'z': - if (!e.ctrlKey) { + if (!e.ctrlKey && !e.metaKey) { return } - - console.log('뒤로가기') - + if (e.shiftKey) { + redo() + } else { + undo() + } break default: @@ -381,20 +392,26 @@ export function useCanvasEvent() { /** * 선택한 도형을 삭제한다. */ + let _isDeleting = false const handleDelete = () => { + if (_isDeleting) return const targets = canvas?.getActiveObjects() - if (targets?.length === 0) { - alert('삭제할 대상을 선택해주세요.') + if (!targets || targets.length === 0) { return } + _isDeleting = true if (!confirm('정말로 삭제하시겠습니까?')) { + _isDeleting = false return } - targets?.forEach((target) => { + targets.forEach((target) => { canvas?.remove(target) }) + canvas?.discardActiveObject() + canvas?.renderAll() + _isDeleting = false } const handleZoom = (isZoom) => { diff --git a/src/hooks/useUndoRedo.js b/src/hooks/useUndoRedo.js new file mode 100644 index 00000000..ce36d6de --- /dev/null +++ b/src/hooks/useUndoRedo.js @@ -0,0 +1,369 @@ +import { useCallback, useRef } from 'react' +import { useRecoilState, useRecoilValue, useSetRecoilState } from 'recoil' +import { + canvasState, + canvasZoomState, + checkedModuleState, + currentMenuState, + drewRoofCellsState, + moduleIsSetupState, + moduleRowColArrayState, + moduleSetupSurfaceState, + redoStackState, + roofPolygonArrayState, + undoStackState, +} from '@/store/canvasAtom' +import { outerLinePointsState } from '@/store/outerLineAtom' +import { selectedMenuState } from '@/store/menuAtom' +import { popupState } from '@/store/popupAtom' +import { POLYGON_TYPE, SAVE_KEY } from '@/common/common' + +const MAX_HISTORY = 20 + +// 모든 useUndoRedo 인스턴스가 공유하는 복원 플래그 +// useRef는 인스턴스마다 독립적이므로, 모듈 레벨 변수로 전환 +let _isRestoring = false +let _restoreTimer = null + +// loadFromJSON 공통 처리 - 이벤트 일시 중단 후 복원 +function _restoreFromJSON(canvas, snapshot, restoreAtomSnapshot, rebuildCanvasAtoms, setCanvasZoom) { + // 진행 중인 복원 타이머가 있으면 취소 + if (_restoreTimer) { + clearTimeout(_restoreTimer) + _restoreTimer = null + } + + // loadFromJSON 중 object:added 등 이벤트로 인한 부작용 방지 + // 리스너를 저장/복원하지 않고 단순히 off만 한다 + // restoreAtomSnapshot에서 currentMenu 변경 → useCanvasEvent가 attachDefaultEventOnCanvas로 재등록 + ;['object:added', 'object:modified', 'object:removed'].forEach((eventName) => { + canvas.off(eventName) + }) + + canvas.loadFromJSON(snapshot.canvasJSON, () => { + rebuildCanvasAtoms() + canvas.renderAll() + restoreAtomSnapshot(snapshot.atomSnapshot) + + if (canvas.viewportTransform) { + setCanvasZoom(Number((canvas.viewportTransform[0] * 100).toFixed(0))) + } + + // 다음 undo/redo 허용까지 짧은 딜레이 (연속 클릭 디바운스) + _restoreTimer = setTimeout(() => { + _isRestoring = false + _restoreTimer = null + }, 50) + }) +} +// 순환 참조만 안전하게 건너뛰는 JSON 직렬화 +// WeakSet 방식은 공유 참조(같은 배열이 여러 곳에서 참조)까지 제거하므로 사용 불가 +// 대신 조상 경로(ancestors) 스택으로 진짜 순환만 감지 +function safeStringify(obj) { + const ancestors = [] + return JSON.stringify(obj, function (key, value) { + if (typeof value !== 'object' || value === null) return value + + // `this`는 현재 key를 담고 있는 부모 객체 + // ancestors 스택에서 현재 부모 이후의 항목(형제 경로)을 정리 + while (ancestors.length > 0 && ancestors[ancestors.length - 1] !== this) { + ancestors.pop() + } + + // 현재 value가 조상 경로에 이미 있으면 순환 참조 + if (ancestors.includes(value)) return undefined + + ancestors.push(value) + return value + }) +} + +function safeClone(value) { + if (value === null || value === undefined) return value + try { + return JSON.parse(safeStringify(value)) + } catch { + return null + } +} + +export function useUndoRedo() { + const canvas = useRecoilValue(canvasState) + const [undoStack, setUndoStack] = useRecoilState(undoStackState) + const [redoStack, setRedoStack] = useRecoilState(redoStackState) + + const [, setModuleSetupSurface] = useRecoilState(moduleSetupSurfaceState) + const [moduleIsSetup, setModuleIsSetup] = useRecoilState(moduleIsSetupState) + const [checkedModule, setCheckedModule] = useRecoilState(checkedModuleState) + const [moduleRowColArray, setModuleRowColArray] = useRecoilState(moduleRowColArrayState) + const [, setDrewRoofCells] = useRecoilState(drewRoofCellsState) + const [, setRoofPolygonArray] = useRecoilState(roofPolygonArrayState) + const [outerLinePoints, setOuterLinePoints] = useRecoilState(outerLinePointsState) + const [selectedMenu, setSelectedMenu] = useRecoilState(selectedMenuState) + const [currentMenu, setCurrentMenu] = useRecoilState(currentMenuState) + const [, setCanvasZoom] = useRecoilState(canvasZoomState) + const setPopup = useSetRecoilState(popupState) + + // 최신 스택 값을 ref로 유지하여 클로저 stale 문제 방지 + const undoStackRef = useRef(undoStack) + undoStackRef.current = undoStack + const redoStackRef = useRef(redoStack) + redoStackRef.current = redoStack + + const getAtomSnapshot = useCallback(() => { + return { + moduleIsSetup: safeClone(moduleIsSetup || []), + checkedModule: safeClone(checkedModule || []), + moduleRowColArray: safeClone(moduleRowColArray || []), + outerLinePoints: safeClone(outerLinePoints || []), + selectedMenu, + currentMenu, + } + }, [moduleIsSetup, checkedModule, moduleRowColArray, outerLinePoints, selectedMenu, currentMenu]) + + // loadFromJSON 후 오브젝트 간 라이브 참조 관계를 재구축 + const rebuildObjectGraph = useCallback(() => { + if (!canvas) return + const objects = canvas.getObjects() + + // wall polygon의 lines를 캔버스의 outerLine 오브젝트로 재연결 + const walls = objects.filter((obj) => obj.name === POLYGON_TYPE.WALL) + const outerLines = objects.filter((obj) => obj.name === 'outerLine') + + walls.forEach((wall) => { + if (outerLines.length > 0) { + // outerLine에 idx가 있으면 순서대로, 없으면 parentId로 매칭 + const wallOuterLines = outerLines.filter((line) => !line.parentId || line.parentId === wall.id).sort((a, b) => (a.idx ?? 0) - (b.idx ?? 0)) + if (wallOuterLines.length > 0) { + wall.lines = wallOuterLines + } + } + }) + + // roof polygon의 innerLines, hips, ridges, lines를 캔버스 오브젝트로 재연결 + const roofs = objects.filter((obj) => obj.name === POLYGON_TYPE.ROOF) + roofs.forEach((roof) => { + // innerLines: hip과 ridge 라인들 (roof.innerLines.push(hip), roof.innerLines.push(ridge) 패턴) + const hipLines = objects.filter((obj) => obj.parentId === roof.id && obj.name === 'hip') + const ridgeLines = objects.filter((obj) => obj.parentId === roof.id && obj.name === 'ridge') + + if (hipLines.length > 0 || ridgeLines.length > 0) { + roof.innerLines = [...hipLines, ...ridgeLines] + } + if (hipLines.length > 0) { + roof.hips = hipLines + } + if (ridgeLines.length > 0) { + roof.ridges = ridgeLines + } + + // roof.lines: eaveHelpLine 또는 helpLine (지붕면 할당에서 사용) + const eaveHelpLines = objects.filter( + (obj) => (obj.lineName === 'eaveHelpLine' || obj.lineName === 'helpLine' || obj.name === 'helpLine') && obj.roofId === roof.id, + ) + if (eaveHelpLines.length > 0) { + roof.lines = eaveHelpLines + } + + // skeletonLines 재연결 + const skeletonLines = objects.filter((obj) => (obj.lineName === 'roofLine' || obj.lineName === 'skeletonLine') && obj.parentId === roof.id) + if (skeletonLines.length > 0) { + roof.skeletonLines = skeletonLines + } + }) + + // moduleSetupSurface의 modules 배열을 캔버스의 실제 QPolygon 모듈 객체로 재연결 + const surfaces = objects.filter((obj) => obj.name === POLYGON_TYPE.MODULE_SETUP_SURFACE) + const moduleObjects = objects.filter((obj) => obj.name === POLYGON_TYPE.MODULE) + surfaces.forEach((surface) => { + const surfaceModules = moduleObjects.filter((mod) => mod.surfaceId === surface.id) + surface.modules = surfaceModules + }) + + // 부모가 없는 고아 lengthText, arrow, flowText 제거 + const parentIds = new Set(objects.filter((obj) => obj.id).map((obj) => obj.id)) + const orphans = objects.filter( + (obj) => (obj.name === 'lengthText' || obj.name === 'arrow' || obj.name === 'flowText') && obj.parentId && !parentIds.has(obj.parentId), + ) + orphans.forEach((obj) => canvas.remove(obj)) + + // lengthText 이동 관련 속성 및 이벤트 재설정 + // loadFromJSON 중 object:added 이벤트가 꺼져있어 addEvent가 실행되지 않으므로 수동 설정 + const lengthTexts = canvas.getObjects().filter((obj) => obj.name === 'lengthText' && obj.type?.toLowerCase().includes('text')) + lengthTexts.forEach((target) => { + target.bringToFront() + target.selectable = true + target.evented = true + target.lockMovementX = false + target.lockMovementY = false + target.lockRotation = true + target.lockScalingX = true + target.lockScalingY = true + + // 기존 per-object 이벤트 제거 후 재등록 + target.off('selected') + target.off('moving') + + const x = target.left + const y = target.top + + target.on('selected', () => { + Object.keys(target.controls).forEach((controlKey) => { + target.setControlVisible(controlKey, false) + }) + }) + + target.on('moving', () => { + if (target.parentDirection === 'left' || target.parentDirection === 'right') { + const minX = target.minX + const maxX = target.maxX + if (target.left <= minX) { + target.set({ left: minX, top: y }) + } else if (target.left >= maxX) { + target.set({ left: maxX, top: y }) + } else { + target.set({ top: y }) + } + } else if (target.parentDirection === 'top' || target.parentDirection === 'bottom') { + const minY = target.minY + const maxY = target.maxY + if (target.top <= minY) { + target.set({ left: x, top: minY }) + } else if (target.top >= maxY) { + target.set({ left: x, top: maxY }) + } else { + target.set({ left: x }) + } + } + canvas?.renderAll() + }) + }) + }, [canvas]) + + const rebuildCanvasAtoms = useCallback(() => { + if (!canvas) return + + // 먼저 오브젝트 간 참조 관계 재구축 + rebuildObjectGraph() + + const objects = canvas.getObjects() + + const surfaces = objects.filter((obj) => obj.name === POLYGON_TYPE.MODULE_SETUP_SURFACE) + setModuleSetupSurface(surfaces) + + const roofs = objects.filter((obj) => obj.name === POLYGON_TYPE.ROOF) + setRoofPolygonArray(roofs) + + const cells = roofs.filter((obj) => obj.cells && obj.cells.length > 0) + setDrewRoofCells(cells) + }, [canvas, rebuildObjectGraph]) + + const restoreAtomSnapshot = useCallback((atomSnapshot) => { + if (!atomSnapshot) return + setModuleIsSetup(atomSnapshot.moduleIsSetup || []) + setCheckedModule(atomSnapshot.checkedModule || []) + setModuleRowColArray(atomSnapshot.moduleRowColArray || []) + setOuterLinePoints(atomSnapshot.outerLinePoints || []) + if (atomSnapshot.selectedMenu !== undefined) setSelectedMenu(atomSnapshot.selectedMenu) + if (atomSnapshot.currentMenu !== undefined) setCurrentMenu(atomSnapshot.currentMenu) + }, []) + + // canvas.toJSON(SAVE_KEY)의 결과를 순환 참조 없이 안전하게 직렬화 + const getCanvasSnapshot = useCallback(() => { + if (!canvas) return null + try { + const rawJSON = canvas.toJSON(SAVE_KEY) + // 순환 참조 제거 후 다시 파싱하여 순수 데이터 객체로 변환 + return JSON.parse(safeStringify(rawJSON)) + } catch (e) { + console.warn('Canvas snapshot failed:', e) + return null + } + }, [canvas]) + + const saveSnapshot = useCallback(() => { + if (!canvas || _isRestoring) return + + // snapshot 전에 mouseLine 제거 + const mouseLines = canvas.getObjects().filter((obj) => obj.name === 'mouseLine') + mouseLines.forEach((obj) => canvas.remove(obj)) + + const canvasJSON = getCanvasSnapshot() + if (!canvasJSON) return + + const atomSnapshot = getAtomSnapshot() + + const snapshot = { + canvasJSON, + atomSnapshot, + } + + setUndoStack((prev) => { + const newStack = [...prev, snapshot] + if (newStack.length > MAX_HISTORY) { + return newStack.slice(newStack.length - MAX_HISTORY) + } + return newStack + }) + setRedoStack([]) + }, [canvas, getCanvasSnapshot, getAtomSnapshot]) + + const closeAllPopups = useCallback(() => { + setPopup({ config: [], other: [] }) + }, [setPopup]) + + const undo = useCallback(() => { + const currentUndoStack = undoStackRef.current + console.log('currentUndoStack', currentUndoStack) + if (!canvas || currentUndoStack.length === 0 || _isRestoring) return + + _isRestoring = true + closeAllPopups() + + const currentCanvasJSON = getCanvasSnapshot() + const currentAtomSnapshot = getAtomSnapshot() + const currentSnapshot = { + canvasJSON: currentCanvasJSON, + atomSnapshot: currentAtomSnapshot, + } + + const newUndoStack = [...currentUndoStack] + const snapshot = newUndoStack.pop() + + setUndoStack(newUndoStack) + setRedoStack((prev) => [...prev, currentSnapshot]) + + _restoreFromJSON(canvas, snapshot, restoreAtomSnapshot, rebuildCanvasAtoms, setCanvasZoom) + }, [canvas, getCanvasSnapshot, getAtomSnapshot, restoreAtomSnapshot, rebuildCanvasAtoms, closeAllPopups]) + + const redo = useCallback(() => { + const currentRedoStack = redoStackRef.current + if (!canvas || currentRedoStack.length === 0 || _isRestoring) return + + _isRestoring = true + closeAllPopups() + + const currentCanvasJSON = getCanvasSnapshot() + const currentAtomSnapshot = getAtomSnapshot() + const currentSnapshot = { + canvasJSON: currentCanvasJSON, + atomSnapshot: currentAtomSnapshot, + } + + const newRedoStack = [...currentRedoStack] + const snapshot = newRedoStack.pop() + + setRedoStack(newRedoStack) + setUndoStack((prev) => [...prev, currentSnapshot]) + + _restoreFromJSON(canvas, snapshot, restoreAtomSnapshot, rebuildCanvasAtoms, setCanvasZoom) + }, [canvas, getCanvasSnapshot, getAtomSnapshot, restoreAtomSnapshot, rebuildCanvasAtoms, closeAllPopups]) + + return { + saveSnapshot, + undo, + redo, + canUndo: undoStack.length > 0, + canRedo: redoStack.length > 0, + } +} diff --git a/src/store/canvasAtom.js b/src/store/canvasAtom.js index c8bb1ad0..c87db0d1 100644 --- a/src/store/canvasAtom.js +++ b/src/store/canvasAtom.js @@ -408,3 +408,16 @@ export const moduleRowColArrayState = atom({ default: [], dangerouslyAllowMutability: true, }) + +// Undo/Redo 히스토리 스택 +export const undoStackState = atom({ + key: 'undoStackState', + default: [], + dangerouslyAllowMutability: true, +}) + +export const redoStackState = atom({ + key: 'redoStackState', + default: [], + dangerouslyAllowMutability: true, +}) diff --git a/src/styles/_contents.scss b/src/styles/_contents.scss index 58f9a3ca..fd70eb5e 100644 --- a/src/styles/_contents.scss +++ b/src/styles/_contents.scss @@ -152,6 +152,30 @@ } } } + .undo-redo-wrap{ + display: flex; + gap: 2px; + margin-right: 5px; + button{ + display: flex; + align-items: center; + justify-content: center; + width: 28px; + height: 28px; + border-radius: 2px; + background: #373737; + color: #fff; + cursor: pointer; + transition: all .17s ease-in-out; + &:hover:not(.disabled){ + background: #4B4B4B; + } + &.disabled{ + opacity: 0.3; + cursor: default; + } + } + } .vertical-horizontal{ display: flex; min-width: 170px; @@ -1519,6 +1543,9 @@ .btn-from{ gap: 3px; } + .undo-redo-wrap{ + margin-right: 3px; + } .vertical-horizontal{ margin-right: 3px; min-width: 150px; diff --git a/src/util/canvas-util.js b/src/util/canvas-util.js index 5c51560b..7d364f4f 100644 --- a/src/util/canvas-util.js +++ b/src/util/canvas-util.js @@ -1071,7 +1071,9 @@ export function findAndRemoveClosestPoint(targetPoint, points) { export function polygonToTurfPolygon(object, current = false) { let coordinates coordinates = object.points.map((point) => [point.x, point.y]) - if (current) coordinates = object.getCurrentPoints().map((point) => [point.x, point.y]) + if (current && typeof object.getCurrentPoints === 'function') { + coordinates = object.getCurrentPoints().map((point) => [point.x, point.y]) + } coordinates.push(coordinates[0]) return turf.polygon( [coordinates], -- 2.47.2 From d68f92c5d5803b080056ab7f32ecc8f068abe80e Mon Sep 17 00:00:00 2001 From: "hyojun.choi" Date: Wed, 1 Apr 2026 15:54:55 +0900 Subject: [PATCH 02/60] =?UTF-8?q?redo=20undo=20=EC=9E=91=EC=97=85=EC=A4=91?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/components/floor-plan/CanvasMenu.jsx | 21 +++++++----------- .../floor-plan/modal/basic/BasicSetting.jsx | 7 ++++++ .../circuitTrestle/CircuitTrestleSetting.jsx | 3 +++ src/hooks/module/useModuleBasicSetting.js | 2 -- src/hooks/roofcover/useAuxiliaryDrawing.js | 5 ----- src/hooks/surface/usePlacementShapeDrawing.js | 3 --- src/hooks/useCanvasEvent.js | 4 ++-- src/hooks/useUndoRedo.js | 22 ++++++++++++++----- 8 files changed, 37 insertions(+), 30 deletions(-) diff --git a/src/components/floor-plan/CanvasMenu.jsx b/src/components/floor-plan/CanvasMenu.jsx index 6ef889d1..b4f446e1 100644 --- a/src/components/floor-plan/CanvasMenu.jsx +++ b/src/components/floor-plan/CanvasMenu.jsx @@ -36,7 +36,12 @@ import { import { sessionStore } from '@/store/commonAtom' import { outerLinePointsState } from '@/store/outerLineAtom' import { appMessageStore, globalLocaleStore } from '@/store/localeAtom' -import { addedRoofsState, basicSettingState, selectedRoofMaterialSelector, settingModalFirstOptionsState } from '@/store/settingAtom' +import { + addedRoofsState, + basicSettingState, + selectedRoofMaterialSelector, + settingModalFirstOptionsState, +} from '@/store/settingAtom' import { placementShapeDrawingPointsState } from '@/store/placementShapeDrawingAtom' import { commonUtilsState } from '@/store/commonUtilsAtom' import { menusState } from '@/store/menuAtom' @@ -572,23 +577,13 @@ export default function CanvasMenu(props) { {!['estimate', 'simulation'].some((num) => num === selectedMenu) && ( <>
- -
@@ -121,14 +122,12 @@ export default function AuxiliaryEdit(props) { className={`direction left ${arrow2 === '←' ? 'act' : ''}`} onClick={() => { setArrow2('←') - document.dispatchEvent(new KeyboardEvent('keydown', { key: 'ArrowLeft' })) }} >
diff --git a/src/components/floor-plan/modal/auxiliary/AuxiliarySize.jsx b/src/components/floor-plan/modal/auxiliary/AuxiliarySize.jsx index 6b0abda1..09091011 100644 --- a/src/components/floor-plan/modal/auxiliary/AuxiliarySize.jsx +++ b/src/components/floor-plan/modal/auxiliary/AuxiliarySize.jsx @@ -8,6 +8,7 @@ import { useEffect, useState } from 'react' import Big from 'big.js' import { calcLineActualSize, calcLinePlaneSize } from '@/util/qpolygon-utils' import { normalizeDigits } from '@/util/input-utils' +import { useUndoRedo } from '@/hooks/useUndoRedo' import { CalculatorInput } from '@/components/common/input/CalcInput' export default function AuxiliarySize(props) { @@ -53,7 +54,9 @@ export default function AuxiliarySize(props) { } } + const { saveSnapshot } = useUndoRedo() const handleSave = () => { + saveSnapshot() const { x1, y1, x2, y2 } = currentObject // 선택한 선분의 planeSize와 actualSize의 사잇각을 구한다. diff --git a/src/components/floor-plan/modal/module/PanelEdit.jsx b/src/components/floor-plan/modal/module/PanelEdit.jsx index baf4b101..1c9d0e75 100644 --- a/src/components/floor-plan/modal/module/PanelEdit.jsx +++ b/src/components/floor-plan/modal/module/PanelEdit.jsx @@ -8,6 +8,7 @@ import { canvasState } from '@/store/canvasAtom' import { POLYGON_TYPE } from '@/common/common' import { useModule } from '@/hooks/module/useModule' import { useSwal } from '@/hooks/useSwal' +import { useUndoRedo } from '@/hooks/useUndoRedo' export const PANEL_EDIT_TYPE = { MOVE: 'move', @@ -30,6 +31,7 @@ export default function PanelEdit(props) { const canvas = useRecoilValue(canvasState) const { swalFire } = useSwal() const { moduleMove, moduleCopy, moduleMultiMove, moduleMultiCopy, moduleMoveAll, moduleCopyAll } = useModule() + const { saveSnapshot } = useUndoRedo() useEffect(() => { if (!canvas) { const isSetupModules = canvas.getObjects().filter((obj) => obj.name === 'module') // selectedObj에 없는 객체만 필터링 @@ -52,6 +54,7 @@ export default function PanelEdit(props) { //모듈 이동 적용 const handleApply = () => { + saveSnapshot() if (length <= 0) { swalFire({ title: getMessage('common.message.please.input.over', [1]), diff --git a/src/components/floor-plan/modal/module/column/ColumnInsert.jsx b/src/components/floor-plan/modal/module/column/ColumnInsert.jsx index 0c160656..869e7032 100644 --- a/src/components/floor-plan/modal/module/column/ColumnInsert.jsx +++ b/src/components/floor-plan/modal/module/column/ColumnInsert.jsx @@ -1,3 +1,4 @@ +import { useUndoRedo } from '@/hooks/useUndoRedo' import WithDraggable from '@/components/common/draggable/WithDraggable' import { useRecoilValue } from 'recoil' import { contextPopupPositionState } from '@/store/popupAtom' @@ -14,7 +15,9 @@ export default function ColumnInsert(props) { const [selectedType, setSelectedType] = useState(MODULE_INSERT_TYPE.LEFT) const { getMessage } = useMessage() const { moduleColumnInsert } = useModule() + const { saveSnapshot } = useUndoRedo() const handleApply = () => { + saveSnapshot() moduleColumnInsert(selectedType) closePopup(id) } diff --git a/src/components/floor-plan/modal/module/column/ColumnRemove.jsx b/src/components/floor-plan/modal/module/column/ColumnRemove.jsx index b9079642..2aafb954 100644 --- a/src/components/floor-plan/modal/module/column/ColumnRemove.jsx +++ b/src/components/floor-plan/modal/module/column/ColumnRemove.jsx @@ -1,3 +1,4 @@ +import { useUndoRedo } from '@/hooks/useUndoRedo' import WithDraggable from '@/components/common/draggable/WithDraggable' import { useRecoilValue } from 'recoil' import { contextPopupPositionState } from '@/store/popupAtom' @@ -14,6 +15,7 @@ export default function ColumnRemove(props) { const [selectedType, setSelectedType] = useState(MODULE_REMOVE_TYPE.LEFT) const { getMessage } = useMessage() const { moduleColumnRemove } = useModule() + const { saveSnapshot } = useUndoRedo() const types = [ { name: getMessage('modal.panel.column.remove.type.left'), value: MODULE_REMOVE_TYPE.LEFT }, { name: getMessage('modal.panel.column.remove.type.right'), value: MODULE_REMOVE_TYPE.RIGHT }, @@ -21,6 +23,7 @@ export default function ColumnRemove(props) { { name: getMessage('modal.panel.column.remove.type.none'), value: MODULE_REMOVE_TYPE.NONE }, ] const handleApply = () => { + saveSnapshot() moduleColumnRemove(selectedType) closePopup(id) } diff --git a/src/components/floor-plan/modal/module/row/RowInsert.jsx b/src/components/floor-plan/modal/module/row/RowInsert.jsx index e9fb799c..ff7cb813 100644 --- a/src/components/floor-plan/modal/module/row/RowInsert.jsx +++ b/src/components/floor-plan/modal/module/row/RowInsert.jsx @@ -1,3 +1,4 @@ +import { useUndoRedo } from '@/hooks/useUndoRedo' import WithDraggable from '@/components/common/draggable/WithDraggable' import Image from 'next/image' import { useState } from 'react' @@ -14,7 +15,9 @@ export default function RowInsert(props) { const [selectedType, setSelectedType] = useState(MODULE_INSERT_TYPE.TOP) const { getMessage } = useMessage() const { muduleRowInsert } = useModule() + const { saveSnapshot } = useUndoRedo() const handleApply = () => { + saveSnapshot() muduleRowInsert(selectedType) closePopup(id) } diff --git a/src/components/floor-plan/modal/module/row/RowRemove.jsx b/src/components/floor-plan/modal/module/row/RowRemove.jsx index 5cf95b3f..cdfeebd5 100644 --- a/src/components/floor-plan/modal/module/row/RowRemove.jsx +++ b/src/components/floor-plan/modal/module/row/RowRemove.jsx @@ -1,3 +1,4 @@ +import { useUndoRedo } from '@/hooks/useUndoRedo' import WithDraggable from '@/components/common/draggable/WithDraggable' import Image from 'next/image' import { useState } from 'react' @@ -14,6 +15,7 @@ export default function RowRemove(props) { const [selectedType, setSelectedType] = useState(MODULE_REMOVE_TYPE.TOP) const { getMessage } = useMessage() const { moduleRowRemove } = useModule() + const { saveSnapshot } = useUndoRedo() const types = [ { name: getMessage('modal.row.remove.type.up'), value: MODULE_REMOVE_TYPE.TOP }, { name: getMessage('modal.row.remove.type.down'), value: MODULE_REMOVE_TYPE.BOTTOM }, @@ -21,6 +23,7 @@ export default function RowRemove(props) { { name: getMessage('modal.row.remove.type.none'), value: MODULE_REMOVE_TYPE.NONE }, ] const handleApply = () => { + saveSnapshot() // if (apply) apply() moduleRowRemove(selectedType) closePopup(id) diff --git a/src/hooks/common/useMenu.js b/src/hooks/common/useMenu.js index 40c1b6a0..8cdfeb1b 100644 --- a/src/hooks/common/useMenu.js +++ b/src/hooks/common/useMenu.js @@ -21,6 +21,7 @@ import { MENU } from '@/common/common' import { useTrestle } from '@/hooks/module/useTrestle' import { useOrientation } from '@/hooks/module/useOrientation' import { corridorDimensionSelector } from '@/store/settingAtom' +import { useUndoRedo } from '@/hooks/useUndoRedo' /** * 메뉴 처리 훅 @@ -36,6 +37,7 @@ export default function useMenu() { const { clear: trestleClear, setAllModuleSurfaceIsComplete } = useTrestle() const { nextStep } = useOrientation() const [corridorDimension, setCorridorDimension] = useRecoilState(corridorDimensionSelector) + const { saveSnapshot } = useUndoRedo() const handleMenu = (type) => { closeAll() if (type === 'outline') { diff --git a/src/hooks/common/useRoofFn.js b/src/hooks/common/useRoofFn.js index 4721d581..19b6daa9 100644 --- a/src/hooks/common/useRoofFn.js +++ b/src/hooks/common/useRoofFn.js @@ -8,6 +8,7 @@ import { useLine } from '@/hooks/useLine' import { outerLinePointsState } from '@/store/outerLineAtom' import { usePolygon } from '@/hooks/usePolygon' import { useText } from '@/hooks/useText' +import { useUndoRedo } from '@/hooks/useUndoRedo' const ROOF_COLOR = { 0: 'rgb(199,240,213)', @@ -26,6 +27,7 @@ export function useRoofFn() { const { setPolygonLinesActualSize } = usePolygon() const { changeCorridorDimensionText } = useText() const [outerLinePoints, setOuterLinePoints] = useRecoilState(outerLinePointsState) + const { saveSnapshot } = useUndoRedo() //면형상 선택 클릭시 지붕 패턴 입히기 function setSurfaceShapePattern(polygon, mode = 'onlyBorder', trestleMode = false, roofMaterial, isForceChange = false, isDisplay = false) { @@ -236,6 +238,9 @@ export function useRoofFn() { }) canvas.on('mouse:down', (event) => { + // checkPolygon 을 캔버스에서 제거한 뒤 스냅샷을 찍어야 빨간 사각형이 포함되지 않음 + canvas.remove(checkPolygon) + saveSnapshot() let mousePos = canvas.getPointer(event.e) mousePos = { x: Math.round(mousePos.x), y: Math.round(mousePos.y) } diff --git a/src/hooks/roofcover/useRoofShapePassivitySetting.js b/src/hooks/roofcover/useRoofShapePassivitySetting.js index b693fe41..bbd9dcaf 100644 --- a/src/hooks/roofcover/useRoofShapePassivitySetting.js +++ b/src/hooks/roofcover/useRoofShapePassivitySetting.js @@ -1,10 +1,11 @@ import { ANGLE_TYPE, canvasState, currentAngleTypeSelector, currentObjectState, pitchTextSelector } from '@/store/canvasAtom' -import { useRecoilValue } from 'recoil' +import { useRecoilValue, useSetRecoilState } from 'recoil' +import { currentMenuState } from '@/store/canvasAtom' import { useEffect, useRef, useState } from 'react' import { useLine } from '@/hooks/useLine' import { useMessage } from '@/hooks/useMessage' import { useEvent } from '@/hooks/useEvent' -import { LINE_TYPE, POLYGON_TYPE } from '@/common/common' +import { LINE_TYPE, MENU, POLYGON_TYPE } from '@/common/common' import { useMode } from '@/hooks/useMode' import { usePolygon } from '@/hooks/usePolygon' import { useSwal } from '@/hooks/useSwal' @@ -20,6 +21,7 @@ export function useRoofShapePassivitySetting(id) { SHED: 'shed', } const canvas = useRecoilValue(canvasState) + const setCurrentMenu = useSetRecoilState(currentMenuState) const currentAngleType = useRecoilValue(currentAngleTypeSelector) const pitchText = useRecoilValue(pitchTextSelector) const { getMessage } = useMessage() @@ -194,10 +196,11 @@ export function useRoofShapePassivitySetting(id) { } const handleSave = () => { - saveSnapshot() + saveSnapshot(null, { force: true }) isFix.current = true handleLineToPolygon() - + // 수동설정 완료 → 지붕면 할당 메뉴로 전환 (undo 가드 해제) + setCurrentMenu(MENU.ROOF_COVERING.ROOF_SHAPE_ALLOC) closePopup(id) } diff --git a/src/hooks/roofcover/useWallLineOffsetSetting.js b/src/hooks/roofcover/useWallLineOffsetSetting.js index 31d8f95f..6b254473 100644 --- a/src/hooks/roofcover/useWallLineOffsetSetting.js +++ b/src/hooks/roofcover/useWallLineOffsetSetting.js @@ -7,7 +7,9 @@ import { useLine } from '@/hooks/useLine' import { useSwal } from '@/hooks/useSwal' import { usePopup } from '@/hooks/usePopup' import Big from 'big.js' +import { calcLinePlaneSize } from '@/util/qpolygon-utils' import { outerLineFixState } from '@/store/outerLineAtom' +import { useUndoRedo } from '@/hooks/useUndoRedo' // 외벽선 편집 및 오프셋 export function useWallLineOffsetSetting(id) { @@ -17,7 +19,7 @@ export function useWallLineOffsetSetting(id) { const { closePopup } = usePopup() const { swalFire } = useSwal() const { addCanvasMouseEventListener, initEvent } = useEvent() - // const { addCanvasMouseEventListener, initEvent } = useContext(EventContext) + const { saveSnapshot } = useUndoRedo() const wallLineEditRef = useRef(null) const length1Ref = useRef(null) const length2Ref = useRef(null) @@ -46,7 +48,13 @@ export function useWallLineOffsetSetting(id) { direction: direction, }) - line.attributes = { ...currentWallLineRef.current.attributes } + // 분할된 라인의 실제 길이로 planeSize/actualSize 재계산 + const newPlaneSize = calcLinePlaneSize({ x1: point1.x, y1: point1.y, x2: point2.x, y2: point2.y }) + line.attributes = { + ...currentWallLineRef.current.attributes, + planeSize: newPlaneSize, + actualSize: newPlaneSize, + } } const TYPES = { @@ -197,9 +205,9 @@ export function useWallLineOffsetSetting(id) { const handleSave = () => { if (currentWallLineRef.current === null) { - // alert('보조선을 먼저 선택하세요') return } + saveSnapshot() switch (type) { case TYPES.WALL_LINE_EDIT: handleWallLineEditSave() diff --git a/src/hooks/surface/usePlacementShapeDrawing.js b/src/hooks/surface/usePlacementShapeDrawing.js index 892f5f1a..2f08713e 100644 --- a/src/hooks/surface/usePlacementShapeDrawing.js +++ b/src/hooks/surface/usePlacementShapeDrawing.js @@ -27,6 +27,7 @@ import { useSurfaceShapeBatch } from './useSurfaceShapeBatch' import { roofDisplaySelector } from '@/store/settingAtom' import { useRoofFn } from '@/hooks/common/useRoofFn' import { useObject } from '@/hooks/useObject' +import { useUndoRedo } from '@/hooks/useUndoRedo' // 배치면 그리기 export function usePlacementShapeDrawing(id) { @@ -48,6 +49,7 @@ export function usePlacementShapeDrawing(id) { const { setSurfaceShapePattern } = useRoofFn() const { changeSurfaceLineType } = useSurfaceShapeBatch({}) const { handleSelectableObjects } = useObject() + const { saveSnapshot, popLastSnapshot } = useUndoRedo() const verticalHorizontalMode = useRecoilValue(verticalHorizontalModeState) const adsorptionRange = useRecoilValue(adsorptionRangeState) @@ -92,13 +94,19 @@ export function usePlacementShapeDrawing(id) { }, [verticalHorizontalMode, points, adsorptionRange, adsorptionPointMode]) useEffect(() => { + // 배치면 그리기 시작 시 스냅샷 저장 (undo 하면 그리기 전 상태로 복원) + saveSnapshot() setPoints([]) setType(OUTER_LINE_TYPE.OUTER_LINE) return () => { const placementShapeDrawingStartPoint = canvas.getObjects().find((obj) => obj.name === 'placementShapeDrawingStartPoint') - canvas.remove(placementShapeDrawingStartPoint) + + // 확정하지 않고 닫은 경우 스냅샷 제거 + if (!isFix.current) { + popLastSnapshot() + } } }, []) diff --git a/src/hooks/surface/useRoofLinePropertySetting.js b/src/hooks/surface/useRoofLinePropertySetting.js index 6115f76e..febd84b2 100644 --- a/src/hooks/surface/useRoofLinePropertySetting.js +++ b/src/hooks/surface/useRoofLinePropertySetting.js @@ -6,6 +6,7 @@ import { usePopup } from '../usePopup' import useSWR from 'swr' import { useSwal } from '../useSwal' import { useMessage } from '../useMessage' +import { useUndoRedo } from '../useUndoRedo' const LINE_COLOR = { EAVES: '#45CD7D', @@ -23,6 +24,7 @@ export function useRoofLinePropertySetting(props) { const { closePopup } = usePopup() const { swalFire } = useSwal() const { getMessage } = useMessage() + const { saveSnapshot, popLastSnapshot } = useUndoRedo() useEffect(() => { if (currentObject && currentObject.name === 'cloneRoofLine') { @@ -47,10 +49,20 @@ export function useRoofLinePropertySetting(props) { } }, [currentObject]) + const isFixed = useRef(false) + useEffect(() => { + // 팝업 열릴 때 스냅샷 저장 + saveSnapshot() + return () => { canvas.remove(...canvas.getObjects().filter((obj) => obj.name === 'cloneRoofLine')) canvas.renderAll() + + // 설정 완료하지 않고 닫은 경우 스냅샷 제거 + if (!isFixed.current) { + popLastSnapshot() + } } }, []) @@ -149,8 +161,6 @@ export function useRoofLinePropertySetting(props) { } const handleFix = () => { - // const roof = canvas.getObjects().find((obj) => currentObject.parentId === obj.id) - // const notSettingLines = roof.lines.filter( const notSettingLines = canvas .getObjects() .filter((obj) => obj.name === 'cloneRoofLine') @@ -187,6 +197,7 @@ export function useRoofLinePropertySetting(props) { // }) // }) + isFixed.current = true canvas.renderAll() closePopup(id) if (setIsHidden) setIsHidden(false) diff --git a/src/hooks/surface/useSurfaceShapeBatch.js b/src/hooks/surface/useSurfaceShapeBatch.js index ac9d5fd1..a5fa5232 100644 --- a/src/hooks/surface/useSurfaceShapeBatch.js +++ b/src/hooks/surface/useSurfaceShapeBatch.js @@ -52,6 +52,7 @@ export function useSurfaceShapeBatch({ isHidden, setIsHidden }) { const { saveSnapshot } = useUndoRedo() const applySurfaceShape = (surfaceRefs, selectedType, id) => { + saveSnapshot() let length1, length2, length3, length4, length5 const surfaceId = selectedType?.id const azimuth = surfaceRefs.azimuth.current @@ -844,6 +845,7 @@ export function useSurfaceShapeBatch({ isHidden, setIsHidden }) { text: getMessage('batch.canvas.delete.all'), type: 'confirm', confirmFn: () => { + saveSnapshot() canvas.clear() delete canvas.outerLineFix diff --git a/src/hooks/useCanvas.js b/src/hooks/useCanvas.js index 9a6fb14b..3880174a 100644 --- a/src/hooks/useCanvas.js +++ b/src/hooks/useCanvas.js @@ -155,6 +155,7 @@ export function useCanvas(id) { // 해당 오브젝트 타입의 경우 저장한 값 그대로 불러와야함 OBJECT_PROTOTYPE.forEach((type) => { + const originalToObject = type.toObject type.toObject = function (propertiesToInclude) { let source = {} @@ -175,7 +176,9 @@ export function useCanvas(id) { } } - return fabric.util.object.extend(this.callSuper('toObject', propertiesToInclude), source) + // 원본 toObject 를 호출하여 Group 의 objects 등 고유 직렬화 로직을 유지 + const base = originalToObject ? originalToObject.call(this, propertiesToInclude) : this.callSuper('toObject', propertiesToInclude) + return fabric.util.object.extend(base, source) } }) diff --git a/src/hooks/useCanvasEvent.js b/src/hooks/useCanvasEvent.js index d15eb05d..351fb8cc 100644 --- a/src/hooks/useCanvasEvent.js +++ b/src/hooks/useCanvasEvent.js @@ -21,7 +21,7 @@ export function useCanvasEvent() { const currentMenu = useRecoilValue(currentMenuState) const { changeCorridorDimensionText } = useText() const { setPolygonLinesActualSize } = usePolygon() - const { undo, redo } = useUndoRedo() + const { undo, redo, saveSnapshot } = useUndoRedo() useEffect(() => { canvas?.setZoom(canvasZoom / 100) @@ -291,22 +291,18 @@ export function useCanvasEvent() { break case 'Down': // IE/Edge에서 사용되는 값 case 'ArrowDown': - // "아래 화살표" 키가 눌렸을 때의 동작입니다. moveDown() break case 'Up': // IE/Edge에서 사용되는 값 case 'ArrowUp': - // "위 화살표" 키가 눌렸을 때의 동작입니다. moveUp() break case 'Left': // IE/Edge에서 사용되는 값 case 'ArrowLeft': - // "왼쪽 화살표" 키가 눌렸을 때의 동작입니다. moveLeft() break case 'Right': // IE/Edge에서 사용되는 값 case 'ArrowRight': - // "오른쪽 화살표" 키가 눌렸을 때의 동작입니다. moveRight() break case 'Enter': diff --git a/src/hooks/useContextMenu.js b/src/hooks/useContextMenu.js index 25dd2a1a..74fd31ad 100644 --- a/src/hooks/useContextMenu.js +++ b/src/hooks/useContextMenu.js @@ -43,6 +43,7 @@ import { useTrestle } from './module/useTrestle' import { useCircuitTrestle } from './useCirCuitTrestle' import { usePolygon } from '@/hooks/usePolygon' import { useText } from '@/hooks/useText' +import { useUndoRedo } from '@/hooks/useUndoRedo' export function useContextMenu() { const canvas = useRecoilValue(canvasState) @@ -77,6 +78,7 @@ export function useContextMenu() { const { isExistCircuit } = useCircuitTrestle() const { changeCorridorDimensionText } = useText() const { setPolygonLinesActualSize, drawDirectionArrow } = usePolygon() + const { saveSnapshot } = useUndoRedo() const currentMenuSetting = () => { switch (selectedMenu) { case 'outline': @@ -170,7 +172,10 @@ export function useContextMenu() { { id: 'roofMaterialRemoveAll', name: getMessage('contextmenu.roof.material.remove.all'), - fn: () => removeAllRoofMaterial(), + fn: () => { + saveSnapshot() + removeAllRoofMaterial() + }, }, { id: 'selectMove', @@ -183,6 +188,7 @@ export function useContextMenu() { id: 'wallLineRemove', name: getMessage('contextmenu.wallline.remove'), fn: (currentMousePos) => { + saveSnapshot() removeOuterLines(currentMousePos) }, }, @@ -260,7 +266,10 @@ export function useContextMenu() { { id: 'roofMaterialRemoveAll', name: getMessage('contextmenu.roof.material.remove.all'), - fn: () => removeAllRoofMaterial(), + fn: () => { + saveSnapshot() + removeAllRoofMaterial() + }, }, { id: 'selectMove', @@ -368,6 +377,7 @@ export function useContextMenu() { name: `${getMessage('contextmenu.auxiliary.remove')}(D)`, fn: () => { if (!currentObject) return + saveSnapshot() const roof = canvas.getObjects().filter((obj) => obj.id === currentObject.attributes.roofId)[0] if (!roof) { // 아직 innerLines로 세팅이 안되어있는 line인 경우 제거 @@ -386,6 +396,7 @@ export function useContextMenu() { name: getMessage('contextmenu.auxiliary.vertical.bisector'), fn: () => { if (!currentObject) return + saveSnapshot() const slope = (currentObject.y2 - currentObject.y1) / (currentObject.x2 - currentObject.x1) const length = currentObject.length @@ -435,6 +446,7 @@ export function useContextMenu() { swalFire({ text: getMessage('roof.is.not.selected') }) return } + saveSnapshot() const innerLines = canvas.getObjects().filter((obj) => obj.id === currentObject.attributes.roofId)[0]?.innerLines if (innerLines) { innerLines.forEach((line) => { @@ -702,7 +714,10 @@ export function useContextMenu() { { id: 'remove', name: getMessage('contextmenu.remove'), - fn: () => modulesRemove(), + fn: () => { + saveSnapshot() + modulesRemove() + }, }, { id: 'move', @@ -900,6 +915,7 @@ export function useContextMenu() { id: 'moduleRemove', name: getMessage('contextmenu.module.remove'), fn: () => { + saveSnapshot() moduleRoofRemove(currentObject.arrayData ?? [currentObject]) // const moduleSetupSurface = canvas.getObjects().filter((obj) => canvas.getActiveObjects()[0].id === obj.id)[0] diff --git a/src/hooks/useUndoRedo.js b/src/hooks/useUndoRedo.js index 21bfc500..cd1ef27b 100644 --- a/src/hooks/useUndoRedo.js +++ b/src/hooks/useUndoRedo.js @@ -16,7 +16,7 @@ import { import { outerLinePointsState } from '@/store/outerLineAtom' import { selectedMenuState } from '@/store/menuAtom' import { pendingPopupState, popupState } from '@/store/popupAtom' -import { POLYGON_TYPE, SAVE_KEY } from '@/common/common' +import { MENU, POLYGON_TYPE, SAVE_KEY } from '@/common/common' const MAX_HISTORY = 3 @@ -182,6 +182,20 @@ export function useUndoRedo() { surface.modules = surfaceModules }) + // fabric.Group 내부의 QPolygon/QLine 자식에 canvas 참조 설정 + // loadFromJSON 후 Group 자식은 canvas 를 받지 못해 init/addLengthText 등이 실패할 수 있음 + objects + .filter((obj) => obj.type === 'group') + .forEach((group) => { + if (group._objects) { + group._objects.forEach((child) => { + if (!child.canvas) { + child.canvas = canvas + } + }) + } + }) + // 부모가 없는 고아 lengthText, arrow, flowText 제거 const parentIds = new Set(objects.filter((obj) => obj.id).map((obj) => obj.id)) const orphans = objects.filter( @@ -258,6 +272,26 @@ export function useUndoRedo() { const cells = roofs.filter((obj) => obj.cells && obj.cells.length > 0) setDrewRoofCells(cells) + + // loadFromJSON 후 QPolygon/QLine 의 lengthText 가 orphan 제거되었을 수 있으므로 재생성 + // (initLines 로 새 QLine 이 만들어지면 기존 lengthText 의 parentId 가 안 맞음) + const qPolygons = objects.filter((obj) => obj.type === 'QPolygon' && obj.name !== 'arrow' && obj.name !== POLYGON_TYPE.MODULE && obj.name !== POLYGON_TYPE.MODULE_SETUP_SURFACE && obj.name !== POLYGON_TYPE.OBJECT_SURFACE) + qPolygons.forEach((polygon) => { + try { + polygon.initLines() + polygon.addLengthText() + } catch (e) { /* skip */ } + }) + + canvas.getObjects() + .filter((obj) => obj.type === 'QLine') + .forEach((line) => { + try { + if (typeof line.addLengthText === 'function') { + line.addLengthText() + } + } catch (e) { /* skip */ } + }) }, [canvas, rebuildObjectGraph]) const restoreAtomSnapshot = useCallback((atomSnapshot) => { @@ -277,7 +311,6 @@ export function useUndoRedo() { if (!canvas) return null try { const rawJSON = canvas.toJSON(SAVE_KEY) - // 순환 참조 제거 후 다시 파싱하여 순수 데이터 객체로 변환 return JSON.parse(safeStringify(rawJSON)) } catch (e) { console.warn('Canvas snapshot failed:', e) @@ -285,9 +318,12 @@ export function useUndoRedo() { } }, [canvas]) - const saveSnapshot = useCallback((atomOverrides = null) => { + const saveSnapshot = useCallback((atomOverrides = null, { force = false } = {}) => { if (!canvas || _isRestoring) return + // 지붕형상 수동설정 메뉴에서는 스냅샷을 쌓지 않는다 (force 시 예외) + if (!force && currentMenu === MENU.ROOF_COVERING.ROOF_SHAPE_PASSIVITY_SETTINGS) return + // snapshot 전에 mouseLine 제거 const mouseLines = canvas.getObjects().filter((obj) => obj.name === 'mouseLine') mouseLines.forEach((obj) => canvas.remove(obj)) @@ -315,21 +351,28 @@ export function useUndoRedo() { return newStack }) setRedoStack([]) - }, [canvas, getCanvasSnapshot, getAtomSnapshot]) + }, [canvas, currentMenu, getCanvasSnapshot, getAtomSnapshot]) const closeAllPopups = useCallback(() => { - setPopup({ config: [], other: [] }) + setPopup((prev) => ({ + // 처마 케라바 변경 팝업(EavesGableEdit)은 undo/redo 시 닫지 않는다 + config: prev.config.filter((p) => p.component?.type?.name === 'EavesGableEdit'), + other: prev.other.filter((p) => p.component?.type?.name === 'EavesGableEdit'), + })) }, [setPopup]) const undo = useCallback(() => { + // 지붕형상 수동설정 중에는 undo 차단 + if (currentMenu === MENU.ROOF_COVERING.ROOF_SHAPE_PASSIVITY_SETTINGS) return + const currentUndoStack = undoStackRef.current - console.log('currentUndoStack', currentUndoStack) if (!canvas || currentUndoStack.length === 0 || _isRestoring) return _isRestoring = true const currentCanvasJSON = getCanvasSnapshot() const currentAtomSnapshot = getAtomSnapshot() + const currentSnapshot = { canvasJSON: currentCanvasJSON, atomSnapshot: currentAtomSnapshot, @@ -348,6 +391,9 @@ export function useUndoRedo() { }, [canvas, currentMenu, getCanvasSnapshot, getAtomSnapshot, restoreAtomSnapshot, rebuildCanvasAtoms, closeAllPopups]) const redo = useCallback(() => { + // 지붕형상 수동설정 중에는 redo 차단 + if (currentMenu === MENU.ROOF_COVERING.ROOF_SHAPE_PASSIVITY_SETTINGS) return + const currentRedoStack = redoStackRef.current if (!canvas || currentRedoStack.length === 0 || _isRestoring) return @@ -377,11 +423,17 @@ export function useUndoRedo() { setRedoStack([]) }, []) + /** undo 스택에서 마지막 스냅샷 1개만 제거 (취소 시 사용) */ + const popLastSnapshot = useCallback(() => { + setUndoStack((prev) => (prev.length > 0 ? prev.slice(0, -1) : prev)) + }, []) + return { saveSnapshot, undo, redo, clearStacks, + popLastSnapshot, canUndo: undoStack.length > 0, canRedo: redoStack.length > 0, } -- 2.47.2 From a5675d41d8774f9bcc19e692ac0b7beaed222d68 Mon Sep 17 00:00:00 2001 From: "hyojun.choi" Date: Mon, 20 Apr 2026 10:27:12 +0900 Subject: [PATCH 06/60] =?UTF-8?q?=EB=B0=B0=EC=B9=98=EB=A9=B4=20=EC=9D=B4?= =?UTF-8?q?=EB=8F=99=20=EC=8B=9C=20=EA=B8=B8=EC=9D=B4=20=EA=B3=84=EC=82=B0?= =?UTF-8?q?=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/components/fabric/QPolygon.js | 13 ++++++++++++- src/store/settingAtom.js | 11 ++++++++++- 2 files changed, 22 insertions(+), 2 deletions(-) diff --git a/src/components/fabric/QPolygon.js b/src/components/fabric/QPolygon.js index b89e9daf..189072db 100644 --- a/src/components/fabric/QPolygon.js +++ b/src/components/fabric/QPolygon.js @@ -543,8 +543,19 @@ export const QPolygon = fabric.util.createClass(fabric.Polygon, { const degree = Big(Math.atan2(dy.toNumber(), dx.toNumber())).times(180).div(Math.PI).toNumber() + // 이동/undo/redo 시 기하학 drift(round-off)로 text 수치가 변동되지 않도록 + // 저장된 attributes 우선 사용. 현재 corridorDimension.column에 맞춰 값 결정: + // - realDimension(배치면 메뉴): actualSize 우선 + // - corridorDimension(지붕덮개 메뉴): planeSize 우선 + const preservedActualSize = this.lines[i]?.attributes?.actualSize + const preservedPlaneSize = this.lines[i]?.attributes?.planeSize + const currentColumn = (typeof window !== 'undefined' && window.__currentCorridorColumn) || 'corridorDimension' + const displayValue = currentColumn === 'realDimension' + ? (preservedActualSize != null ? preservedActualSize : preservedPlaneSize != null ? preservedPlaneSize : length) + : (preservedPlaneSize != null ? preservedPlaneSize : preservedActualSize != null ? preservedActualSize : length) + // Create a new text object if it doesn't exist - const text = new fabric.Text(length.toString(), { + const text = new fabric.Text(displayValue.toString(), { left: midPoint.x, top: midPoint.y, fontSize: this.fontSize, diff --git a/src/store/settingAtom.js b/src/store/settingAtom.js index 32a620a1..decf6168 100644 --- a/src/store/settingAtom.js +++ b/src/store/settingAtom.js @@ -219,7 +219,12 @@ export const corridorDimensionSelector = selector({ key: 'corridorDimensionSelector', get: ({ get }) => { const settingModalFirstOptions = get(settingModalFirstOptionsState) - return settingModalFirstOptions.dimensionDisplay.find((option) => option.selected) + const selected = settingModalFirstOptions.dimensionDisplay.find((option) => option.selected) + // Fabric 클래스(QPolygon) 등 React hook 외부에서 현재 column 참조용 + if (typeof window !== 'undefined' && selected) { + window.__currentCorridorColumn = selected.column + } + return selected }, set: ({ set }, newValue) => { //0 : 복도치수 , 1 : 실제치수 @@ -229,6 +234,10 @@ export const corridorDimensionSelector = selector({ ...prev, dimensionDisplay: prev.dimensionDisplay.map((item, index) => { if (index === newValue) { + // Fabric 클래스(QPolygon) 등 React hook 외부에서 현재 column 참조용 + if (typeof window !== 'undefined') { + window.__currentCorridorColumn = item.column + } return { ...item, selected: true } } else { return { ...item, selected: false } -- 2.47.2 From cce18d47adcaca1a4674304fbf3276b5c3242a28 Mon Sep 17 00:00:00 2001 From: "hyojun.choi" Date: Fri, 24 Apr 2026 16:14:58 +0900 Subject: [PATCH 07/60] =?UTF-8?q?=EB=8F=84=EB=A8=B8=20=EC=84=A4=EC=B9=98?= =?UTF-8?q?=EB=90=9C=20=EC=A7=80=EB=B6=95=EB=A9=B4=20=EC=9D=B4=EB=8F=99?= =?UTF-8?q?=EC=8B=9C=20=EC=9D=B4=EC=83=81=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/components/fabric/QPolygon.js | 5 ++++ src/hooks/useUndoRedo.js | 48 +++++++++++++++++++++++++++++-- 2 files changed, 51 insertions(+), 2 deletions(-) diff --git a/src/components/fabric/QPolygon.js b/src/components/fabric/QPolygon.js index 189072db..353cb344 100644 --- a/src/components/fabric/QPolygon.js +++ b/src/components/fabric/QPolygon.js @@ -146,11 +146,13 @@ export const QPolygon = fabric.util.createClass(fabric.Polygon, { this.setCoords() // 보조선 / 자식 객체들도 같이 이동시킨다 (ROOF 가 아닌 WALL 등에서도 동작하도록) + // 단, fabric.Group(도머 등)은 절대 위치 유지해야 하므로 제외 if ((dxModified !== 0 || dyModified !== 0) && this.canvas) { const directChildren = this.canvas.getObjects().filter((obj) => { if (obj === this) return false if (obj.parentId !== this.id) return false if (obj.name === 'lengthText') return false + if (obj.type === 'group') return false // 도머 등 Group 은 이동 대상에서 제외 return true }) const childIds = new Set(directChildren.map((c) => c.id).filter(Boolean)) @@ -181,6 +183,7 @@ export const QPolygon = fabric.util.createClass(fabric.Polygon, { movedTextCount++ } }) + this.canvas.renderAll() // ROOF 의 경우 polygonMoved 가 같은 일을 다시 하므로, _preDragLeft 는 거기서 정리됨 @@ -277,6 +280,7 @@ export const QPolygon = fabric.util.createClass(fabric.Polygon, { if (obj === this) return false if (obj.parentId !== this.id) return false if (obj.name === 'lengthText') return false // 이미 재생성됨 + if (obj.type === 'group') return false // 도머 등 Group 은 절대 위치 유지 return true }) @@ -318,6 +322,7 @@ export const QPolygon = fabric.util.createClass(fabric.Polygon, { movedTextCount++ } }) + this.canvas.renderAll() } diff --git a/src/hooks/useUndoRedo.js b/src/hooks/useUndoRedo.js index cd1ef27b..35df6b2b 100644 --- a/src/hooks/useUndoRedo.js +++ b/src/hooks/useUndoRedo.js @@ -197,7 +197,29 @@ export function useUndoRedo() { }) // 부모가 없는 고아 lengthText, arrow, flowText 제거 - const parentIds = new Set(objects.filter((obj) => obj.id).map((obj) => obj.id)) + // fabric.Group 내부의 QPolygon/QLine 자식 id도 부모로 간주해야 + // 도머(Group) 내부 삼각형들의 lengthText가 사라지지 않음 + const parentIds = new Set() + objects.forEach((obj) => { + if (obj.id) parentIds.add(obj.id) + if (obj.type === 'group' && obj._objects) { + obj._objects.forEach((child) => { + if (child.id) parentIds.add(child.id) + // 그룹 내부 QPolygon의 lines(QLine) id도 포함 + if (child.lines && Array.isArray(child.lines)) { + child.lines.forEach((line) => { + if (line.id) parentIds.add(line.id) + }) + } + }) + } + // 일반 QPolygon의 lines id도 포함 (내부 보조선/치수선의 parentId) + if (obj.lines && Array.isArray(obj.lines)) { + obj.lines.forEach((line) => { + if (line.id) parentIds.add(line.id) + }) + } + }) const orphans = objects.filter( (obj) => (obj.name === 'lengthText' || obj.name === 'arrow' || obj.name === 'flowText') && obj.parentId && !parentIds.has(obj.parentId), ) @@ -275,7 +297,14 @@ export function useUndoRedo() { // loadFromJSON 후 QPolygon/QLine 의 lengthText 가 orphan 제거되었을 수 있으므로 재생성 // (initLines 로 새 QLine 이 만들어지면 기존 lengthText 의 parentId 가 안 맞음) - const qPolygons = objects.filter((obj) => obj.type === 'QPolygon' && obj.name !== 'arrow' && obj.name !== POLYGON_TYPE.MODULE && obj.name !== POLYGON_TYPE.MODULE_SETUP_SURFACE && obj.name !== POLYGON_TYPE.OBJECT_SURFACE) + const shouldRebuildText = (poly) => + poly.type === 'QPolygon' && + poly.name !== 'arrow' && + poly.name !== POLYGON_TYPE.MODULE && + poly.name !== POLYGON_TYPE.MODULE_SETUP_SURFACE && + poly.name !== POLYGON_TYPE.OBJECT_SURFACE + + const qPolygons = objects.filter(shouldRebuildText) qPolygons.forEach((polygon) => { try { polygon.initLines() @@ -283,6 +312,21 @@ export function useUndoRedo() { } catch (e) { /* skip */ } }) + // Group 내부 QPolygon(도머 등)의 lengthText 도 동일하게 재생성 + objects + .filter((obj) => obj.type === 'group' && obj._objects) + .forEach((group) => { + group._objects.forEach((child) => { + if (shouldRebuildText(child)) { + try { + if (!child.canvas) child.canvas = canvas + child.initLines?.() + child.addLengthText?.() + } catch (e) { /* skip */ } + } + }) + }) + canvas.getObjects() .filter((obj) => obj.type === 'QLine') .forEach((line) => { -- 2.47.2 From b49e3871d559dc006741bbdff6d0069c1b64a759 Mon Sep 17 00:00:00 2001 From: "hyojun.choi" Date: Tue, 28 Apr 2026 16:12:21 +0900 Subject: [PATCH 08/60] =?UTF-8?q?redo=20undo=20=EC=9E=91=EC=97=85=EC=A4=91?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/hooks/common/useMenu.js | 6 +++++- src/hooks/useUndoRedo.js | 7 +++++++ 2 files changed, 12 insertions(+), 1 deletion(-) diff --git a/src/hooks/common/useMenu.js b/src/hooks/common/useMenu.js index 8cdfeb1b..9ba54255 100644 --- a/src/hooks/common/useMenu.js +++ b/src/hooks/common/useMenu.js @@ -21,7 +21,7 @@ import { MENU } from '@/common/common' import { useTrestle } from '@/hooks/module/useTrestle' import { useOrientation } from '@/hooks/module/useOrientation' import { corridorDimensionSelector } from '@/store/settingAtom' -import { useUndoRedo } from '@/hooks/useUndoRedo' +import { useUndoRedo, isUndoRedoRestoring } from '@/hooks/useUndoRedo' /** * 메뉴 처리 훅 @@ -69,6 +69,8 @@ export default function useMenu() { addPopup(popupId, 1, ) break case MENU.ROOF_COVERING.ALL_REMOVE: + // undo/redo 복원 중에는 confirm 창을 띄우지 않는다 + if (isUndoRedoRestoring()) break deleteAllSurfacesAndObjects() break } @@ -91,6 +93,8 @@ export default function useMenu() { addPopup(popupId, 1, ) break case MENU.BATCH_CANVAS.ALL_REMOVE: + // undo/redo 복원 중에는 confirm 창을 띄우지 않는다 + if (isUndoRedoRestoring()) break deleteAllSurfacesAndObjects() break } diff --git a/src/hooks/useUndoRedo.js b/src/hooks/useUndoRedo.js index 35df6b2b..2047dc7e 100644 --- a/src/hooks/useUndoRedo.js +++ b/src/hooks/useUndoRedo.js @@ -25,6 +25,13 @@ const MAX_HISTORY = 3 let _isRestoring = false let _restoreTimer = null +/** + * 외부 훅/모듈에서 현재 undo/redo 복원 중인지 확인할 때 사용 + */ +export function isUndoRedoRestoring() { + return _isRestoring +} + // loadFromJSON 공통 처리 - 이벤트 일시 중단 후 복원 function _restoreFromJSON(canvas, snapshot, restoreAtomSnapshot, rebuildCanvasAtoms, setCanvasZoom) { // 진행 중인 복원 타이머가 있으면 취소 -- 2.47.2 From 80720fef2a3e39375966499913e9af9529d1e7a3 Mon Sep 17 00:00:00 2001 From: "hyojun.choi" Date: Thu, 7 May 2026 10:53:51 +0900 Subject: [PATCH 09/60] =?UTF-8?q?=EC=99=B8=EB=B2=BD=EC=84=A0,=20=EB=B0=B0?= =?UTF-8?q?=EC=B9=98=EB=A9=B4=20Redo=20undo=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/components/floor-plan/MenuDepth01.jsx | 5 ++ src/hooks/roofcover/useOuterLineWall.js | 15 +++++- src/hooks/surface/usePlacementShapeDrawing.js | 9 ++-- src/hooks/useUndoRedo.js | 54 +++++++++++++++++-- src/store/canvasAtom.js | 7 +++ 5 files changed, 80 insertions(+), 10 deletions(-) diff --git a/src/components/floor-plan/MenuDepth01.jsx b/src/components/floor-plan/MenuDepth01.jsx index d6bb2477..9e02df75 100644 --- a/src/components/floor-plan/MenuDepth01.jsx +++ b/src/components/floor-plan/MenuDepth01.jsx @@ -9,6 +9,7 @@ import { useRecoilState, useRecoilValue, useResetRecoilState } from 'recoil' import { subMenusState } from '@/store/menuAtom' import { useCanvasMenu } from '@/hooks/common/useCanvasMenu' import { commonUtilsState } from '@/store/commonUtilsAtom' +import { isUndoRedoRestoring } from '@/hooks/useUndoRedo' export default function MenuDepth01() { const canvas = useRecoilValue(canvasState) @@ -29,6 +30,10 @@ export default function MenuDepth01() { } useEffect(() => { + // undo/redo 복원 중에는 메뉴 변경에 따른 팝업 자동 오픈을 건너뛴다. + // 그렇지 않으면 그리기 팝업이 다시 열리며 setDrawing(true) 가 호출되어 + // redo 버튼이 비활성화되는 문제가 발생한다. + if (isUndoRedoRestoring()) return handleMenu(selectedMenu) canvas?.discardActiveObject() }, [currentMenu]) diff --git a/src/hooks/roofcover/useOuterLineWall.js b/src/hooks/roofcover/useOuterLineWall.js index 44f53475..9fcd56a2 100644 --- a/src/hooks/roofcover/useOuterLineWall.js +++ b/src/hooks/roofcover/useOuterLineWall.js @@ -63,7 +63,7 @@ export function useOuterLineWall(id, propertiesId) { const { tempGridMode } = useTempGrid() const { addPolygonByLines } = usePolygon() const { handleSelectableObjects } = useObject() - const { saveSnapshot } = useUndoRedo() + const { saveSnapshot, setDrawing } = useUndoRedo() const verticalHorizontalMode = useRecoilValue(verticalHorizontalModeState) const adsorptionPointAddMode = useRecoilValue(adsorptionPointAddModeState) @@ -121,6 +121,15 @@ export function useOuterLineWall(id, propertiesId) { } }, [verticalHorizontalMode, points, adsorptionPointAddMode, adsorptionPointMode, adsorptionRange, interval, tempGridMode]) + // 외벽선 그리기 진행 중에는 undo/redo/스냅샷을 차단한다. + // 그리기 전 스냅샷은 첫 클릭(mouseDown) 에서 force 로 저장한다 (기존 동작 유지). + useEffect(() => { + setDrawing(true) + return () => { + setDrawing(false) + } + }, []) + useEffect(() => { arrow1Ref.current = arrow1 }, [arrow1]) @@ -158,7 +167,9 @@ export function useOuterLineWall(id, propertiesId) { pointer = { x: Big(pointer.x).toNumber(), y: Big(pointer.y).toNumber() } if (points.length === 0) { - saveSnapshot() + // 그리기 진행 중에는 스냅샷이 차단되므로 force 로 저장한다. + // (처음 한 번만 저장되고 이후 추가 클릭은 points.length > 0 분기로 빠지므로 중복 저장 안 됨) + saveSnapshot(null, { force: true }) setPoints((prev) => [...prev, pointer]) } else { const lastPoint = points[points.length - 1] diff --git a/src/hooks/surface/usePlacementShapeDrawing.js b/src/hooks/surface/usePlacementShapeDrawing.js index 67ff35f9..d23def24 100644 --- a/src/hooks/surface/usePlacementShapeDrawing.js +++ b/src/hooks/surface/usePlacementShapeDrawing.js @@ -51,7 +51,7 @@ export function usePlacementShapeDrawing(id) { const { setSurfaceShapePattern } = useRoofFn() const { changeSurfaceLineType } = useSurfaceShapeBatch({}) const { handleSelectableObjects } = useObject() - const { saveSnapshot, popLastSnapshot } = useUndoRedo() + const { saveSnapshot, popLastSnapshot, setDrawing } = useUndoRedo() const { swalFire } = useSwal() const { getMessage } = useMessage() @@ -98,12 +98,15 @@ export function usePlacementShapeDrawing(id) { }, [verticalHorizontalMode, points, adsorptionRange, adsorptionPointMode]) useEffect(() => { - // 배치면 그리기 시작 시 스냅샷 저장 (undo 하면 그리기 전 상태로 복원) - saveSnapshot() + // 배치면 그리기 시작 시 스냅샷 저장 (undo 하면 그리기 전 상태로 복원). + // 그리기 진행 중에는 undo/redo/snapshot 을 차단하므로 force 로 저장한다. + setDrawing(true) + saveSnapshot(null, { force: true }) setPoints([]) setType(OUTER_LINE_TYPE.OUTER_LINE) return () => { + setDrawing(false) const placementShapeDrawingStartPoint = canvas.getObjects().find((obj) => obj.name === 'placementShapeDrawingStartPoint') canvas.remove(placementShapeDrawingStartPoint) diff --git a/src/hooks/useUndoRedo.js b/src/hooks/useUndoRedo.js index 2047dc7e..1567985b 100644 --- a/src/hooks/useUndoRedo.js +++ b/src/hooks/useUndoRedo.js @@ -11,6 +11,7 @@ import { moduleSetupSurfaceState, redoStackState, roofPolygonArrayState, + undoRedoDrawingState, undoStackState, } from '@/store/canvasAtom' import { outerLinePointsState } from '@/store/outerLineAtom' @@ -25,6 +26,10 @@ const MAX_HISTORY = 3 let _isRestoring = false let _restoreTimer = null +// 외벽선/배치면 등 그리기 진행 중에는 undo/redo/snapshot 을 차단한다. +// (확정 시점부터 history 가 동작해야 하므로) +let _isDrawing = false + /** * 외부 훅/모듈에서 현재 undo/redo 복원 중인지 확인할 때 사용 */ @@ -32,6 +37,21 @@ export function isUndoRedoRestoring() { return _isRestoring } +/** + * 외벽선/배치면 등 그리기 진행 상태를 외부에서 설정한다. + * 그리기가 시작될 때 true, 확정 또는 취소 시 false 로 설정한다. + */ +export function setUndoRedoDrawing(value) { + _isDrawing = !!value +} + +/** + * 외부 훅/모듈에서 현재 그리기 진행 중인지 확인할 때 사용 + */ +export function isUndoRedoDrawing() { + return _isDrawing +} + // loadFromJSON 공통 처리 - 이벤트 일시 중단 후 복원 function _restoreFromJSON(canvas, snapshot, restoreAtomSnapshot, rebuildCanvasAtoms, setCanvasZoom) { // 진행 중인 복원 타이머가 있으면 취소 @@ -98,6 +118,7 @@ export function useUndoRedo() { const canvas = useRecoilValue(canvasState) const [undoStack, setUndoStack] = useRecoilState(undoStackState) const [redoStack, setRedoStack] = useRecoilState(redoStackState) + const [isDrawing, setIsDrawing] = useRecoilState(undoRedoDrawingState) const [, setModuleSetupSurface] = useRecoilState(moduleSetupSurfaceState) const [moduleIsSetup, setModuleIsSetup] = useRecoilState(moduleIsSetupState) @@ -375,6 +396,9 @@ export function useUndoRedo() { // 지붕형상 수동설정 메뉴에서는 스냅샷을 쌓지 않는다 (force 시 예외) if (!force && currentMenu === MENU.ROOF_COVERING.ROOF_SHAPE_PASSIVITY_SETTINGS) return + // 외벽선/배치면 등 그리기 진행 중에는 스냅샷을 쌓지 않는다 (force 시 예외) + if (!force && (_isDrawing || isDrawing)) return + // snapshot 전에 mouseLine 제거 const mouseLines = canvas.getObjects().filter((obj) => obj.name === 'mouseLine') mouseLines.forEach((obj) => canvas.remove(obj)) @@ -402,7 +426,7 @@ export function useUndoRedo() { return newStack }) setRedoStack([]) - }, [canvas, currentMenu, getCanvasSnapshot, getAtomSnapshot]) + }, [canvas, currentMenu, isDrawing, getCanvasSnapshot, getAtomSnapshot]) const closeAllPopups = useCallback(() => { setPopup((prev) => ({ @@ -416,6 +440,9 @@ export function useUndoRedo() { // 지붕형상 수동설정 중에는 undo 차단 if (currentMenu === MENU.ROOF_COVERING.ROOF_SHAPE_PASSIVITY_SETTINGS) return + // 외벽선/배치면 등 그리기 진행 중에는 undo 차단 + if (_isDrawing || isDrawing) return + const currentUndoStack = undoStackRef.current if (!canvas || currentUndoStack.length === 0 || _isRestoring) return @@ -439,12 +466,15 @@ export function useUndoRedo() { setRedoStack((prev) => [...prev, currentSnapshot]) _restoreFromJSON(canvas, snapshot, restoreAtomSnapshot, rebuildCanvasAtoms, setCanvasZoom) - }, [canvas, currentMenu, getCanvasSnapshot, getAtomSnapshot, restoreAtomSnapshot, rebuildCanvasAtoms, closeAllPopups]) + }, [canvas, currentMenu, isDrawing, getCanvasSnapshot, getAtomSnapshot, restoreAtomSnapshot, rebuildCanvasAtoms, closeAllPopups]) const redo = useCallback(() => { // 지붕형상 수동설정 중에는 redo 차단 if (currentMenu === MENU.ROOF_COVERING.ROOF_SHAPE_PASSIVITY_SETTINGS) return + // 외벽선/배치면 등 그리기 진행 중에는 redo 차단 + if (_isDrawing || isDrawing) return + const currentRedoStack = redoStackRef.current if (!canvas || currentRedoStack.length === 0 || _isRestoring) return @@ -467,7 +497,7 @@ export function useUndoRedo() { setUndoStack((prev) => [...prev, currentSnapshot]) _restoreFromJSON(canvas, snapshot, restoreAtomSnapshot, rebuildCanvasAtoms, setCanvasZoom) - }, [canvas, currentMenu, getCanvasSnapshot, getAtomSnapshot, restoreAtomSnapshot, rebuildCanvasAtoms, closeAllPopups]) + }, [canvas, currentMenu, isDrawing, getCanvasSnapshot, getAtomSnapshot, restoreAtomSnapshot, rebuildCanvasAtoms, closeAllPopups]) const clearStacks = useCallback(() => { setUndoStack([]) @@ -479,13 +509,27 @@ export function useUndoRedo() { setUndoStack((prev) => (prev.length > 0 ? prev.slice(0, -1) : prev)) }, []) + /** + * 외벽선/배치면 등 그리기 진행 중에는 undo/redo 를 차단한다. + * 마운트 시 true, 언마운트 시 false 로 설정한다. + */ + const setDrawing = useCallback( + (value) => { + const v = !!value + setIsDrawing(v) + setUndoRedoDrawing(v) + }, + [setIsDrawing], + ) + return { saveSnapshot, undo, redo, clearStacks, popLastSnapshot, - canUndo: undoStack.length > 0, - canRedo: redoStack.length > 0, + setDrawing, + canUndo: undoStack.length > 0 && !isDrawing, + canRedo: redoStack.length > 0 && !isDrawing, } } diff --git a/src/store/canvasAtom.js b/src/store/canvasAtom.js index c87db0d1..2949de7e 100644 --- a/src/store/canvasAtom.js +++ b/src/store/canvasAtom.js @@ -421,3 +421,10 @@ export const redoStackState = atom({ default: [], dangerouslyAllowMutability: true, }) + +// 외벽선/배치면 등 그리기 진행 중 여부. +// true 동안에는 undo/redo 가 비활성화되고 새 스냅샷이 쌓이지 않는다. +export const undoRedoDrawingState = atom({ + key: 'undoRedoDrawingState', + default: false, +}) -- 2.47.2 From edf521e617bcd81386b78b411096a4dc14b14abf Mon Sep 17 00:00:00 2001 From: "hyojun.choi" Date: Thu, 7 May 2026 14:18:34 +0900 Subject: [PATCH 10/60] =?UTF-8?q?plan=20=EC=9D=B4=EB=8F=99=20=EC=8B=9C=20u?= =?UTF-8?q?ndo=20redo=20stack=20=EC=A0=9C=EA=B1=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/hooks/usePlan.js | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/src/hooks/usePlan.js b/src/hooks/usePlan.js index 150be261..77f5b9b7 100644 --- a/src/hooks/usePlan.js +++ b/src/hooks/usePlan.js @@ -31,6 +31,7 @@ import { useCanvasMenu } from './common/useCanvasMenu' import { QcastContext } from '@/app/QcastProvider' import { unescapeString } from '@/util/common-utils' import { useTrestle } from '@/hooks/module/useTrestle' +import { useUndoRedo } from '@/hooks/useUndoRedo' // putCanvasStatus 의 in-flight 요청 — module-scope 로 두어 usePlan() 인스턴스 간 공유. // 큰 fabric JSON 의 동시 PUT 이 SQL Server 데드락 victim 을 양산해 온 이력 때문에 직전 호출은 abort. @@ -84,6 +85,8 @@ export function usePlan(params = {}) { //선택된 모듈 배치면 초기화 const resetModuleSetupSurface = useResetRecoilState(moduleSetupSurfaceState) const { isAllComplete } = useTrestle() + // undo/redo 스택 초기화 (플랜 전환 시 사용) + const { clearStacks } = useUndoRedo() /** * 마우스 포인터의 가이드라인 제거 @@ -477,6 +480,8 @@ export function usePlan(params = {}) { handleCurrentPlanUrl() // resetCurrentObject() resetModuleSetupSurface() + // 플랜 전환 시 이전 플랜의 undo/redo 히스토리는 의미가 없으므로 초기화한다. + clearStacks() }, [currentCanvasPlan]) /** -- 2.47.2 From dd39e6f07bfe9ca1495553c30445bc4debac0c6d Mon Sep 17 00:00:00 2001 From: "hyojun.choi" Date: Thu, 7 May 2026 14:40:20 +0900 Subject: [PATCH 11/60] =?UTF-8?q?707=20=EC=83=9D=EA=B8=B0=EB=8A=94=20?= =?UTF-8?q?=ED=98=84=EC=83=81=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/hooks/useUndoRedo.js | 21 ++++++--------------- 1 file changed, 6 insertions(+), 15 deletions(-) diff --git a/src/hooks/useUndoRedo.js b/src/hooks/useUndoRedo.js index 1567985b..777b767e 100644 --- a/src/hooks/useUndoRedo.js +++ b/src/hooks/useUndoRedo.js @@ -323,8 +323,11 @@ export function useUndoRedo() { const cells = roofs.filter((obj) => obj.cells && obj.cells.length > 0) setDrewRoofCells(cells) - // loadFromJSON 후 QPolygon/QLine 의 lengthText 가 orphan 제거되었을 수 있으므로 재생성 - // (initLines 로 새 QLine 이 만들어지면 기존 lengthText 의 parentId 가 안 맞음) + // loadFromJSON 후 QPolygon 의 내부 lines 배열은 plain object 로만 복원되므로 + // initLines() 로 실제 QLine 인스턴스를 재구축한다. + // (lengthText 는 JSON 에서 이미 복원되므로 재생성하지 않는다. + // 과거에는 addLengthText 도 함께 호출했으나, undo/redo 반복 시 숨겨진 모서리 라인의 + // 텍스트가 누적 생성되어 "707" 같은 잔존 텍스트가 화면에 나타나는 문제가 있었음) const shouldRebuildText = (poly) => poly.type === 'QPolygon' && poly.name !== 'arrow' && @@ -336,11 +339,10 @@ export function useUndoRedo() { qPolygons.forEach((polygon) => { try { polygon.initLines() - polygon.addLengthText() } catch (e) { /* skip */ } }) - // Group 내부 QPolygon(도머 등)의 lengthText 도 동일하게 재생성 + // Group 내부 QPolygon(도머 등)도 동일하게 내부 라인 인스턴스만 재구축한다. objects .filter((obj) => obj.type === 'group' && obj._objects) .forEach((group) => { @@ -349,21 +351,10 @@ export function useUndoRedo() { try { if (!child.canvas) child.canvas = canvas child.initLines?.() - child.addLengthText?.() } catch (e) { /* skip */ } } }) }) - - canvas.getObjects() - .filter((obj) => obj.type === 'QLine') - .forEach((line) => { - try { - if (typeof line.addLengthText === 'function') { - line.addLengthText() - } - } catch (e) { /* skip */ } - }) }, [canvas, rebuildObjectGraph]) const restoreAtomSnapshot = useCallback((atomSnapshot) => { -- 2.47.2 From 9de3424cdf8548ddb4a5f32d4ae163309e21249a Mon Sep 17 00:00:00 2001 From: "hyojun.choi" Date: Thu, 7 May 2026 15:21:31 +0900 Subject: [PATCH 12/60] =?UTF-8?q?undo=20redo=20=EC=8B=9C=20=EC=A7=80?= =?UTF-8?q?=EB=B6=95=EB=A9=B4=20=ED=95=A0=EB=8B=B9=20=EC=95=88=EB=90=98?= =?UTF-8?q?=EB=8A=94=20=ED=98=84=EC=83=81=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/common/common.js | 5 +++++ src/hooks/useUndoRedo.js | 2 +- 2 files changed, 6 insertions(+), 1 deletion(-) diff --git a/src/common/common.js b/src/common/common.js index 2e4bb98c..df8be1a2 100644 --- a/src/common/common.js +++ b/src/common/common.js @@ -227,6 +227,11 @@ export const SAVE_KEY = [ 'outerLineFix', 'adjustRoofLines', 'northModuleYn', + // SK 빌드 시 hip 이 ext 끝점까지 연장된 상태임을 표시. + // undo/redo 시 보존되어야 integrateExtensionLines 가 중복 merge 를 스킵할 수 있다. + // (없으면 undo 후 지붕면 할당 시 hip 이 다시 merge 되어 corner 가 inner mesh 와 분리되며 + // path-finding 이 실패 → 4 면 분할 안 됨) + '__extended', ] export const OBJECT_PROTOTYPE = [fabric.Line.prototype, fabric.Polygon.prototype, fabric.Triangle.prototype, fabric.Group.prototype] diff --git a/src/hooks/useUndoRedo.js b/src/hooks/useUndoRedo.js index 777b767e..cd5ef77b 100644 --- a/src/hooks/useUndoRedo.js +++ b/src/hooks/useUndoRedo.js @@ -309,7 +309,7 @@ export function useUndoRedo() { const rebuildCanvasAtoms = useCallback(() => { if (!canvas) return - // 먼저 오브젝트 간 참조 관계 재구축 + // 먼저 오브젝트 간 참조 관계 재구축 (roof.innerLines 등) rebuildObjectGraph() const objects = canvas.getObjects() -- 2.47.2 From 77de97eef10e78b6cb1836016016c935dbab533c Mon Sep 17 00:00:00 2001 From: "hyojun.choi" Date: Thu, 7 May 2026 15:53:08 +0900 Subject: [PATCH 13/60] =?UTF-8?q?=EC=A7=80=EB=B6=95=ED=98=95=EC=83=81=20?= =?UTF-8?q?=EC=84=A4=EC=A0=95=20=EC=8B=9C=20undo=20redo=20=EB=90=98?= =?UTF-8?q?=EB=8F=84=EB=A1=9D=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/hooks/usePlan.js | 12 +++++++++--- 1 file changed, 9 insertions(+), 3 deletions(-) diff --git a/src/hooks/usePlan.js b/src/hooks/usePlan.js index 77f5b9b7..00c480c8 100644 --- a/src/hooks/usePlan.js +++ b/src/hooks/usePlan.js @@ -1,6 +1,6 @@ 'use client' -import { useContext, useEffect, useState } from 'react' +import { useContext, useEffect, useRef, useState } from 'react' import { usePathname, useRouter } from 'next/navigation' import { useRecoilState, useResetRecoilState, useSetRecoilState } from 'recoil' @@ -475,13 +475,19 @@ export function usePlan(params = {}) { } } + // usePlan() 인스턴스가 새로 mount 될 때 clearStacks 가 실수로 호출되지 않도록 + // 직전 플랜 id 를 ref 로 보관하고, 실제 id 변경이 일어났을 때만 스택을 비운다. + const prevPlanIdRef = useRef(currentCanvasPlan?.id) useEffect(() => { setSelectedPlan(currentCanvasPlan) handleCurrentPlanUrl() // resetCurrentObject() resetModuleSetupSurface() - // 플랜 전환 시 이전 플랜의 undo/redo 히스토리는 의미가 없으므로 초기화한다. - clearStacks() + // 플랜 ID가 실제로 바뀐 경우에만 undo/redo 히스토리를 초기화한다. + if (prevPlanIdRef.current !== currentCanvasPlan?.id) { + clearStacks() + prevPlanIdRef.current = currentCanvasPlan?.id + } }, [currentCanvasPlan]) /** -- 2.47.2 From 0e9cf938e9fd3a90715e75c4e12388e45028b943 Mon Sep 17 00:00:00 2001 From: "hyojun.choi" Date: Mon, 11 May 2026 09:59:49 +0900 Subject: [PATCH 14/60] =?UTF-8?q?=EC=A7=80=EB=B6=95=ED=98=95=EC=83=81=20?= =?UTF-8?q?=EC=88=98=EB=8F=99=EC=84=A4=EC=A0=95=20undo=20redo=20=EC=95=88?= =?UTF-8?q?=EB=90=98=EB=8A=94=20=ED=98=84=EC=83=81=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/components/fabric/QPolygon.js | 50 +++++++++++++++++++ .../roofcover/useRoofShapePassivitySetting.js | 8 ++- src/hooks/useUndoRedo.js | 15 ++---- 3 files changed, 60 insertions(+), 13 deletions(-) diff --git a/src/components/fabric/QPolygon.js b/src/components/fabric/QPolygon.js index 57d859a4..d15242e7 100644 --- a/src/components/fabric/QPolygon.js +++ b/src/components/fabric/QPolygon.js @@ -265,6 +265,56 @@ export const QPolygon = fabric.util.createClass(fabric.Polygon, { } }) + // [WALL→ROOF 동기 이동] + // 지붕형상 설정 후 wall 과 roof 는 별개의 폴리곤이고, hip/ridge/baseLine 같은 보조선은 + // 모두 parentId=roof.id 로 묶여 있어 wall 의 directChildren 에 포함되지 않는다. + // wall 만 단독 이동시키면 roof+보조선이 옛 위치에 남아 시각적으로 어긋나므로 + // wall 이동 시 연결된 roof 를 찾아 polygonMoved 로 같은 delta 만큼 이동시킨다. + // (polygonMoved 가 자체적으로 직속 자식 평행이동까지 처리하므로 별도 순회 불필요) + if (this.name === POLYGON_TYPE.WALL) { + const linkedRoof = this.canvas.getObjects().find( + (obj) => obj.name === POLYGON_TYPE.ROOF && obj.wall === this, + ) + if (linkedRoof) { + linkedRoof._preDragLeft = linkedRoof.left + linkedRoof._preDragTop = linkedRoof.top + linkedRoof.set({ + left: (linkedRoof.left ?? 0) + dxModified, + top: (linkedRoof.top ?? 0) + dyModified, + }) + linkedRoof.fire('polygonMoved') + // polygonMoved 가 ROOF 분기에서 _preDragLeft 를 정리하지 않으므로 여기서 정리 + linkedRoof._preDragLeft = null + linkedRoof._preDragTop = null + + // roof 의 attributes.roofId 매칭 객체(예: 일부 baseLine, 텍스트류)는 + // roof.id 와 다른 식별자로 묶여 polygonMoved 의 parentId === roof.id 필터에 + // 잡히지 않을 수 있다. 누락 방지를 위해 추가 평행이동. + const extraKids = this.canvas.getObjects().filter((obj) => { + if (obj === linkedRoof) return false + if (obj.parentId === linkedRoof.id) return false // 이미 polygonMoved 가 처리 + return obj.attributes?.roofId === linkedRoof.id + }) + extraKids.forEach((obj) => { + const next = {} + if (obj.left != null) next.left = obj.left + dxModified + if (obj.top != null) next.top = obj.top + dyModified + if (obj.x1 != null) next.x1 = obj.x1 + dxModified + if (obj.y1 != null) next.y1 = obj.y1 + dyModified + if (obj.x2 != null) next.x2 = obj.x2 + dxModified + if (obj.y2 != null) next.y2 = obj.y2 + dyModified + obj.set(next) + if (obj.startPoint) obj.startPoint = { x: obj.startPoint.x + dxModified, y: obj.startPoint.y + dyModified } + if (obj.endPoint) obj.endPoint = { x: obj.endPoint.x + dxModified, y: obj.endPoint.y + dyModified } + if (obj.minX != null) obj.minX += dxModified + if (obj.maxX != null) obj.maxX += dxModified + if (obj.minY != null) obj.minY += dyModified + if (obj.maxY != null) obj.maxY += dyModified + obj.setCoords?.() + }) + } + } + this.canvas.renderAll() // ROOF 의 경우 polygonMoved 가 같은 일을 다시 하므로, _preDragLeft 는 거기서 정리됨 diff --git a/src/hooks/roofcover/useRoofShapePassivitySetting.js b/src/hooks/roofcover/useRoofShapePassivitySetting.js index 20a31c46..17dd164a 100644 --- a/src/hooks/roofcover/useRoofShapePassivitySetting.js +++ b/src/hooks/roofcover/useRoofShapePassivitySetting.js @@ -42,7 +42,7 @@ export function useRoofShapePassivitySetting(id) { const [isLoading, setIsLoading] = useState(false) const { closePopup } = usePopup() - const { saveSnapshot } = useUndoRedo() + const { saveSnapshot, setDrawing } = useUndoRedo() const buttons = [ { id: 1, name: getMessage('eaves'), type: TYPES.EAVES }, { id: 2, name: getMessage('gable'), type: TYPES.GABLE }, @@ -56,7 +56,13 @@ export function useRoofShapePassivitySetting(id) { closePopup(id) //return } + // 수동설정 popup 진행 중에는 undo/redo/snapshot 차단 + setDrawing(true) setIsLoading(true) + return () => { + // popup 닫힐 때 (handleSave 후 closePopup, 또는 사용자가 그냥 닫기) 잠금 해제 + setDrawing(false) + } }, []) useEffect(() => { diff --git a/src/hooks/useUndoRedo.js b/src/hooks/useUndoRedo.js index cd5ef77b..6ad030fe 100644 --- a/src/hooks/useUndoRedo.js +++ b/src/hooks/useUndoRedo.js @@ -384,10 +384,7 @@ export function useUndoRedo() { const saveSnapshot = useCallback((atomOverrides = null, { force = false } = {}) => { if (!canvas || _isRestoring) return - // 지붕형상 수동설정 메뉴에서는 스냅샷을 쌓지 않는다 (force 시 예외) - if (!force && currentMenu === MENU.ROOF_COVERING.ROOF_SHAPE_PASSIVITY_SETTINGS) return - - // 외벽선/배치면 등 그리기 진행 중에는 스냅샷을 쌓지 않는다 (force 시 예외) + // 외벽선/배치면/지붕형상 수동설정 등 그리기 진행 중에는 스냅샷을 쌓지 않는다 (force 시 예외) if (!force && (_isDrawing || isDrawing)) return // snapshot 전에 mouseLine 제거 @@ -428,10 +425,7 @@ export function useUndoRedo() { }, [setPopup]) const undo = useCallback(() => { - // 지붕형상 수동설정 중에는 undo 차단 - if (currentMenu === MENU.ROOF_COVERING.ROOF_SHAPE_PASSIVITY_SETTINGS) return - - // 외벽선/배치면 등 그리기 진행 중에는 undo 차단 + // 외벽선/배치면/지붕형상 수동설정 등 그리기 진행 중에는 undo 차단 if (_isDrawing || isDrawing) return const currentUndoStack = undoStackRef.current @@ -460,10 +454,7 @@ export function useUndoRedo() { }, [canvas, currentMenu, isDrawing, getCanvasSnapshot, getAtomSnapshot, restoreAtomSnapshot, rebuildCanvasAtoms, closeAllPopups]) const redo = useCallback(() => { - // 지붕형상 수동설정 중에는 redo 차단 - if (currentMenu === MENU.ROOF_COVERING.ROOF_SHAPE_PASSIVITY_SETTINGS) return - - // 외벽선/배치면 등 그리기 진행 중에는 redo 차단 + // 외벽선/배치면/지붕형상 수동설정 등 그리기 진행 중에는 redo 차단 if (_isDrawing || isDrawing) return const currentRedoStack = redoStackRef.current -- 2.47.2 From de5b9f1fb72d6c4c092001712e4361043190b171 Mon Sep 17 00:00:00 2001 From: "hyojun.choi" Date: Wed, 20 May 2026 15:10:48 +0900 Subject: [PATCH 15/60] =?UTF-8?q?=EB=8F=84=EB=A8=B8=20undo=20redo=20?= =?UTF-8?q?=ED=9B=84=20=EC=A0=9C=EA=B1=B0=20=ED=95=98=EB=A9=B4=20lengthTex?= =?UTF-8?q?t=20=EB=82=A8=EC=95=84=20=EC=9E=88=EB=8A=94=20=ED=98=84?= =?UTF-8?q?=EC=83=81=20=EC=88=98=EC=A0=95,=20=EB=8F=84=EB=A8=B8=20?= =?UTF-8?q?=EC=9D=B4=EB=8F=99=20=EC=8B=9C=20=EB=91=90=EB=B2=88=20=EA=B3=84?= =?UTF-8?q?=EC=82=B0=EB=90=98=EB=8A=94=20=ED=98=84=EC=83=81=20=EC=88=98?= =?UTF-8?q?=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/hooks/object/useObjectBatch.js | 16 +++++----------- src/hooks/useUndoRedo.js | 19 +++++++++++++++++++ 2 files changed, 24 insertions(+), 11 deletions(-) diff --git a/src/hooks/object/useObjectBatch.js b/src/hooks/object/useObjectBatch.js index ddcb725d..5b204406 100644 --- a/src/hooks/object/useObjectBatch.js +++ b/src/hooks/object/useObjectBatch.js @@ -1436,8 +1436,6 @@ export function useObjectBatch({ isHidden, setIsHidden }) { const originObj = { ...obj } const turfSurface = pointsToTurfPolygon(parentSurface.getCurrentPoints()) - const originLeft = obj.left - const originTop = obj.top addCanvasMouseEventListener('mouse:up', (e) => { //개구, 그림자 타입일 경우 폴리곤 타입 변경 @@ -1482,15 +1480,11 @@ export function useObjectBatch({ isHidden, setIsHidden }) { }) if (obj.name === BATCH_TYPE.TRIANGLE_DORMER || obj.name === BATCH_TYPE.PENTAGON_DORMER) { - const calcLeft = obj.left - originLeft - const calcTop = obj.top - originTop - - const currentDormerPoints = obj.getCurrentPoints().map((item) => { - return { - x: item.x + calcLeft, - y: item.y + calcTop, - } - }) + // getCurrentPoints() 는 calcTransformMatrix() 기반으로 이미 이동된 월드 좌표를 반환한다. + // 과거 코드에서 (obj.left - originLeft) delta 를 다시 더해 2배 이동된 좌표로 검사 → + // 빠르게 이동 시 도머가 지붕 내부에 있어도 within=false 가 되어 alert 오발생. + obj.setCoords() + const currentDormerPoints = obj.getCurrentPoints() const turfObject = pointsToTurfPolygon(currentDormerPoints) diff --git a/src/hooks/useUndoRedo.js b/src/hooks/useUndoRedo.js index 6ad030fe..e0f8bebc 100644 --- a/src/hooks/useUndoRedo.js +++ b/src/hooks/useUndoRedo.js @@ -335,10 +335,26 @@ export function useUndoRedo() { poly.name !== POLYGON_TYPE.MODULE_SETUP_SURFACE && poly.name !== POLYGON_TYPE.OBJECT_SURFACE + // lengthText 인스턴스를 parentId 기준으로 한 번만 모아둔다 (texts 배열 복원에 재사용) + const lengthTextsByParent = new Map() + objects.forEach((obj) => { + if (obj.name === 'lengthText' && obj.parentId) { + const arr = lengthTextsByParent.get(obj.parentId) || [] + arr.push(obj) + lengthTextsByParent.set(obj.parentId, arr) + } + }) + const qPolygons = objects.filter(shouldRebuildText) qPolygons.forEach((polygon) => { try { polygon.initLines() + // texts 배열 참조 복원 (재생성이 아닌 기존 lengthText 인스턴스 연결만) + // commonDeleteText 가 group._objects[i].texts / object.texts 를 통해 제거하므로, + // loadFromJSON 후 끊긴 참조를 다시 이어주지 않으면 삭제 시 lengthText 가 잔존한다. + if (polygon.id) { + polygon.texts = lengthTextsByParent.get(polygon.id) || [] + } } catch (e) { /* skip */ } }) @@ -351,6 +367,9 @@ export function useUndoRedo() { try { if (!child.canvas) child.canvas = canvas child.initLines?.() + if (child.id) { + child.texts = lengthTextsByParent.get(child.id) || [] + } } catch (e) { /* skip */ } } }) -- 2.47.2 From 37a1c24667bf39f57d6dc26f85690dec745fff48 Mon Sep 17 00:00:00 2001 From: "hyojun.choi" Date: Wed, 20 May 2026 15:30:52 +0900 Subject: [PATCH 16/60] =?UTF-8?q?=EB=AA=A8=EB=93=88/=EA=B0=80=EB=8C=80=20?= =?UTF-8?q?=EC=84=A4=EC=A0=95=20undo,=20redo=20=EC=9D=B4=EC=83=81=20?= =?UTF-8?q?=EB=8F=99=EC=9E=91=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/hooks/module/useModuleBasicSetting.js | 5 ++++- src/hooks/useUndoRedo.js | 9 +++++++++ 2 files changed, 13 insertions(+), 1 deletion(-) diff --git a/src/hooks/module/useModuleBasicSetting.js b/src/hooks/module/useModuleBasicSetting.js index 3f472210..c7450aff 100644 --- a/src/hooks/module/useModuleBasicSetting.js +++ b/src/hooks/module/useModuleBasicSetting.js @@ -1,5 +1,5 @@ import { useEffect, useState } from 'react' -import { useRecoilState, useRecoilValue, useSetRecoilState } from 'recoil' +import { useRecoilState, useRecoilValue, useResetRecoilState, useSetRecoilState } from 'recoil' import { canvasSettingState, @@ -40,12 +40,14 @@ export function useModuleBasicSetting(tabNum) { const { getMessage } = useMessage() const roofDisplay = useRecoilValue(roofDisplaySelector) const [moduleSetupSurface, setModuleSetupSurface] = useRecoilState(moduleSetupSurfaceState) + const resetModuleSetupSurface = useResetRecoilState(moduleSetupSurfaceState) const { addCanvasMouseEventListener, initEvent, removeMouseEvent, addTargetMouseEventListener } = useEvent() const { swalFire } = useSwal() const compasDeg = useRecoilValue(compasDegAtom) const { setSurfaceShapePattern } = useRoofFn() const checkedModule = useRecoilValue(checkedModuleState) const [isManualModuleSetup, setIsManualModuleSetup] = useRecoilState(isManualModuleSetupState) + const [isManualModuleLayoutSetup, setIsManualModuleLayoutSetup] = useRecoilState(isManualModuleLayoutSetupState) const canvasSetting = useRecoilValue(canvasSettingState) const moduleSelectionData = useRecoilValue(moduleSelectionDataState) @@ -67,6 +69,7 @@ export function useModuleBasicSetting(tabNum) { useEffect(() => { initEvent() + resetModuleSetupSurface() return () => { //수동 설치시 초기화 removeMouseEvent('mouse:up') diff --git a/src/hooks/useUndoRedo.js b/src/hooks/useUndoRedo.js index e0f8bebc..bdce19a6 100644 --- a/src/hooks/useUndoRedo.js +++ b/src/hooks/useUndoRedo.js @@ -69,6 +69,15 @@ function _restoreFromJSON(canvas, snapshot, restoreAtomSnapshot, rebuildCanvasAt canvas.loadFromJSON(snapshot.canvasJSON, () => { rebuildCanvasAtoms() + + // undo/redo 후 모듈 설치영역이 active 인 채로 남으면 + // 사용자가 다시 클릭하지 않아도 선택된 것처럼 보여 자동 배치 등 부작용 발생. + // 활성 객체가 MODULE_SETUP_SURFACE 이면 선택 해제한다. + const active = canvas.getActiveObject() + if (active && active.name === POLYGON_TYPE.MODULE_SETUP_SURFACE) { + canvas.discardActiveObject() + } + canvas.renderAll() restoreAtomSnapshot(snapshot.atomSnapshot) -- 2.47.2 From 9a24860b1c04673697acc7193ef9fa9225a6c3a5 Mon Sep 17 00:00:00 2001 From: "hyojun.choi" Date: Tue, 26 May 2026 10:16:31 +0900 Subject: [PATCH 17/60] =?UTF-8?q?=EB=AA=A8=EB=93=88=20=EC=82=AD=EC=A0=9C?= =?UTF-8?q?=20=EC=8B=9C=20=EC=8A=A4=EB=83=85=EC=83=B7=20=EC=A4=91=EB=B3=B5?= =?UTF-8?q?=20=EC=A0=9C=EA=B1=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../floor-plan/modal/module/column/ColumnRemove.jsx | 3 --- src/components/floor-plan/modal/module/row/RowRemove.jsx | 4 ---- src/hooks/useContextMenu.js | 1 - 3 files changed, 8 deletions(-) diff --git a/src/components/floor-plan/modal/module/column/ColumnRemove.jsx b/src/components/floor-plan/modal/module/column/ColumnRemove.jsx index 2aafb954..b9079642 100644 --- a/src/components/floor-plan/modal/module/column/ColumnRemove.jsx +++ b/src/components/floor-plan/modal/module/column/ColumnRemove.jsx @@ -1,4 +1,3 @@ -import { useUndoRedo } from '@/hooks/useUndoRedo' import WithDraggable from '@/components/common/draggable/WithDraggable' import { useRecoilValue } from 'recoil' import { contextPopupPositionState } from '@/store/popupAtom' @@ -15,7 +14,6 @@ export default function ColumnRemove(props) { const [selectedType, setSelectedType] = useState(MODULE_REMOVE_TYPE.LEFT) const { getMessage } = useMessage() const { moduleColumnRemove } = useModule() - const { saveSnapshot } = useUndoRedo() const types = [ { name: getMessage('modal.panel.column.remove.type.left'), value: MODULE_REMOVE_TYPE.LEFT }, { name: getMessage('modal.panel.column.remove.type.right'), value: MODULE_REMOVE_TYPE.RIGHT }, @@ -23,7 +21,6 @@ export default function ColumnRemove(props) { { name: getMessage('modal.panel.column.remove.type.none'), value: MODULE_REMOVE_TYPE.NONE }, ] const handleApply = () => { - saveSnapshot() moduleColumnRemove(selectedType) closePopup(id) } diff --git a/src/components/floor-plan/modal/module/row/RowRemove.jsx b/src/components/floor-plan/modal/module/row/RowRemove.jsx index cdfeebd5..a6d61188 100644 --- a/src/components/floor-plan/modal/module/row/RowRemove.jsx +++ b/src/components/floor-plan/modal/module/row/RowRemove.jsx @@ -1,4 +1,3 @@ -import { useUndoRedo } from '@/hooks/useUndoRedo' import WithDraggable from '@/components/common/draggable/WithDraggable' import Image from 'next/image' import { useState } from 'react' @@ -15,7 +14,6 @@ export default function RowRemove(props) { const [selectedType, setSelectedType] = useState(MODULE_REMOVE_TYPE.TOP) const { getMessage } = useMessage() const { moduleRowRemove } = useModule() - const { saveSnapshot } = useUndoRedo() const types = [ { name: getMessage('modal.row.remove.type.up'), value: MODULE_REMOVE_TYPE.TOP }, { name: getMessage('modal.row.remove.type.down'), value: MODULE_REMOVE_TYPE.BOTTOM }, @@ -23,8 +21,6 @@ export default function RowRemove(props) { { name: getMessage('modal.row.remove.type.none'), value: MODULE_REMOVE_TYPE.NONE }, ] const handleApply = () => { - saveSnapshot() - // if (apply) apply() moduleRowRemove(selectedType) closePopup(id) } diff --git a/src/hooks/useContextMenu.js b/src/hooks/useContextMenu.js index bd1a674f..e57e6acc 100644 --- a/src/hooks/useContextMenu.js +++ b/src/hooks/useContextMenu.js @@ -727,7 +727,6 @@ export function useContextMenu() { id: 'remove', name: getMessage('contextmenu.remove'), fn: () => { - saveSnapshot() modulesRemove() }, }, -- 2.47.2 From 39f4b0130fb1265f3cf603e8e6e24f5fbefa73a4 Mon Sep 17 00:00:00 2001 From: "hyojun.choi" Date: Tue, 26 May 2026 10:23:25 +0900 Subject: [PATCH 18/60] =?UTF-8?q?=EC=97=B4=EC=9D=B4=EB=8F=99,=20=EB=8B=A8?= =?UTF-8?q?=EC=9D=B4=EB=8F=99=20=EC=8B=9C=20=EC=8A=A4=EB=83=85=EC=83=B7=20?= =?UTF-8?q?=EC=A0=80=EC=9E=A5=20=EC=9C=84=EC=B9=98=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/components/floor-plan/modal/module/PanelEdit.jsx | 4 ---- src/hooks/module/useModule.js | 3 +++ 2 files changed, 3 insertions(+), 4 deletions(-) diff --git a/src/components/floor-plan/modal/module/PanelEdit.jsx b/src/components/floor-plan/modal/module/PanelEdit.jsx index 1c9d0e75..1c212a33 100644 --- a/src/components/floor-plan/modal/module/PanelEdit.jsx +++ b/src/components/floor-plan/modal/module/PanelEdit.jsx @@ -8,8 +8,6 @@ import { canvasState } from '@/store/canvasAtom' import { POLYGON_TYPE } from '@/common/common' import { useModule } from '@/hooks/module/useModule' import { useSwal } from '@/hooks/useSwal' -import { useUndoRedo } from '@/hooks/useUndoRedo' - export const PANEL_EDIT_TYPE = { MOVE: 'move', MOVE_ALL: 'moveAll', @@ -31,7 +29,6 @@ export default function PanelEdit(props) { const canvas = useRecoilValue(canvasState) const { swalFire } = useSwal() const { moduleMove, moduleCopy, moduleMultiMove, moduleMultiCopy, moduleMoveAll, moduleCopyAll } = useModule() - const { saveSnapshot } = useUndoRedo() useEffect(() => { if (!canvas) { const isSetupModules = canvas.getObjects().filter((obj) => obj.name === 'module') // selectedObj에 없는 객체만 필터링 @@ -54,7 +51,6 @@ export default function PanelEdit(props) { //모듈 이동 적용 const handleApply = () => { - saveSnapshot() if (length <= 0) { swalFire({ title: getMessage('common.message.please.input.over', [1]), diff --git a/src/hooks/module/useModule.js b/src/hooks/module/useModule.js index 41f014f9..e7c1111f 100644 --- a/src/hooks/module/useModule.js +++ b/src/hooks/module/useModule.js @@ -277,6 +277,7 @@ export function useModule() { } const moduleCopyAll = (length, direction, surfaceArray) => { + saveSnapshot() surfaceArray.forEach((surface) => { const modules = canvas .getObjects() @@ -332,6 +333,7 @@ export function useModule() { const moduleCopy = (length, direction) => { if (canvas.getActiveObjects().length === 0) return + saveSnapshot() const activeModuleIds = canvas.getActiveObjects().map((obj) => obj.id) const modules = canvas.getObjects().filter((obj) => activeModuleIds.includes(obj.id)) const objects = getObjects() @@ -404,6 +406,7 @@ export function useModule() { removeTrestleMaterials() } + saveSnapshot() const modules = type === 'row' ? getRowModules(activeModule) : getColumnModules(activeModule) const otherModules = canvas.getObjects().filter((obj) => obj.surfaceId === modules[0].surfaceId && obj.name === POLYGON_TYPE.MODULE) const objects = getObjects() -- 2.47.2 From b775af191120ecbd62279f41c227f4e4bb76149b Mon Sep 17 00:00:00 2001 From: "hyojun.choi" Date: Tue, 26 May 2026 10:31:08 +0900 Subject: [PATCH 19/60] =?UTF-8?q?=EB=AA=A8=EB=93=88=20redo=20undo=20?= =?UTF-8?q?=EC=8B=9C=20=EC=84=A0=ED=83=9D=EB=90=98=EC=96=B4=20=EC=9E=88?= =?UTF-8?q?=EB=8A=94=EA=B2=83=20=ED=95=B4=EC=A0=9C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/hooks/useUndoRedo.js | 83 ++++++++++++++++++++++------------------ 1 file changed, 45 insertions(+), 38 deletions(-) diff --git a/src/hooks/useUndoRedo.js b/src/hooks/useUndoRedo.js index bdce19a6..0472ba1d 100644 --- a/src/hooks/useUndoRedo.js +++ b/src/hooks/useUndoRedo.js @@ -17,7 +17,7 @@ import { import { outerLinePointsState } from '@/store/outerLineAtom' import { selectedMenuState } from '@/store/menuAtom' import { pendingPopupState, popupState } from '@/store/popupAtom' -import { MENU, POLYGON_TYPE, SAVE_KEY } from '@/common/common' +import { POLYGON_TYPE, SAVE_KEY } from '@/common/common' const MAX_HISTORY = 3 @@ -70,13 +70,12 @@ function _restoreFromJSON(canvas, snapshot, restoreAtomSnapshot, rebuildCanvasAt canvas.loadFromJSON(snapshot.canvasJSON, () => { rebuildCanvasAtoms() - // undo/redo 후 모듈 설치영역이 active 인 채로 남으면 - // 사용자가 다시 클릭하지 않아도 선택된 것처럼 보여 자동 배치 등 부작용 발생. - // 활성 객체가 MODULE_SETUP_SURFACE 이면 선택 해제한다. - const active = canvas.getActiveObject() - if (active && active.name === POLYGON_TYPE.MODULE_SETUP_SURFACE) { - canvas.discardActiveObject() - } + canvas.discardActiveObject() + canvas.getObjects().forEach((obj) => { + if (obj.type === 'QPolygon' && obj.name === POLYGON_TYPE.MODULE) { + obj.set({ stroke: 'black', strokeWidth: 0.3 }) + } + }) canvas.renderAll() restoreAtomSnapshot(snapshot.atomSnapshot) @@ -92,6 +91,7 @@ function _restoreFromJSON(canvas, snapshot, restoreAtomSnapshot, rebuildCanvasAt }, 50) }) } + // 순환 참조만 안전하게 건너뛰는 JSON 직렬화 // WeakSet 방식은 공유 참조(같은 배열이 여러 곳에서 참조)까지 제거하므로 사용 불가 // 대신 조상 경로(ancestors) 스택으로 진짜 순환만 감지 @@ -364,7 +364,9 @@ export function useUndoRedo() { if (polygon.id) { polygon.texts = lengthTextsByParent.get(polygon.id) || [] } - } catch (e) { /* skip */ } + } catch (e) { + /* skip */ + } }) // Group 내부 QPolygon(도머 등)도 동일하게 내부 라인 인스턴스만 재구축한다. @@ -379,7 +381,9 @@ export function useUndoRedo() { if (child.id) { child.texts = lengthTextsByParent.get(child.id) || [] } - } catch (e) { /* skip */ } + } catch (e) { + /* skip */ + } } }) }) @@ -409,40 +413,43 @@ export function useUndoRedo() { } }, [canvas]) - const saveSnapshot = useCallback((atomOverrides = null, { force = false } = {}) => { - if (!canvas || _isRestoring) return + const saveSnapshot = useCallback( + (atomOverrides = null, { force = false } = {}) => { + if (!canvas || _isRestoring) return - // 외벽선/배치면/지붕형상 수동설정 등 그리기 진행 중에는 스냅샷을 쌓지 않는다 (force 시 예외) - if (!force && (_isDrawing || isDrawing)) return + // 외벽선/배치면/지붕형상 수동설정 등 그리기 진행 중에는 스냅샷을 쌓지 않는다 (force 시 예외) + if (!force && (_isDrawing || isDrawing)) return - // snapshot 전에 mouseLine 제거 - const mouseLines = canvas.getObjects().filter((obj) => obj.name === 'mouseLine') - mouseLines.forEach((obj) => canvas.remove(obj)) + // snapshot 전에 mouseLine 제거 + const mouseLines = canvas.getObjects().filter((obj) => obj.name === 'mouseLine') + mouseLines.forEach((obj) => canvas.remove(obj)) - const canvasJSON = getCanvasSnapshot() - if (!canvasJSON) return + const canvasJSON = getCanvasSnapshot() + if (!canvasJSON) return - const atomSnapshot = getAtomSnapshot() + const atomSnapshot = getAtomSnapshot() - // atomOverrides 로 비동기 state 반영이 안 된 값을 즉시 덮어쓸 수 있다 - if (atomOverrides) { - Object.assign(atomSnapshot, atomOverrides) - } - - const snapshot = { - canvasJSON, - atomSnapshot, - } - - setUndoStack((prev) => { - const newStack = [...prev, snapshot] - if (newStack.length > MAX_HISTORY) { - return newStack.slice(newStack.length - MAX_HISTORY) + // atomOverrides 로 비동기 state 반영이 안 된 값을 즉시 덮어쓸 수 있다 + if (atomOverrides) { + Object.assign(atomSnapshot, atomOverrides) } - return newStack - }) - setRedoStack([]) - }, [canvas, currentMenu, isDrawing, getCanvasSnapshot, getAtomSnapshot]) + + const snapshot = { + canvasJSON, + atomSnapshot, + } + + setUndoStack((prev) => { + const newStack = [...prev, snapshot] + if (newStack.length > MAX_HISTORY) { + return newStack.slice(newStack.length - MAX_HISTORY) + } + return newStack + }) + setRedoStack([]) + }, + [canvas, currentMenu, isDrawing, getCanvasSnapshot, getAtomSnapshot], + ) const closeAllPopups = useCallback(() => { setPopup((prev) => ({ -- 2.47.2 From f5758cb383edf28f98d2b25dcb6dc312e9b7d25d Mon Sep 17 00:00:00 2001 From: "hyojun.choi" Date: Tue, 26 May 2026 11:13:47 +0900 Subject: [PATCH 20/60] =?UTF-8?q?=EB=AA=A8=EB=93=88=20=EC=84=B8=EB=A1=9C?= =?UTF-8?q?=20=EA=B0=80=EC=9A=B4=EB=8D=B0=EC=A0=95=EB=A0=AC,=20=EA=B0=80?= =?UTF-8?q?=EB=A1=9C=20=EA=B0=80=EC=9A=B4=EB=8D=B0=EC=A0=95=EB=A0=AC=20sna?= =?UTF-8?q?pshot=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/hooks/useContextMenu.js | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/hooks/useContextMenu.js b/src/hooks/useContextMenu.js index e57e6acc..4e56d93e 100644 --- a/src/hooks/useContextMenu.js +++ b/src/hooks/useContextMenu.js @@ -901,6 +901,7 @@ export function useContextMenu() { return } + saveSnapshot() resetModule() alignModule(MODULE_ALIGN_TYPE.VERTICAL, currentObject.arrayData ?? [currentObject]) }, @@ -918,6 +919,7 @@ export function useContextMenu() { return } + saveSnapshot() resetModule() alignModule(MODULE_ALIGN_TYPE.HORIZONTAL, currentObject.arrayData ?? [currentObject]) }, -- 2.47.2 From 74c980adaf9e6562b2b3607899e1a13ea5bfb573 Mon Sep 17 00:00:00 2001 From: sangwook yoo Date: Wed, 27 May 2026 14:42:02 +0900 Subject: [PATCH 21/60] =?UTF-8?q?redo/undo=20=EA=B8=B0=EB=8A=A5=20?= =?UTF-8?q?=EA=B0=9C=EB=B0=9C=EC=9D=84=20=EC=9C=84=ED=95=9C=20=EB=A1=9C?= =?UTF-8?q?=EC=BB=AC=20=EC=8B=9C=EC=9E=91=20=EC=8A=A4=ED=81=AC=EB=A6=BD?= =?UTF-8?q?=ED=8A=B8=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- package.json | 1 + startscript-ur.js | 2 ++ 2 files changed, 3 insertions(+) create mode 100644 startscript-ur.js diff --git a/package.json b/package.json index 1f96d08c..e4045015 100644 --- a/package.json +++ b/package.json @@ -14,6 +14,7 @@ "start:cluster1": "env-cmd -f .env.production next start -p 5000", "start:cluster2": "env-cmd -f .env.production next start -p 5001", "start:dev": "env-cmd -f .env.development next start -p 5010", + "start:ur": "env-cmd -f .env.localhost next start -p 5050", "lint": "next lint", "serve": "node server.js" }, diff --git a/startscript-ur.js b/startscript-ur.js new file mode 100644 index 00000000..fc940e28 --- /dev/null +++ b/startscript-ur.js @@ -0,0 +1,2 @@ +var exec = require('child_process').exec +exec('yarn start:ur', { windowsHide: true }) -- 2.47.2 From f91b953ef5a908ef1f8e96d3565d7bf896af8a60 Mon Sep 17 00:00:00 2001 From: sangwook yoo Date: Thu, 28 May 2026 17:35:28 +0900 Subject: [PATCH 22/60] feat: Add development server startup script for UR environment - Introduces a new `start:ur` script in `package.json` to launch the Next.js application on port 5050 for the UR environment. --- package.json | 1 + 1 file changed, 1 insertion(+) diff --git a/package.json b/package.json index e990626b..7e51dd5d 100644 --- a/package.json +++ b/package.json @@ -14,6 +14,7 @@ "start:cluster1": "env-cmd -f .env.production next start -p 5000", "start:cluster2": "env-cmd -f .env.production next start -p 5001", "start:dev": "env-cmd -f .env.development next start -p 5010", + "start:ur": "env-cmd -f .env.development next start -p 5050", "start:btob": "env-cmd -f .env.development next start -p 5060", "lint": "next lint", "serve": "node server.js" -- 2.47.2 From 5391e6de71e0a3f10864d457db2a28787c60eda2 Mon Sep 17 00:00:00 2001 From: "hyojun.choi" Date: Thu, 28 May 2026 18:25:20 +0900 Subject: [PATCH 23/60] =?UTF-8?q?=ED=99=94=EC=82=B4=ED=91=9C=20css=20?= =?UTF-8?q?=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/styles/_contents.scss | 42 +++++++++++++++++++++++++++++++++++++++ 1 file changed, 42 insertions(+) diff --git a/src/styles/_contents.scss b/src/styles/_contents.scss index a3f97928..8375573f 100644 --- a/src/styles/_contents.scss +++ b/src/styles/_contents.scss @@ -113,6 +113,48 @@ align-items: center; margin-left: auto; + .undo-redo-wrap { + display: flex; + align-items: center; + gap: 4px; + margin-right: 8px; + + button { + display: flex; + align-items: center; + justify-content: center; + width: 30px; + height: 30px; + padding: 0; + border: none; + border-radius: 2px; + background-color: #3d3d3d; + color: #fff; + cursor: pointer; + transition: all 0.17s ease-in-out; + + svg { + width: 16px; + height: 16px; + stroke: currentColor; + stroke-width: 2; + stroke-linecap: round; + stroke-linejoin: round; + fill: none; + } + + &:hover:not(.disabled):not(:disabled) { + background-color: #1083e3; + } + + &.disabled, + &:disabled { + opacity: 0.35; + cursor: not-allowed; + } + } + } + .select-box { width: 124px; margin: 0 5px; -- 2.47.2 From 15eaa4950a007abc8c85aeabb1177bdd085ffa61 Mon Sep 17 00:00:00 2001 From: "hyojun.choi" Date: Mon, 1 Jun 2026 16:18:19 +0900 Subject: [PATCH 24/60] =?UTF-8?q?=EC=B2=98=EB=A7=88=20=EC=BC=80=EB=9D=BC?= =?UTF-8?q?=EB=B0=94=20=EB=B3=80=EA=B2=BD=20=EC=97=90=EB=9F=AC=20=EC=88=98?= =?UTF-8?q?=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/hooks/roofcover/useEavesGableEdit.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/hooks/roofcover/useEavesGableEdit.js b/src/hooks/roofcover/useEavesGableEdit.js index e4373713..e0804d2e 100644 --- a/src/hooks/roofcover/useEavesGableEdit.js +++ b/src/hooks/roofcover/useEavesGableEdit.js @@ -3440,7 +3440,7 @@ export function useEavesGableEdit(id) { return true } - s // [2240 KERAB-KLINE-ONLY 2026-05-20] 양 끝 hip 평행 케이스: apex 가 없어 중앙선조차 그릴 수 없음. + // [2240 KERAB-KLINE-ONLY 2026-05-20] 양 끝 hip 평행 케이스: apex 가 없어 중앙선조차 그릴 수 없음. // "라인 삭제 금지 + 중앙선만 그리기" 정책상 hip 제거도 skip → attr-only 와 동일하게 동작. const applyKerabParallelHipsPattern = () => { canvas.renderAll() -- 2.47.2 From 4378712b39efc80c6b7be03a9961ed7829bfdc87 Mon Sep 17 00:00:00 2001 From: "hyojun.choi" Date: Tue, 2 Jun 2026 13:40:52 +0900 Subject: [PATCH 25/60] =?UTF-8?q?=EC=88=98=EB=8F=99=EC=84=A4=EC=A0=95=20?= =?UTF-8?q?=EC=A0=84=EB=B6=80=20=EC=95=88=EB=90=98=EB=A9=B4=20=EC=84=A4?= =?UTF-8?q?=EC=A0=95=EC=99=84=EB=A3=8C=20=EB=B6=88=EA=B0=80=20=EC=88=98?= =?UTF-8?q?=EB=8F=99=EC=84=A4=EC=A0=95=20=EC=99=84=EB=A3=8C=20=EC=8B=9C=20?= =?UTF-8?q?=EC=8A=A4=EB=83=85=EC=83=B7=20=EB=B3=B4=EC=A1=B0=EC=84=A0=20?= =?UTF-8?q?=EC=9E=91=EC=84=B1=20=EC=99=84=EB=A3=8C=20=EC=8B=9C=20=EC=8A=A4?= =?UTF-8?q?=EB=83=85=EC=83=B7=20=EA=B0=9C=EA=B5=AC=20=EB=B0=B0=EC=B9=98?= =?UTF-8?q?=EC=8B=9C=20undo=202=ED=9A=8C=20=ED=95=84=EC=9A=94=20=EC=88=98?= =?UTF-8?q?=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../floor-plan/modal/object/SizeSetting.jsx | 4 +-- src/hooks/object/useObjectBatch.js | 10 +++++--- src/hooks/roofcover/useAuxiliaryDrawing.js | 25 +++++++++++++++++++ .../roofcover/useRoofShapePassivitySetting.js | 21 ++++++++++++---- 4 files changed, 50 insertions(+), 10 deletions(-) diff --git a/src/components/floor-plan/modal/object/SizeSetting.jsx b/src/components/floor-plan/modal/object/SizeSetting.jsx index 00fb8e34..da7d818d 100644 --- a/src/components/floor-plan/modal/object/SizeSetting.jsx +++ b/src/components/floor-plan/modal/object/SizeSetting.jsx @@ -16,7 +16,7 @@ import { useSwal } from '@/hooks/useSwal' export default function SizeSetting(props) { const contextPopupPosition = useRecoilValue(contextPopupPositionState) const [settingTarget, setSettingTarget] = useState(props.side || 1) - const { id, pos = contextPopupPosition, target } = props + const { id, pos = contextPopupPosition, target, initialPlacement = false } = props const { getMessage } = useMessage() const { closePopup } = usePopup() const { resizeObjectBatch } = useObjectBatch({}) @@ -58,7 +58,7 @@ export default function SizeSetting(props) { } if (target.name === BATCH_TYPE.OPENING || target.name === BATCH_TYPE.SHADOW) { - resizeObjectBatch(settingTarget, target, width, height, id) + resizeObjectBatch(settingTarget, target, width, height, id, initialPlacement) } else if (target.name === POLYGON_TYPE.ROOF) { resizeSurfaceShapeBatch(settingTarget, target, width, height, id) } diff --git a/src/hooks/object/useObjectBatch.js b/src/hooks/object/useObjectBatch.js index 5b204406..0a295e5d 100644 --- a/src/hooks/object/useObjectBatch.js +++ b/src/hooks/object/useObjectBatch.js @@ -209,7 +209,9 @@ export function useObjectBatch({ isHidden, setIsHidden }) { initEvent() closeAll() - addPopup(popupId, 1, ) + // 초기 배치 흐름에서 연 사이즈 설정: 배치 시작(applyOpeningAndShadow)이 이미 "오브젝트 없음" 스냅샷을 + // 찍어뒀으므로, 이 사이즈 확정은 중복 스냅샷을 만들지 않게 한다(undo 한 번에 제거되도록). + addPopup(popupId, 1, ) } }) } else if (selectedType === INPUT_TYPE.DIMENSION) { @@ -1305,8 +1307,10 @@ export function useObjectBatch({ isHidden, setIsHidden }) { return { leftPoints, rightPoints, groupPoints } } - const resizeObjectBatch = (side, target, width, height, popupId) => { - saveSnapshot() + const resizeObjectBatch = (side, target, width, height, popupId, skipSnapshot = false) => { + // 초기 배치(applyOpeningAndShadow)에서 열린 사이즈 설정이면 배치 시작 스냅샷과 중복되므로 건너뛴다. + // 컨텍스트 메뉴 "사이즈 수정"으로 기존 오브젝트를 수정할 땐 skipSnapshot=false 라 별개 undo 로 남는다. + if (!skipSnapshot) saveSnapshot() const objectWidth = target.width const objectHeight = target.height diff --git a/src/hooks/roofcover/useAuxiliaryDrawing.js b/src/hooks/roofcover/useAuxiliaryDrawing.js index 17a1d70a..0a0102e4 100644 --- a/src/hooks/roofcover/useAuxiliaryDrawing.js +++ b/src/hooks/roofcover/useAuxiliaryDrawing.js @@ -10,6 +10,7 @@ import { fabric } from 'fabric' import { useAdsorptionPoint } from '@/hooks/useAdsorptionPoint' import { useSwal } from '@/hooks/useSwal' import { usePopup } from '@/hooks/usePopup' +import { useUndoRedo } from '@/hooks/useUndoRedo' import { calculateAngle, isSamePoint } from '@/util/qpolygon-utils' import { POLYGON_TYPE } from '@/common/common' import { useMessage } from '../useMessage' @@ -36,6 +37,7 @@ export function useAuxiliaryDrawing(id, isUseEffect = true) { const { getAdsorptionPoints } = useAdsorptionPoint() const { closePopup } = usePopup() const { getMessage } = useMessage() + const { saveSnapshot, setDrawing, popLastSnapshot } = useUndoRedo() const adsorptionRange = useRecoilValue(adsorptionRangeState) @@ -44,6 +46,7 @@ export function useAuxiliaryDrawing(id, isUseEffect = true) { const mousePointerArr = useRef([]) const roofAdsorptionPoints = useRef([]) const lineHistory = useRef([]) + const isFix = useRef(false) const length1Ref = useRef(0) const length2Ref = useRef(0) @@ -117,6 +120,24 @@ export function useAuxiliaryDrawing(id, isUseEffect = true) { } }, []) + // 보조선 그리기 시작 시 스냅샷 저장 (undo 하면 보조선 적용 전 상태로 복원). + // 그리기 진행 중에는 undo/redo/snapshot 을 차단하므로 force 로 저장한다. + // 편집 모달(AuxiliaryEdit) 은 id 없이 hook 을 호출하므로 제외한다. + useEffect(() => { + if (!id) return + const roofs = canvas?.getObjects().filter((obj) => obj.name === POLYGON_TYPE.ROOF) + if (!roofs || roofs.length === 0) return + setDrawing(true) + saveSnapshot(null, { force: true }) + return () => { + setDrawing(false) + // 확정(완료)하지 않고 닫은 경우 스냅샷 제거 + if (!isFix.current) { + popLastSnapshot() + } + } + }, []) + useEffect(() => { if (type === null) { return @@ -918,6 +939,9 @@ export function useAuxiliaryDrawing(id, isUseEffect = true) { return } + // 완료 확정 → 마운트 시 저장한 "보조선 적용 전" 스냅샷을 유지 (언마운트 cleanup 에서 pop 하지 않음) + isFix.current = true + // cutAuxiliary() const roofBases = canvas.getObjects().filter((obj) => obj.name === POLYGON_TYPE.ROOF) @@ -978,6 +1002,7 @@ export function useAuxiliaryDrawing(id, isUseEffect = true) { roofBase.innerLines = lineHistory.current.length !== 0 ? [...roofInnerLines] : roofBase.innerLines canvas.renderAll() }) + closePopup(id) } diff --git a/src/hooks/roofcover/useRoofShapePassivitySetting.js b/src/hooks/roofcover/useRoofShapePassivitySetting.js index 17dd164a..9e3b3885 100644 --- a/src/hooks/roofcover/useRoofShapePassivitySetting.js +++ b/src/hooks/roofcover/useRoofShapePassivitySetting.js @@ -1,6 +1,5 @@ -import { ANGLE_TYPE, canvasState, currentAngleTypeSelector, currentObjectState, pitchTextSelector } from '@/store/canvasAtom' +import { ANGLE_TYPE, canvasState, currentAngleTypeSelector, currentMenuState, currentObjectState, pitchTextSelector } from '@/store/canvasAtom' import { useRecoilValue, useSetRecoilState } from 'recoil' -import { currentMenuState } from '@/store/canvasAtom' import { useEffect, useRef, useState } from 'react' import { useLine } from '@/hooks/useLine' import { useMessage } from '@/hooks/useMessage' @@ -38,6 +37,7 @@ export function useRoofShapePassivitySetting(id) { const history = useRef([]) const [type, setType] = useState(TYPES.EAVES) const isFix = useRef(false) + const isConverted = useRef(false) const initLines = useRef([]) const [isLoading, setIsLoading] = useState(false) @@ -52,7 +52,7 @@ export function useRoofShapePassivitySetting(id) { useEffect(() => { const outerLines = canvas.getObjects().filter((obj) => obj.name === 'outerLine') if (!canvas.outerLineFix || outerLines.length === 0) { - swalFire({ text: getMessage('wall.line.not.found'),icon: 'warning' }) + swalFire({ text: getMessage('wall.line.not.found'), icon: 'warning' }) closePopup(id) //return } @@ -176,7 +176,6 @@ export function useRoofShapePassivitySetting(id) { } } - saveSnapshot() currentLineRef.current.set({ attributes: { ...attributes, isFixed: true }, }) @@ -202,7 +201,13 @@ export function useRoofShapePassivitySetting(id) { } const handleSave = () => { - saveSnapshot(null, { force: true }) + // 모든 외곽선이 처마/케라바/편류 중 하나로 설정되었는지 검증 → 미설정 라인이 있으면 완료 차단 + const lines = canvas.getObjects().filter((obj) => obj.name === 'outerLine') + const isAllSet = lines.length > 0 && lines.every((line) => !!line.attributes?.type) + if (!isAllSet) { + swalFire({ text: getMessage('modal.canvas.setting.roofline.properties.setting.not.setting'), icon: 'warning' }) + return + } isFix.current = true handleLineToPolygon() // 수동설정 완료 → 지붕면 할당 메뉴로 전환 (undo 가드 해제) @@ -211,6 +216,10 @@ export function useRoofShapePassivitySetting(id) { } const handleLineToPolygon = () => { + // 완료(handleSave) 후 closePopup → 언마운트 cleanup 에서 중복 호출되는 것을 방지 (스냅샷/지붕 중복 생성 차단) + if (isConverted.current) return + isConverted.current = true + const roofBases = canvas.getObjects().filter((obj) => obj.name === POLYGON_TYPE.ROOF) const exceptObjs = canvas.getObjects().filter((obj) => obj.name !== 'outerLine' && obj.parent?.name !== 'outerLine') const lines = canvas.getObjects().filter((obj) => obj.name === 'outerLine') @@ -259,6 +268,8 @@ export function useRoofShapePassivitySetting(id) { if (isFix.current) { // 완료 한 경우에는 지붕까지 그려줌 addPitchTextsByOuterLines() + // 지붕 그리기 직전(라인 세팅 완료) 상태를 스냅샷 → undo 시 지붕이 그려지기 전 화면으로 복귀 + saveSnapshot(null, { force: true }) const roof = drawRoofPolygon(wall) addLengthText(roof) } -- 2.47.2 From 4d8fc60cf107b1619b511f011a1ceca729d99938 Mon Sep 17 00:00:00 2001 From: "hyojun.choi" Date: Fri, 5 Jun 2026 13:57:51 +0900 Subject: [PATCH 26/60] =?UTF-8?q?=EB=8F=99=EC=9D=B4=EB=8F=99=20=ED=98=95?= =?UTF-8?q?=EC=98=AC=EB=A6=BC=EB=82=B4=EB=A6=BC=20undo=20=ED=96=88?= =?UTF-8?q?=EC=9D=84=EB=95=8C=20=EC=84=A0=20=EC=83=89=EA=B9=94=20=EC=9D=B4?= =?UTF-8?q?=EC=83=81=20=EB=B3=B4=EC=A1=B0=EC=84=A0=20=EC=9E=A1=EA=B3=A0=20?= =?UTF-8?q?=EC=82=AD=EC=A0=9C=EC=8B=9C=20=EC=A7=80=EB=B6=95=EC=9D=98=20?= =?UTF-8?q?=EB=B3=B4=EC=A1=B0=EC=84=A0=EC=9D=80=20=EC=82=AD=EC=A0=9C=20?= =?UTF-8?q?=EC=95=88=EB=90=98=EA=B3=A0=EC=9E=88=EC=9D=8C.=20=EB=8F=84?= =?UTF-8?q?=EB=A8=B8=20=EC=84=A4=EC=B9=98=20=EC=8B=9C=20=EB=B3=B5=EC=82=AC?= =?UTF-8?q?=20=ED=9B=84=20undo=20=EC=84=B8=EB=B2=88=ED=95=B4=EC=95=BC=20?= =?UTF-8?q?=EB=8B=A4=EC=82=AC=EB=9D=BC=EC=A7=90=20=EB=8F=84=EB=A8=B8=20?= =?UTF-8?q?=EC=98=A4=ED=94=84=EC=85=8B=EC=9D=B4=EB=8F=99=20=EC=8B=9C=20?= =?UTF-8?q?=EC=95=88=EB=90=A8=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/hooks/common/useCommonUtils.js | 3 +- src/hooks/object/useObjectBatch.js | 27 ++++++------- src/hooks/roofcover/useAuxiliaryDrawing.js | 7 ++++ src/hooks/roofcover/useMovementSetting.js | 44 +++++++++++++--------- src/hooks/useContextMenu.js | 22 ++++++++--- 5 files changed, 66 insertions(+), 37 deletions(-) diff --git a/src/hooks/common/useCommonUtils.js b/src/hooks/common/useCommonUtils.js index db6f4b5b..5225e50f 100644 --- a/src/hooks/common/useCommonUtils.js +++ b/src/hooks/common/useCommonUtils.js @@ -619,7 +619,8 @@ export function useCommonUtils() { } const buttonAct = dormerName == BATCH_TYPE.TRIANGLE_DORMER ? 3 : 4 - applyDormers(dormerParams, buttonAct) + // [undo 중복 fix] copyObject 에서 이미 saveSnapshot 을 호출했으므로 applyDormers 의 스냅샷은 건너뛴다. + applyDormers(dormerParams, buttonAct, true) } else if (obj.name === 'roof' && obj.type === 'QPolygon') { // roof(QPolygon) 객체는 순환 참조(lines[].parent -> polygon)로 인해 // fabric.clone() 사용 시 Maximum call stack size exceeded 에러 발생 diff --git a/src/hooks/object/useObjectBatch.js b/src/hooks/object/useObjectBatch.js index 0a295e5d..2c485f92 100644 --- a/src/hooks/object/useObjectBatch.js +++ b/src/hooks/object/useObjectBatch.js @@ -313,7 +313,7 @@ export function useObjectBatch({ isHidden, setIsHidden }) { * @param {*} surfaceShapePolygons * @returns */ - const applyDormers = ({ height, width, pitch, offsetRef, offsetWidthRef, directionRef }, buttonAct) => { + const applyDormers = ({ height, width, pitch, offsetRef, offsetWidthRef, directionRef }, buttonAct, skipSnapshot = false) => { const dormerName = buttonAct === 3 ? BATCH_TYPE.TRIANGLE_DORMER : BATCH_TYPE.PENTAGON_DORMER const dormerTempName = buttonAct === 3 ? BATCH_TYPE.TRIANGLE_DORMER_TEMP : BATCH_TYPE.PENTAGON_DORMER_TEMP @@ -331,7 +331,9 @@ export function useObjectBatch({ isHidden, setIsHidden }) { return } - saveSnapshot() + // [undo 중복 fix] 도머 복사(commonCopyObject)는 copyObject 에서 이미 saveSnapshot 을 호출한 뒤 + // applyDormers 를 재사용하므로, 복사 경로에서는 skipSnapshot=true 로 중복 스냅샷을 막는다. + if (!skipSnapshot) saveSnapshot() let theta = 0 @@ -1602,7 +1604,6 @@ export function useObjectBatch({ isHidden, setIsHidden }) { } const dormerOffset = (arrow1, arrow2, length1, length2) => { - saveSnapshot() length1 = parseInt(length1) / 10 length2 = parseInt(length2) / 10 @@ -1631,22 +1632,17 @@ export function useObjectBatch({ isHidden, setIsHidden }) { } } + // [좌표 검증 fix] moveObjectBatch 와 동일하게 setCoords()+getCurrentPoints() 로 실제 이동된 + // 월드좌표를 검사한다. 과거처럼 groupPoints+delta 로 검사하면 2배 이동된 좌표가 되어 + // 도머가 지붕 내부에 있어도 within=false → '지붕내 설치' alert 오발생. + dormer.setCoords() const parentSurface = canvas?.getObjects().filter((item) => item.name === POLYGON_TYPE.ROOF && item.id === dormer.parentId)[0] const turfSurface = pointsToTurfPolygon(parentSurface.getCurrentPoints()) let turfDormer = pointsToTurfPolygon(rectToPolygon(dormer)) let currentPoints = [] if (dormer.name === BATCH_TYPE.TRIANGLE_DORMER || dormer.name === BATCH_TYPE.PENTAGON_DORMER) { - const calcLeft = dormer.left - left - const calcTop = dormer.top - top - - currentPoints = dormer.groupPoints.map((item) => { - return { - x: item.x + calcLeft, - y: item.y + calcTop, - } - }) - + currentPoints = dormer.getCurrentPoints() turfDormer = pointsToTurfPolygon(currentPoints) } @@ -1659,10 +1655,15 @@ export function useObjectBatch({ isHidden, setIsHidden }) { //위치 원복` dormer.left = left dormer.top = top + dormer.setCoords() return } + // [undo 중복 fix] 검증 통과 후에만 스냅샷을 저장한다. 검증 실패(원복) 시에도 스냅샷이 + // 쌓이면 도머 작업 undo 횟수가 불필요하게 늘어난다. + saveSnapshot() + if (dormer.type === 'group') { dormer.set({ groupPoints: currentPoints }) const newDormer = reGroupObject(dormer) diff --git a/src/hooks/roofcover/useAuxiliaryDrawing.js b/src/hooks/roofcover/useAuxiliaryDrawing.js index 0a0102e4..62dab535 100644 --- a/src/hooks/roofcover/useAuxiliaryDrawing.js +++ b/src/hooks/roofcover/useAuxiliaryDrawing.js @@ -185,6 +185,13 @@ export function useAuxiliaryDrawing(id, isUseEffect = true) { name: 'auxiliaryLine', }) + // [복사본 roofId 유실 fix] addLine 이 내부에서 attributes 를 {} 로 초기화하므로, + // 원본 attributes(roofId 등)를 복사해 복원한다. 미복원 시 복사본의 roofId 가 없어 + // 우클릭 '보조선 전체삭제' 등 roofId 기반 처리가 누락된다. + if (newLine) { + newLine.attributes = { ...object.attributes } + } + // roofs의 innerLines에서 object와 같은 것을 찾아서 newLine으로 변경 const roofs = canvas.getObjects().filter((obj) => obj.name === POLYGON_TYPE.ROOF) roofs.forEach((roof) => { diff --git a/src/hooks/roofcover/useMovementSetting.js b/src/hooks/roofcover/useMovementSetting.js index e9231178..cf06ba57 100644 --- a/src/hooks/roofcover/useMovementSetting.js +++ b/src/hooks/roofcover/useMovementSetting.js @@ -156,23 +156,7 @@ export function useMovementSetting(id) { canvas.remove(CONFIRM_LINE_REF.current) CONFIRM_LINE_REF.current = null } - const roofs = canvas.getObjects().filter((obj) => obj.name === POLYGON_TYPE.ROOF) - roofs.forEach((roof) => { - roof.set({ stroke: '#1083E3' }) - roof.innerLines.forEach((line) => line.set({ selectable: true, strokeWidth: 2, stroke: '#1083E3' })) - }) - const walls = canvas.getObjects().filter((obj) => obj.name === POLYGON_TYPE.WALL) - walls.forEach((wall) => { - if (wall.baseLines.length === 0) { - wall.baseLines = canvas.getObjects().filter((obj) => obj.name === 'baseLine' && obj.attributes.wallId === wall.id) - } - wall.baseLines.forEach((baseLine) => { - baseLine.set({ selectable: false, visible: false }) - }) - }) - const outerLines = canvas.getObjects().filter((obj) => obj.name === 'outerLine') - outerLines.forEach((line) => line.set({ visible: true })) - canvas.renderAll() + restoreLineStyles() initEvent() } }, []) @@ -230,6 +214,28 @@ export function useMovementSetting(id) { canvas.renderAll() }, [currentObject]) + /** 동선이동/형올림내림 적용 전(평상) 선 스타일로 되돌린다. + * saveSnapshot 직전과 팝업 cleanup 양쪽에서 호출해, undo 시 강조색이 스냅샷에 박혀 복원되지 않도록 한다. */ + const restoreLineStyles = () => { + const roofs = canvas.getObjects().filter((obj) => obj.name === POLYGON_TYPE.ROOF) + roofs.forEach((roof) => { + roof.set({ stroke: '#1083E3' }) + roof.innerLines.forEach((line) => line.set({ selectable: true, strokeWidth: 2, stroke: '#1083E3' })) + }) + const walls = canvas.getObjects().filter((obj) => obj.name === POLYGON_TYPE.WALL) + walls.forEach((wall) => { + if (wall.baseLines.length === 0) { + wall.baseLines = canvas.getObjects().filter((obj) => obj.name === 'baseLine' && obj.attributes.wallId === wall.id) + } + wall.baseLines.forEach((baseLine) => { + baseLine.set({ selectable: false, visible: false }) + }) + }) + const outerLines = canvas.getObjects().filter((obj) => obj.name === 'outerLine') + outerLines.forEach((line) => line.set({ visible: true })) + canvas.renderAll() + } + const clearRef = () => { if (type === TYPE.FLOW_LINE) { // 안전한 ref 접근 @@ -534,6 +540,10 @@ export function useMovementSetting(id) { canvas.renderAll() } + // [undo 색상 fix] 스냅샷은 동선이동/형올림내림 적용 직전 상태를 저장한다. + // 팝업 오픈 시 입힌 강조색이 스냅샷에 박히면 undo 시 강조색이 복원되므로, + // 스냅샷 저장 전에 평상 선 스타일로 되돌린다. + restoreLineStyles() saveSnapshot() const target = selectedObject.current !== null ? selectedObject.current : CONFIRM_LINE_REF.current?.target if (!target) return diff --git a/src/hooks/useContextMenu.js b/src/hooks/useContextMenu.js index 4e56d93e..20a9f0f1 100644 --- a/src/hooks/useContextMenu.js +++ b/src/hooks/useContextMenu.js @@ -469,15 +469,25 @@ export function useContextMenu() { innerLines.length = 0 } - // 확정되지 않은 보조선 - const notFixedAuxiliaryLines = canvas.getObjects().filter((obj) => obj.name === 'auxiliaryLine' && !obj.isAuxiliaryFixed) - - notFixedAuxiliaryLines.forEach((line) => { + // [전체삭제 누락 fix] 기존에는 미확정 보조선(!isAuxiliaryFixed)만 삭제해, + // 복사본/미확정 보조선을 우클릭하면 currentObject.roofId 가 위 innerLines 와 어긋나 + // 확정된 보조선이 누락되었다. 확정 여부와 무관하게 모든 보조선을 삭제한다. + const auxiliaryLines = canvas.getObjects().filter((obj) => obj.name === 'auxiliaryLine') + auxiliaryLines.forEach((line) => { canvas.remove(line) }) - //동이동현이동 - const eaveHelpLines = canvas.getObjects().filter((obj) => obj.name === 'eaveHelpLine' && obj.parentId === currentObject.attributes.roofId) + // 지붕 innerLines 에 남은 보조선/eaveHelpLine 참조 정리 (stale 부활 방지, 용마루 hip·ridge 는 보존) + canvas.getObjects().forEach((obj) => { + if (Array.isArray(obj.innerLines) && obj.innerLines.length > 0) { + obj.innerLines = obj.innerLines.filter( + (line) => line.name !== 'auxiliaryLine' && line.name !== 'eaveHelpLine' && line.lineName !== 'eaveHelpLine', + ) + } + }) + + //동이동현이동 - 모든 eaveHelpLine 삭제 (roofId 무관, name/lineName 두 식별자 모두 검사) + const eaveHelpLines = canvas.getObjects().filter((obj) => obj.name === 'eaveHelpLine' || obj.lineName === 'eaveHelpLine') eaveHelpLines.forEach((line) => { canvas.remove(line) }) -- 2.47.2 From 632221ac01685a6c8e1e8f1c9adc1440ba5abe27 Mon Sep 17 00:00:00 2001 From: "hyojun.choi" Date: Fri, 5 Jun 2026 15:14:20 +0900 Subject: [PATCH 27/60] =?UTF-8?q?=EB=8F=84=EB=A8=B8=20=EC=98=A4=ED=94=84?= =?UTF-8?q?=EC=85=8B=20undo=20=EC=95=88=EB=90=98=EB=8A=94=20=ED=98=84?= =?UTF-8?q?=EC=83=81=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/hooks/object/useObjectBatch.js | 11 ++++++----- 1 file changed, 6 insertions(+), 5 deletions(-) diff --git a/src/hooks/object/useObjectBatch.js b/src/hooks/object/useObjectBatch.js index 2c485f92..1bf9c6b1 100644 --- a/src/hooks/object/useObjectBatch.js +++ b/src/hooks/object/useObjectBatch.js @@ -42,7 +42,7 @@ export function useObjectBatch({ isHidden, setIsHidden }) { const { getIntersectMousePoint } = useMouse() const { checkModuleDisjointSurface } = useTurf() const currentObject = useRecoilValue(currentObjectState) - const { saveSnapshot } = useUndoRedo() + const { saveSnapshot, popLastSnapshot } = useUndoRedo() useEffect(() => { if (canvas) { @@ -1604,6 +1604,9 @@ export function useObjectBatch({ isHidden, setIsHidden }) { } const dormerOffset = (arrow1, arrow2, length1, length2) => { + // [undo fix] 도머 이동 전 상태를 스냅샷으로 저장해야 undo 시 도머가 원위치로 복원된다. + // (이동 후 저장하면 undo 해도 도머는 그 자리에 남는다.) 검증 실패 시 popLastSnapshot 으로 제거. + saveSnapshot() length1 = parseInt(length1) / 10 length2 = parseInt(length2) / 10 @@ -1657,13 +1660,11 @@ export function useObjectBatch({ isHidden, setIsHidden }) { dormer.top = top dormer.setCoords() + // [undo 중복 fix] 검증 실패(원복) 시 위에서 저장한 스냅샷을 제거해 불필요한 undo 단계를 막는다. + popLastSnapshot() return } - // [undo 중복 fix] 검증 통과 후에만 스냅샷을 저장한다. 검증 실패(원복) 시에도 스냅샷이 - // 쌓이면 도머 작업 undo 횟수가 불필요하게 늘어난다. - saveSnapshot() - if (dormer.type === 'group') { dormer.set({ groupPoints: currentPoints }) const newDormer = reGroupObject(dormer) -- 2.47.2 From dccdfdd1752ba7aeb5d02f01d8398ebe9e9a259b Mon Sep 17 00:00:00 2001 From: "hyojun.choi" Date: Fri, 5 Jun 2026 16:02:21 +0900 Subject: [PATCH 28/60] =?UTF-8?q?=EB=AA=A8=EB=93=88=20undo=20redo=EC=8B=9C?= =?UTF-8?q?=20=EA=B3=84=EC=82=B0=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/hooks/useUndoRedo.js | 16 +++++++++++----- 1 file changed, 11 insertions(+), 5 deletions(-) diff --git a/src/hooks/useUndoRedo.js b/src/hooks/useUndoRedo.js index 0472ba1d..478a483e 100644 --- a/src/hooks/useUndoRedo.js +++ b/src/hooks/useUndoRedo.js @@ -18,6 +18,7 @@ import { outerLinePointsState } from '@/store/outerLineAtom' import { selectedMenuState } from '@/store/menuAtom' import { pendingPopupState, popupState } from '@/store/popupAtom' import { POLYGON_TYPE, SAVE_KEY } from '@/common/common' +import { useCircuitTrestle } from '@/hooks/useCirCuitTrestle' const MAX_HISTORY = 3 @@ -53,7 +54,7 @@ export function isUndoRedoDrawing() { } // loadFromJSON 공통 처리 - 이벤트 일시 중단 후 복원 -function _restoreFromJSON(canvas, snapshot, restoreAtomSnapshot, rebuildCanvasAtoms, setCanvasZoom) { +function _restoreFromJSON(canvas, snapshot, restoreAtomSnapshot, rebuildCanvasAtoms, setCanvasZoom, setModuleStatisticsData) { // 진행 중인 복원 타이머가 있으면 취소 if (_restoreTimer) { clearTimeout(_restoreTimer) @@ -80,6 +81,10 @@ function _restoreFromJSON(canvas, snapshot, restoreAtomSnapshot, rebuildCanvasAt canvas.renderAll() restoreAtomSnapshot(snapshot.atomSnapshot) + // [undo 집계 fix] 캔버스/surface.modules 복원 후 패널 배치 집계를 재계산한다. + // 집계 atom(moduleStatisticsState)은 스냅샷 대상이 아니라, 복원 시점에 다시 계산해야 한다. + if (typeof setModuleStatisticsData === 'function') setModuleStatisticsData() + if (canvas.viewportTransform) { setCanvasZoom(Number((canvas.viewportTransform[0] * 100).toFixed(0))) } @@ -141,6 +146,7 @@ export function useUndoRedo() { const [, setCanvasZoom] = useRecoilState(canvasZoomState) const setPopup = useSetRecoilState(popupState) const [pendingPopup, setPendingPopup] = useRecoilState(pendingPopupState) + const { setModuleStatisticsData } = useCircuitTrestle() // 최신 스택 값을 ref로 유지하여 클로저 stale 문제 방지 const undoStackRef = useRef(undoStack) @@ -485,8 +491,8 @@ export function useUndoRedo() { setUndoStack(newUndoStack) setRedoStack((prev) => [...prev, currentSnapshot]) - _restoreFromJSON(canvas, snapshot, restoreAtomSnapshot, rebuildCanvasAtoms, setCanvasZoom) - }, [canvas, currentMenu, isDrawing, getCanvasSnapshot, getAtomSnapshot, restoreAtomSnapshot, rebuildCanvasAtoms, closeAllPopups]) + _restoreFromJSON(canvas, snapshot, restoreAtomSnapshot, rebuildCanvasAtoms, setCanvasZoom, setModuleStatisticsData) + }, [canvas, currentMenu, isDrawing, getCanvasSnapshot, getAtomSnapshot, restoreAtomSnapshot, rebuildCanvasAtoms, closeAllPopups, setModuleStatisticsData]) const redo = useCallback(() => { // 외벽선/배치면/지붕형상 수동설정 등 그리기 진행 중에는 redo 차단 @@ -513,8 +519,8 @@ export function useUndoRedo() { setRedoStack(newRedoStack) setUndoStack((prev) => [...prev, currentSnapshot]) - _restoreFromJSON(canvas, snapshot, restoreAtomSnapshot, rebuildCanvasAtoms, setCanvasZoom) - }, [canvas, currentMenu, isDrawing, getCanvasSnapshot, getAtomSnapshot, restoreAtomSnapshot, rebuildCanvasAtoms, closeAllPopups]) + _restoreFromJSON(canvas, snapshot, restoreAtomSnapshot, rebuildCanvasAtoms, setCanvasZoom, setModuleStatisticsData) + }, [canvas, currentMenu, isDrawing, getCanvasSnapshot, getAtomSnapshot, restoreAtomSnapshot, rebuildCanvasAtoms, closeAllPopups, setModuleStatisticsData]) const clearStacks = useCallback(() => { setUndoStack([]) -- 2.47.2 From dda00e75b3477bbbd9c2b9ab84c4cbd340deb29d Mon Sep 17 00:00:00 2001 From: "hyojun.choi" Date: Tue, 9 Jun 2026 14:17:31 +0900 Subject: [PATCH 29/60] =?UTF-8?q?=EB=B0=B0=EC=B9=98=EB=A9=B4=20=EC=9D=B4?= =?UTF-8?q?=EB=8F=99=20=EC=8B=9C=20=EA=B8=B8=EC=9D=B4=20=EA=B3=84=EC=82=B0?= =?UTF-8?q?=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/hooks/surface/useSurfaceShapeBatch.js | 5 ++++- src/hooks/usePolygon.js | 10 +++++++--- 2 files changed, 11 insertions(+), 4 deletions(-) diff --git a/src/hooks/surface/useSurfaceShapeBatch.js b/src/hooks/surface/useSurfaceShapeBatch.js index 5ee598c5..87b7e75c 100644 --- a/src/hooks/surface/useSurfaceShapeBatch.js +++ b/src/hooks/surface/useSurfaceShapeBatch.js @@ -986,7 +986,10 @@ export function useSurfaceShapeBatch({ isHidden, setIsHidden }) { roof.fire('modified') drawDirectionArrow(roof) changeCorridorDimensionText() - addLengthText(roof) + // [이동 후 길이 fix] 이동 경로는 addLengthText 만 호출해 actualSize(경사 보정 실제길이)가 + // 재계산되지 않고 평면길이로 표시되던 문제. 생성/resize 경로와 동일하게 + // setPolygonLinesActualSize(내부에서 actualSize 재계산 + addLengthText) 로 통일한다. + setPolygonLinesActualSize(roof, true) roof.setCoords() initEvent() canvas.renderAll() diff --git a/src/hooks/usePolygon.js b/src/hooks/usePolygon.js index 16d548ce..729cd8ec 100644 --- a/src/hooks/usePolygon.js +++ b/src/hooks/usePolygon.js @@ -4,7 +4,7 @@ import { fabric } from 'fabric' import { calculateIntersection, findAndRemoveClosestPoint, getDegreeByChon, isPointOnLine } from '@/util/canvas-util' import { QPolygon } from '@/components/fabric/QPolygon' import { calcLinePlaneSize, equalizeSymmetricHips, isSamePoint, removeDuplicatePolygons } from '@/util/qpolygon-utils' -import { basicSettingState, flowDisplaySelector } from '@/store/settingAtom' +import { basicSettingState, flowDisplaySelector, corridorDimensionSelector } from '@/store/settingAtom' import { fontSelector } from '@/store/fontAtom' import { QLine } from '@/components/fabric/QLine' import { LINE_TYPE, POLYGON_TYPE } from '@/common/common' @@ -21,6 +21,7 @@ export const usePolygon = () => { const pitchText = useRecoilValue(pitchTextSelector) const globalPitch = useRecoilValue(globalPitchState) const roofSizeSet = useRecoilValue(basicSettingState).roofSizeSet + const corridorDimension = useRecoilValue(corridorDimensionSelector) const { setActualSize } = useLine() @@ -91,8 +92,11 @@ export const usePolygon = () => { const maxY = line.top + line.length const degree = (Math.atan2(y2 - y1, x2 - x1) * 180) / Math.PI - // [2026-05-18 라벨 저장값 통일] planeSize(정수 또는 0.5 step) 그대로. - const __display = planeSize ?? length + // [라벨 실제길이 모드 fix] 치수표시가 '복도치수 표시'(corridorDimension) 면 roofSizeSet 무관하게 + // planeSize(평면길이)를 표시한다. 그 외에는 roofSizeSet===1(실제길이) 이면 actualSize, 아니면 planeSize. + // 기존엔 planeSize 만 표시해, 실제치수 모드에서도 이동 후 평면길이(예: 8000)가 나오던 문제. + const __isCorridorDimension = corridorDimension?.column === 'corridorDimension' + const __display = (__isCorridorDimension ? planeSize : +roofSizeSet === 1 ? actualSize : planeSize) ?? planeSize ?? length const text = new fabric.Textbox( Number(__display).toFixed(1).replace(/\.0$/, ''), { -- 2.47.2 From c0539f49ec0d9da89cdbac3d3a0a51d330336b83 Mon Sep 17 00:00:00 2001 From: "hyojun.choi" Date: Tue, 9 Jun 2026 16:11:51 +0900 Subject: [PATCH 30/60] =?UTF-8?q?=EC=A7=80=EB=B6=95=EC=84=A0=20=EC=84=A0?= =?UTF-8?q?=ED=83=9D=20=ED=9B=84=20=EC=9D=B4=EB=8F=99=20=EC=8B=9C=20?= =?UTF-8?q?=EA=B9=A8=EC=A7=80=EB=8A=94=20=ED=98=84=EC=83=81=20=EC=88=98?= =?UTF-8?q?=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/hooks/common/useRoofFn.js | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/src/hooks/common/useRoofFn.js b/src/hooks/common/useRoofFn.js index c71b412b..8d611eaf 100644 --- a/src/hooks/common/useRoofFn.js +++ b/src/hooks/common/useRoofFn.js @@ -280,6 +280,12 @@ export function useRoofFn() { originY: 'center', objectCaching: false, }) + // [보조선 이중이동 fix] 지붕선 클릭 시 설정된 _preDragLeft 가 남아 있으면 polygonMoved 핸들러가 + // 내부 보조선(ridge 등 parentId===roof.id)을 delta 만큼 한 번 이동시키고, 아래 allRoofObject + // 평행이동이 또 이동시켜 보조선이 2×delta 로 어긋난다(줌 축소+원거리 이동 시 크게 틀어짐). + // fire 전에 정리해 polygonMoved 의 자식 이동을 막고, 평행이동은 아래에서 한 번만 수행한다. + roof._preDragLeft = null + roof._preDragTop = null roof.fire('polygonMoved') roof.fire('modified') -- 2.47.2 From e17af8f4b4734f3887e3cbae701a66c1bb2f785f Mon Sep 17 00:00:00 2001 From: Jaeyoung Lee Date: Wed, 10 Jun 2026 13:22:44 +0900 Subject: [PATCH 31/60] =?UTF-8?q?fix:=20=EC=A7=80=EB=B6=95=ED=98=95?= =?UTF-8?q?=EC=83=81=20=EC=84=A4=EC=A0=95/=EC=88=98=EB=8F=99=EC=84=A4?= =?UTF-8?q?=EC=A0=95=20=EC=99=B8=EB=B2=BD=EC=84=A0=20=ED=91=9C=EC=8B=9C?= =?UTF-8?q?=C2=B7=EC=84=A0=ED=83=9D=20=EC=83=81=ED=83=9C=20=EC=A0=95?= =?UTF-8?q?=EB=A6=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit [작업내용] : - 지붕형상 저장 후 색칠된 외벽선 선택·드래그 차단, 변별로 설정 재진입 시 선택 복원 - 수동설정 저장 시에도 자동설정과 동일하게 외벽선을 타입별 색상으로 표시 - 두 모달을 진입 시 스냅샷 → 저장 시 커밋 / 저장 없이 닫으면 롤백 구조로 변경 (팝업을 열었다 닫기만 해도 외벽선이 숨겨지고 지붕면이 삭제되던 문제 수정) - 롤백 시 WALL 폴리곤은 원본 객체를 원래 z-순서에 재삽입 (재생성 시 최상단에 올라가 보조선 클릭을 가로채던 문제 방지) Co-Authored-By: Claude Fable 5 --- .../roofcover/useRoofShapePassivitySetting.js | 69 +++++++++++-------- src/hooks/roofcover/useRoofShapeSetting.js | 54 +++++++++++++-- 2 files changed, 91 insertions(+), 32 deletions(-) diff --git a/src/hooks/roofcover/useRoofShapePassivitySetting.js b/src/hooks/roofcover/useRoofShapePassivitySetting.js index e0223139..14c648f0 100644 --- a/src/hooks/roofcover/useRoofShapePassivitySetting.js +++ b/src/hooks/roofcover/useRoofShapePassivitySetting.js @@ -36,6 +36,7 @@ export function useRoofShapePassivitySetting(id) { const [type, setType] = useState(TYPES.EAVES) const isFix = useRef(false) const initLines = useRef([]) + const removedWalls = useRef([]) const [isLoading, setIsLoading] = useState(false) const { closePopup } = usePopup() @@ -60,6 +61,7 @@ export function useRoofShapePassivitySetting(id) { addCanvasMouseEventListener('mouse:down', mouseDown) const wallLines = canvas.getObjects().filter((obj) => obj.name === POLYGON_TYPE.WALL) + removedWalls.current = wallLines.map((wall) => ({ wall, index: canvas.getObjects().indexOf(wall) })) canvas?.remove(...wallLines) const outerLines = canvas.getObjects().filter((obj) => obj.name === 'outerLine') @@ -198,10 +200,32 @@ export function useRoofShapePassivitySetting(id) { } const handleLineToPolygon = () => { - const roofBases = canvas.getObjects().filter((obj) => obj.name === POLYGON_TYPE.ROOF) - const exceptObjs = canvas.getObjects().filter((obj) => obj.name !== 'outerLine' && obj.parent?.name !== 'outerLine') const lines = canvas.getObjects().filter((obj) => obj.name === 'outerLine') + if (!isFix.current) { + // 저장 없이 닫을 경우 진입 시점 스냅샷으로 롤백 — 캔버스를 진입 전 상태 그대로 되돌린다 + lines.forEach((line, idx) => { + const init = initLines.current[idx] + if (!init) { + return + } + init.visible ? showLine(line) : hideLine(line) + line.set({ stroke: init.stroke, strokeWidth: init.strokeWidth, selectable: init.selectable }) + line.attributes = init.attributes + }) + + // 진입 시 삭제했던 WALL 폴리곤 원본을 원래 z-순서 그대로 재삽입 + removedWalls.current.forEach(({ wall, index }) => { + canvas.insertAt(wall, index) + }) + removedWalls.current = [] + + canvas.renderAll() + return + } + + const exceptObjs = canvas.getObjects().filter((obj) => obj.name !== 'outerLine' && obj.parent?.name !== 'outerLine') + let checkedAllSetting = true lines.forEach((line) => { @@ -219,36 +243,27 @@ export function useRoofShapePassivitySetting(id) { canvas.remove(obj) }) + // 저장 시에는 자동 설정과 동일하게 외벽선을 타입별 색상으로 표시 lines.forEach((line) => { - hideLine(line) + let stroke + if (line.attributes?.type === LINE_TYPE.WALLLINE.EAVES || line.attributes?.type === LINE_TYPE.WALLLINE.HIPANDGABLE) { + stroke = '#45CD7D' + } else if (line.attributes?.type === LINE_TYPE.WALLLINE.GABLE || line.attributes?.type === LINE_TYPE.WALLLINE.JERKINHEAD) { + stroke = '#3FBAE6' + } else { + stroke = '#000000' + } + line.set({ stroke, strokeWidth: 4, selectable: false, visible: true }) }) - let wall - - if (isFix.current) { - wall = addPolygonByLines(lines, { name: POLYGON_TYPE.WALL, fill: 'transparent', stroke: 'black' }) - } else { - // 그냥 닫을 경우 처리 - wall = addPolygonByLines([...initLines.current], { - name: POLYGON_TYPE.WALL, - fill: 'transparent', - stroke: 'black', - }) - lines.forEach((line, idx) => { - line.attributes = initLines.current[idx].attributes - }) - } - + const wall = addPolygonByLines(lines, { name: POLYGON_TYPE.WALL, fill: 'transparent', stroke: 'black' }) wall.lines = [...lines] - // 기존 그려진 지붕이 없다면 - - if (isFix.current) { - // 완료 한 경우에는 지붕까지 그려줌 - addPitchTextsByOuterLines() - const roof = drawRoofPolygon(wall) - addLengthText(roof) - } + // 완료 한 경우에는 지붕까지 그려줌 + addPitchTextsByOuterLines() + const roof = drawRoofPolygon(wall) + addLengthText(roof) + lines.forEach((line) => line.bringToFront()) canvas.renderAll() } diff --git a/src/hooks/roofcover/useRoofShapeSetting.js b/src/hooks/roofcover/useRoofShapeSetting.js index f5c84f5b..58fec3df 100644 --- a/src/hooks/roofcover/useRoofShapeSetting.js +++ b/src/hooks/roofcover/useRoofShapeSetting.js @@ -52,6 +52,11 @@ export function useRoofShapeSetting(id) { const isFixRef = useRef(false) + // 저장 없이 닫을 때 롤백할 진입 시점 스냅샷 + const initLinesRef = useRef([]) + const removedWallsRef = useRef([]) + const pitchTextLineIdsRef = useRef(new Set()) + const pitchRef = useRef(null) const shedPitchRef = useRef(null) const jerkinHeadPitchRef = useRef(null) @@ -87,14 +92,46 @@ export function useRoofShapeSetting(id) { closePopup(id) return } - return () => { - if (!isFixRef.current) { - return - } + initLinesRef.current = outerLines.map((line) => ({ ...line })) + pitchTextLineIdsRef.current = new Set( + canvas + .getObjects() + .filter((obj) => obj.name === 'pitchText') + .map((obj) => obj.parentId), + ) + + return () => { const outerLines = canvas.getObjects().filter((obj) => obj.name === 'outerLine') const pitchTexts = canvas.getObjects().filter((obj) => obj.name === 'pitchText') + if (!isFixRef.current) { + // 저장 없이 닫을 경우 진입 시점 스냅샷으로 롤백 — 캔버스를 진입 전 상태 그대로 되돌린다 + canvas.remove(...pitchTexts) + outerLines.forEach((line, idx) => { + const init = initLinesRef.current[idx] + if (!init) { + return + } + init.visible ? showLine(line) : hideLine(line) + line.set({ stroke: init.stroke, strokeWidth: init.strokeWidth, selectable: init.selectable }) + line.attributes = init.attributes + if (pitchTextLineIdsRef.current.has(line.id)) { + addPitchText(line) + } + }) + + // 변별로 설정 진입 시 삭제했던 WALL 폴리곤 원본을 원래 z-순서 그대로 재삽입 + removedWallsRef.current.forEach(({ wall, index }) => { + canvas.insertAt(wall, index) + }) + removedWallsRef.current = [] + + canvas.discardActiveObject() + canvas.renderAll() + return + } + canvas.remove(...pitchTexts) outerLines.forEach((line) => { let stroke, strokeWidth @@ -113,6 +150,8 @@ export function useRoofShapeSetting(id) { line.set({ stroke, strokeWidth, + selectable: false, + visible: true, }) addPitchText(line) @@ -144,10 +183,15 @@ export function useRoofShapeSetting(id) { useEffect(() => { if (shapeNum === 4) { - canvas?.remove(canvas.getObjects().find((obj) => obj.name === POLYGON_TYPE.WALL)) + const wall = canvas.getObjects().find((obj) => obj.name === POLYGON_TYPE.WALL) + if (wall && removedWallsRef.current.length === 0) { + removedWallsRef.current = [{ wall, index: canvas.getObjects().indexOf(wall) }] + } + canvas?.remove(wall) const outerLines = canvas.getObjects().filter((obj) => obj.name === 'outerLine') outerLines.forEach((line) => { showLine(line) + line.set({ selectable: true }) line.bringToFront() }) -- 2.47.2 From 83107a801de30dac12d22c84831f60df5852546b Mon Sep 17 00:00:00 2001 From: "hyojun.choi" Date: Wed, 10 Jun 2026 16:50:46 +0900 Subject: [PATCH 32/60] =?UTF-8?q?=EC=A7=80=EB=B6=95=EB=A9=B4=20=ED=95=A0?= =?UTF-8?q?=EB=8B=B9=20=EB=A1=9C=EC=A7=81=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/hooks/usePolygon.js | 13 ++++++++++++- 1 file changed, 12 insertions(+), 1 deletion(-) diff --git a/src/hooks/usePolygon.js b/src/hooks/usePolygon.js index 729cd8ec..288caf88 100644 --- a/src/hooks/usePolygon.js +++ b/src/hooks/usePolygon.js @@ -1266,6 +1266,13 @@ export const usePolygon = () => { polygonLine.attributes = { ...polygonLine.attributes, isStart: true } }) + // [지붕면 공유변 fix] 보조선(innerLines)도 isStart=true 로 표시해 외곽선과 동일한 distance 페널티(+1000)를 + // 받게 한다. 미설정 시 보조선(빗변)이 페널티 없이 항상 최단으로 선택돼, 면이 외곽 변(예: 직사각형 + // 왼쪽변)을 두고 빗변으로 우회 → 오각형이 생긴다. (isValidPoint 가 type=default 끝점을 막아 면 자체는 안 늘어남) + innerLines.forEach((line) => { + line.attributes = { ...line.attributes, isStart: true } + }) + let allLines = [...polygonLines, ...innerLines] // allLines를 전부 돌면서 교차점이 있는 경우 그 line을 잘라서 allLines에 추가 @@ -2047,7 +2054,11 @@ export const usePolygon = () => { // 현재 남아있는 line들로 그래프 생성 const graph = {} - for (const line of remainingLines.filter((line2) => line2 !== startLine)) { + // [지붕면 공유변 fix] graph 는 startLine 자신만 제외한 전체 allLines 로 구성한다. + // remainingLines(이미 사용된 startLine 이 제거됨)로 만들면, 두 면이 공유하는 외곽 변이 먼저 처리된 + // 면에서 소비되어 다음 면 graph 에서 사라지고 그 면이 빗변으로 우회한다. startLine 소비는 후보 + // 목록(remainingLines)에서만 하고, 경로 탐색용 graph 에는 항상 남겨 공유 변을 재사용하게 한다. + for (const line of allLines.filter((line2) => line2 !== startLine)) { const p1 = line.startPoint const p2 = line.endPoint const key1 = pointToKey(p1) -- 2.47.2 From e3c092a6b4249b16c0e2d1d6ca16ef6170a92733 Mon Sep 17 00:00:00 2001 From: "hyojun.choi" Date: Thu, 11 Jun 2026 13:03:18 +0900 Subject: [PATCH 33/60] =?UTF-8?q?=EC=A0=80=EC=9E=A5=20=ED=9B=84=20?= =?UTF-8?q?=EB=B3=B4=EC=A1=B0=EC=84=A0=EC=9C=BC=EB=A1=9C=20=EB=82=98?= =?UTF-8?q?=EB=89=98=EC=96=B4=EC=A7=80=EC=A7=80=20=EC=95=8A=EB=8A=94=20?= =?UTF-8?q?=ED=98=84=EC=83=81=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/hooks/common/useCanvasConfigInitialize.js | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/src/hooks/common/useCanvasConfigInitialize.js b/src/hooks/common/useCanvasConfigInitialize.js index 62f9574b..efe063b2 100644 --- a/src/hooks/common/useCanvasConfigInitialize.js +++ b/src/hooks/common/useCanvasConfigInitialize.js @@ -242,7 +242,9 @@ export function useCanvasConfigInitialize() { obj.type === 'QLine' && obj.attributes?.type !== 'pitchSizeLine' && obj.attributes?.roofId === roof.id && - innerLineTypes.includes(obj.name), + // [보조선 복원 fix] 저장→불러오기 후 지붕면 할당이 보조선을 쓰려면 auxiliaryLine 도 innerLines 에 + // 재연결해야 한다. 기존엔 SUBLINE(hip/ridge) 만 포함해 보조선이 빠져, 불러온 직후 할당이 실패했다. + (innerLineTypes.includes(obj.name) || obj.name === 'auxiliaryLine'), ) }) } -- 2.47.2 From 7900b2e67b160f124b327494aab3581c81bd1265 Mon Sep 17 00:00:00 2001 From: Jaeyoung Lee Date: Thu, 11 Jun 2026 14:52:19 +0900 Subject: [PATCH 34/60] =?UTF-8?q?fix:=20removePitchText=EA=B0=80=20?= =?UTF-8?q?=EA=B2=BD=EC=82=AC=20=ED=85=8D=EC=8A=A4=ED=8A=B8=EB=A5=BC=20?= =?UTF-8?q?=EC=82=AD=EC=A0=9C=ED=95=98=EC=A7=80=20=EB=AA=BB=ED=95=98?= =?UTF-8?q?=EB=8D=98=20=EB=B2=84=EA=B7=B8=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit [작업내용] : - canvas.remove()에 배열을 통째로 넘겨 Fabric이 무시(silent no-op)하던 것을 spread로 개별 전달하도록 수정 - addPitchText의 중복 방지가 무력화되어 같은 자리에 경사/출폭 텍스트가 겹겹이 쌓이던 문제 해결 Co-Authored-By: Claude Fable 5 --- src/hooks/useLine.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/hooks/useLine.js b/src/hooks/useLine.js index fcca1bbc..b9a3eb05 100644 --- a/src/hooks/useLine.js +++ b/src/hooks/useLine.js @@ -148,7 +148,7 @@ export const useLine = () => { // 기존 pitchText가 있으면 삭제 if (pitchText.length > 0) { - canvas.remove(pitchText) + canvas.remove(...pitchText) } } -- 2.47.2 From 18abbeb902a298898062c20ea3a28fd19f206ec3 Mon Sep 17 00:00:00 2001 From: Jaeyoung Lee Date: Thu, 11 Jun 2026 18:09:31 +0900 Subject: [PATCH 35/60] =?UTF-8?q?fix:=20offset=200=EC=9D=B8=20=EB=B3=80?= =?UTF-8?q?=EC=97=90=EC=84=9C=20=EC=83=89=EC=B9=A0=EB=90=9C=20=EC=99=B8?= =?UTF-8?q?=EB=B2=BD=EC=84=A0=EC=9D=B4=20=EB=B3=B4=EC=A1=B0=EC=84=A0=20?= =?UTF-8?q?=EC=84=A0=ED=83=9D=EC=9D=84=20=EA=B0=80=EB=A1=9C=EC=B1=84?= =?UTF-8?q?=EB=8D=98=20=EB=AC=B8=EC=A0=9C=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit [작업내용] : - 지붕형상 저장 시 외벽선을 bringToFront(맨 앞) 대신 WALL/ROOF 폴리곤 바로 위 인덱스에 삽입 - offset 0이면 보조선이 외벽선과 같은 좌표에 그려지는데, 외벽선이 맨 위에 있으면 클릭을 가로채(selectable:false라도 evented는 유지됨) 보조선 선택이 불가능했음 - z-order를 폴리곤 < 외벽선 < 보조선·텍스트로 유지해 색 표시와 보조선 클릭을 양립 Co-Authored-By: Claude Fable 5 --- src/hooks/roofcover/useRoofShapePassivitySetting.js | 8 +++++++- src/hooks/roofcover/useRoofShapeSetting.js | 8 +++++++- 2 files changed, 14 insertions(+), 2 deletions(-) diff --git a/src/hooks/roofcover/useRoofShapePassivitySetting.js b/src/hooks/roofcover/useRoofShapePassivitySetting.js index 14c648f0..cabcbe90 100644 --- a/src/hooks/roofcover/useRoofShapePassivitySetting.js +++ b/src/hooks/roofcover/useRoofShapePassivitySetting.js @@ -263,7 +263,13 @@ export function useRoofShapePassivitySetting(id) { addPitchTextsByOuterLines() const roof = drawRoofPolygon(wall) addLengthText(roof) - lines.forEach((line) => line.bringToFront()) + + // 외벽선은 WALL/ROOF 폴리곤 바로 위에 배치 — 맨 앞(bringToFront)으로 올리면 + // offset 0인 변에서 같은 좌표에 그려진 보조선의 클릭을 가로채 선택을 막는다 + const polygonTopIdx = canvas + .getObjects() + .reduce((top, obj, idx) => (obj.name === POLYGON_TYPE.WALL || obj.name === POLYGON_TYPE.ROOF ? idx : top), -1) + lines.forEach((line) => canvas.moveTo(line, polygonTopIdx)) canvas.renderAll() } diff --git a/src/hooks/roofcover/useRoofShapeSetting.js b/src/hooks/roofcover/useRoofShapeSetting.js index 58fec3df..be939b1b 100644 --- a/src/hooks/roofcover/useRoofShapeSetting.js +++ b/src/hooks/roofcover/useRoofShapeSetting.js @@ -155,9 +155,15 @@ export function useRoofShapeSetting(id) { }) addPitchText(line) - line.bringToFront() } }) + + // 외벽선은 WALL/ROOF 폴리곤 바로 위에 배치 — 맨 앞(bringToFront)으로 올리면 + // offset 0인 변에서 같은 좌표에 그려진 보조선의 클릭을 가로채 선택을 막는다 + const polygonTopIdx = canvas + .getObjects() + .reduce((top, obj, idx) => (obj.name === POLYGON_TYPE.WALL || obj.name === POLYGON_TYPE.ROOF ? idx : top), -1) + outerLines.forEach((line) => canvas.moveTo(line, polygonTopIdx)) canvas.renderAll() } }, []) -- 2.47.2 From 1bf5f45b635fb436a129008dd986a25303e5f659 Mon Sep 17 00:00:00 2001 From: Jaeyoung Lee Date: Fri, 12 Jun 2026 13:58:08 +0900 Subject: [PATCH 36/60] =?UTF-8?q?refactor:=20=EC=B2=98=EB=A7=88=EB=B3=80?= =?UTF-8?q?=20split=20=EB=A1=9C=EC=A7=81=20splitRoofLinesToInnerLines=20?= =?UTF-8?q?=EA=B3=B5=EC=9A=A9=20=ED=95=A8=EC=88=98=20=EC=B6=94=EC=B6=9C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit [작업내용] : - drawRoofByAttribute 의 인라인 split 블록을 splitRoofLinesToInnerLines 공용 함수로 추출 (변별/용마루 경로 공용) - 분할점 거리순 정렬 버그 수정 (a[1] → a.distance — 분할점 2개 이상에서 정렬이 no-op 이던 문제) - 분할점 끝점 판정을 almostEqual(1e-10) → 끝점과의 유클리드 거리 1px 이내 제외로 변경 (SK 추녀 연장의 부동소수 오차 흡수, 0 길이 조각 생성 방지) Co-Authored-By: Claude Opus 4.8 (1M context) --- src/util/qpolygon-utils.js | 53 ++++++++++++++++++++++++++------------ 1 file changed, 36 insertions(+), 17 deletions(-) diff --git a/src/util/qpolygon-utils.js b/src/util/qpolygon-utils.js index 7a8ddda7..fb23d06c 100644 --- a/src/util/qpolygon-utils.js +++ b/src/util/qpolygon-utils.js @@ -5429,7 +5429,35 @@ export const drawRoofByAttribute = (roofId, canvas, textMode) => { //추가된 하단 지붕 라인 innerLines에 추가. innerLines.push(...downRoofLines) - //지붕선에 따라 라인추가 작업 처리. + //지붕선에 따라 라인추가 작업 처리. (split 공용 함수 — docs §3-1) + splitRoofLinesToInnerLines(roof, innerLines, canvas, textMode) + + //지붕 innerLines에 추가. + roof.innerLines = innerLines + + canvas + .getObjects() + .filter((object) => object.name === 'check') + .forEach((object) => canvas.remove(object)) + canvas.renderAll() +} + +/** + * roof.lines(처마 외곽선)를 innerLine 끝점 기준으로 split 해 편집 가능한 innerLine(hip/roofLine)으로 만든다. + * 변별/용마루 경로 공용. innerLines 에 결과를 in-place push 하고 반환한다. + * (용마루 경로는 추녀 연장 후 호출해야 종점이 처마변 위에 있어 분할점이 잡힌다 — docs §9) + * @param {fabric.Object} roof - 대상 지붕 (roof.lines 사용) + * @param {Array} innerLines - 내부선 배열 (split 결과가 여기에 in-place 추가됨) + * @param {fabric.Canvas} canvas + * @param {string} textMode + * @returns {Array} innerLines + */ +export const splitRoofLinesToInnerLines = (roof, innerLines, canvas, textMode) => { + // 분할점이 처마변 끝점(코너)과 이 거리(px) 이내면 분할에서 제외. + // SK 추녀 연장이 처마 코너에 닿을 때 ray cast 부동소수 오차(관측 0.02px)로 코너와 미세하게 + // 어긋나면, 기존 almostEqual(1e-10)은 "끝점 아님"으로 오판 → 0 길이 조각 생성. END_EPS 로 흡수. + // 변별 경로의 진짜 분할점은 끝점에서 수백 px 떨어져 있어 영향 없음. + const END_EPS = 1.0 const innerLinesPoints = [] innerLines.forEach((line) => { const hasCoord1 = innerLinesPoints.find((p) => p.x === line.x1 && p.y === line.y1) @@ -5464,17 +5492,15 @@ export const drawRoofByAttribute = (roofId, canvas, textMode) => { if (hasOverlapLine) return innerLinesPoints.forEach((point) => { - if ( - isPointOnLineNew(currentLine, point) && - !(almostEqual(currentLine.x1, point.x) && almostEqual(currentLine.y1, point.y)) && - !(almostEqual(currentLine.x2, point.x) && almostEqual(currentLine.y2, point.y)) - ) { - const distance = Math.sqrt((point.x - currentLine.x1) ** 2 + (point.y - currentLine.y1) ** 2) - splitPoint.push({ point, distance }) + const distToStart = Math.hypot(point.x - currentLine.x1, point.y - currentLine.y1) + const distToEnd = Math.hypot(point.x - currentLine.x2, point.y - currentLine.y2) + // 변 위 점이면서 양 끝점(코너)에서 END_EPS 보다 멀 때만 진짜 분할점으로 채택. + if (isPointOnLineNew(currentLine, point) && distToStart > END_EPS && distToEnd > END_EPS) { + splitPoint.push({ point, distance: distToStart }) } }) if (splitPoint.length > 0) { - splitPoint.sort((a, b) => a[1] - b[1]) + splitPoint.sort((a, b) => a.distance - b.distance) let startPoint = { x: currentLine.x1, y: currentLine.y1 } for (let i = 0; i < splitPoint.length; i++) { const point = splitPoint[i].point @@ -5495,14 +5521,7 @@ export const drawRoofByAttribute = (roofId, canvas, textMode) => { } }) - //지붕 innerLines에 추가. - roof.innerLines = innerLines - - canvas - .getObjects() - .filter((object) => object.name === 'check') - .forEach((object) => canvas.remove(object)) - canvas.renderAll() + return innerLines } /** -- 2.47.2 From 87545e5df17ca5188d15768dec47079917584daf Mon Sep 17 00:00:00 2001 From: Jaeyoung Lee Date: Fri, 12 Jun 2026 13:58:15 +0900 Subject: [PATCH 37/60] =?UTF-8?q?feat:=20=EC=9A=A9=EB=A7=88=EB=A3=A8(skele?= =?UTF-8?q?ton)=20=EC=A7=80=EB=B6=95=20=EC=99=B8=EA=B3=BD=20=EC=B2=98?= =?UTF-8?q?=EB=A7=88=EC=84=A0=20=ED=8E=B8=EC=A7=91=20=EA=B8=B0=EB=8A=A5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit [작업내용] : - 추녀 연장(drawBaselineToRooflineHelpers) 후 splitRoofLinesToInnerLines 호출 → 처마변을 편집 가능한 innerLine(hip/roofLine)으로 등록 - 처마선 innerLine 이 외곽선을 전담하므로 roof 원본 외곽(stroke) 숨김 처리 - 설계 문서 정정: 대상 브랜치 roofline-edit, §4 리스크1 검증 완료, §9 측정 시점 정정 (추녀 연장 후 종점이 처마변 위에 정합 → split 은 연장 다음에 호출) Co-Authored-By: Claude Opus 4.8 (1M context) --- ...026-06-01-skeleton-roofline-edit-design.md | 46 +++++++++++++++++-- src/util/skeleton-utils.js | 9 +++- 2 files changed, 50 insertions(+), 5 deletions(-) diff --git a/docs/plans/2026-06-01-skeleton-roofline-edit-design.md b/docs/plans/2026-06-01-skeleton-roofline-edit-design.md index 82414cae..9e97a2d2 100644 --- a/docs/plans/2026-06-01-skeleton-roofline-edit-design.md +++ b/docs/plans/2026-06-01-skeleton-roofline-edit-design.md @@ -1,7 +1,7 @@ # 용마루(skeleton) 경로 외곽 지붕선 편집 기능 — 설계 - 작성일: 2026-06-01 -- 대상 브랜치: `feature/redo-undo` +- 대상 브랜치: `feature/roofline-edit` - 관련 파일: `src/util/skeleton-utils.js`, `src/util/qpolygon-utils.js`, `src/hooks/useContextMenu.js` ## 1. 배경 / 문제 @@ -66,13 +66,13 @@ splitRoofLinesToInnerLines(roof, canvas, textMode) → QLine[] - **`drawRoofByAttribute` 교체**: 인라인 블록을 함수 호출로 교체. 순수 추출이므로 변별 경로 동작 불변. ### 3-2. skeleton 경로 변경 -- `createInnerLinesFromSkeleton` 의 `isOuterEdge` 숨김 처리(`:1636-1643` 의 `visible:false`, `:1624` 의 `selectable:false`) 제거. -- 내부선(용마루/추녀) 생성 완료 후 `splitRoofLinesToInnerLines(roof, canvas, textMode)` 호출 → 외곽 지붕선이 변별 경로와 동일한 형태로 `roof.innerLines` 에 합류. +- `createInnerLinesFromSkeleton` 의 `isOuterEdge`(SK eaves) 숨김 처리는 **유지**. (§9 정정: SK eaves 라인은 baseLine 기준이라, 노출하면 처마변 split 결과와 offset 만큼 어긋나 이중 표시됨. 처마 외곽선은 아래 split 으로 별도 innerLine 화.) +- **추녀 연장(`drawBaselineToRooflineHelpers`, `:1285`) 완료 후** `splitRoofLinesToInnerLines(roof, canvas, textMode)` 호출 → 추녀 종점이 처마변 위에 있는 상태라 split 정합 성립, 외곽 지붕선이 변별 경로와 동일한 형태로 `roof.innerLines` 에 합류. (⚠️ 연장 **전**에 호출하면 종점이 baseLine 위라 분할점을 못 잡음 — §9 참조) - `drawHelpLine` 재호출 시마다 자동 재실행되어 편집 후 재그리기에도 외곽 지붕선 유지. ## 4. 리스크 & 구현 전 검증 -1. **SK 추녀 끝점 ↔ `roof.lines` 정합성**: split 은 "innerLine 끝점이 `roof.lines` 위에 있다"(`isPointOnLineNew`, tol 0.1)를 전제로 한다. SK 가 만든 hip 끝점이 처마 외곽선 위에 실제로 떨어지는지 **실측 확인 필요**. 안 맞으면 split 이 헛돌아 외곽선이 통째로 roofLine 1개로만 그려진다. → 구현 첫 단계에서 검증. +1. **SK 추녀 끝점 ↔ `roof.lines` 정합성** — ✅ **검증 완료 (2026-06-12, §9 참조)**: **연장 후 정합 OK**. SK 골조는 `wall.baseLines` 기준이라 추녀 끝점이 처음엔 baseLine 코너에 있으나(연장 전 `onRoof=false`), `drawBaselineToRooflineHelpers`(`:1285`)가 추녀를 처마변까지 **연장**해 종점을 처마변 위로 옮긴다(연장 후 4코너 전부 `diffRoof=0`, `onRoof=true`). → **split 을 연장 다음에 호출하면 정합 자동 성립**. (초기 측정이 연장 전에 찍혀 "구조적 불일치"로 오판했음 — §9-0 참조) 2. **순환 import**: `qpolygon-utils ↔ skeleton-utils` 상호 import 여부 점검. 순환이면 split 함수를 제3의 모듈(예: canvas-util)로 추출하거나 의존성 정리. 3. **기존 `isOuterEdge` roofLine 제거 영향**: 숨긴 라인을 참조하는 곳(치수 계산, 플랜 저장/복원 등)이 있는지 확인 후 제거. @@ -159,3 +159,41 @@ opts.degreeOf?: (parentLine) => number // 부모 외곽변 → 경사각(도). ### 8-5. 리스크 - **분할점 부재**: 한쪽흐름은 내부 보조선이 없어 외곽변이 1:1 통짜 라인으로 그려진다 → 부모 변 type 매칭이 단순(분할 시에도 조각은 부모 type 상속). split 의 끝점 정합성(`isPointOnLineNew`, tol 0.1) 자체는 분할점이 없으면 무의미하므로 `else` 통짜 경로(`:5493`)로 안전하게 떨어진다. - **`isGable` 판정원**: `drawShedRoof` 의 `gables` 분류(`:1653`)와 동일 기준(`attributes.type === GABLE`)을 써야 보정 대상이 일치. + +--- + +## 9. SK 끝점 ↔ 처마변 정합 — 실측 결과 (2026-06-12, 측정 시점 정정) + +§4 리스크1 을 임시 계측(`skeleton-utils.js` 의 `logSkRoofLineAlignment`)으로 추녀 연장 **전/후** 2단계 실측한 결과. + +### 9-0. ⚠️ 측정 시점 정정 (중요) +1차 측정은 `createInnerLinesFromSkeleton` **직후**(`:1279`, 추녀 연장 전)에 찍혀, 추녀 종점이 baseLine 코너에 있는 상태(`onRoof=false`, `perpRoof=offset`)만 보고 **"구조적 불일치 → 투영 보정 필요"로 오판**했다. 실제로는 그 직후 `drawBaselineToRooflineHelpers`(`:1285`)가 추녀를 처마변까지 연장한다. 연장 **후**(`afterExt`) 측정에서 정합이 성립함을 확인 → 아래는 정정된 결론. **(투영 보정·SK 입력 교체·연장 보정 설계는 모두 폐기. 불필요했음.)** + +### 9-1. 실측 결과 (용마루 우진각, 변별 offset 50px) +처마변 x∈[357.5,1147.6]·y∈[115.2,636.7] vs baseLine x∈[407.5,1097.6]·y∈[165.2,586.7] (전 변 50px 안쪽). + +| 추녀 끝점 | beforeExt (`:1279`, 연장 전) | afterExt (`:1285`, 연장 후) | +|---|---|---| +| **바깥** 끝점(4코너) | baseLine 코너 — `onRoof=false`, `diffRoof=22.4`, `onBase=true` | **처마 코너로 연장 — `onRoof=true`, `diffRoof=0`** | +| 안쪽·용마루(내부점) | `onRoof=false` (260.7) | `onRoof=false` (260.7) — 내부점이라 정상 | + +예: 추녀 바깥 끝 `(407.5,165.2)`(baseLine) → `(357.5,115.2)`(처마 코너) 로 연장. 4코너 전부 `diffRoof=0`, `onRoof=true`. + +### 9-2. 추녀 연장 메커니즘 (이미 구현됨) +`drawBaselineToRooflineHelpers`(`:685`, 플래그 `DRAW_BASELINE_TO_ROOFLINE_HELPER=true`, `:25`): +- `hipLines` 추출(`:734`) → baseLine 코너에 닿는 hip 만(`CORNER_EPS`, `:773`) → 추녀 진행 방향으로 ray cast(`rayHitSegment`, `:693`) → 처마변 교점까지 hip 좌표를 **연장**(`:861-865`). +- `actualSize` 비율 보정(`:871`) + `__extended`/`attributes.extended` 플래그로 save/load 영속(`:879`). +- 즉 "추녀는 roof.lines 까지 연결되는 선" 이 이미 코드에 있다. SK 골조 자체는 baseLine 기준(동기화 유지) + 추녀 종점만 처마변까지 연장. + +### 9-3. 결론 — split 은 추녀 연장 **다음**에 호출 +- 추녀 종점은 연장 후 처마변 위(`onRoof=true`, `diffRoof=0`)에 정확히 앉으므로, **split/외곽선 innerLine화의 정합은 깨지지 않는다.** +- **유일한 요건**: `splitRoofLinesToInnerLines` 호출을 `createInnerLinesFromSkeleton` 직후가 아니라 **`drawBaselineToRooflineHelpers`(`:1285`) 다음에** 둘 것. §3-2 가 호출 위치를 연장 전으로 잡은 것이 유일한 함정이었음. +- **단순 우진각**: 추녀가 처마 **코너**(변 끝점)에 닿으므로 split 분할점 없음 → 처마 4변 통짜 innerLine(정상). +- **복합 형상**(L자·凸자): 추녀가 처마변 **중간**에 닿으면 그 점에서 분할. 연장 후 종점이 처마변 위이므로 분할점으로 정상 인식. + +### 9-4. 검증 기준 (구현 시) +- split 을 연장 후 호출 → 처마 4변이 innerLine 으로 등록, select+우클릭 편집 가능. +- 복합 형상: 처마변 중간 접점에서 분할, 각 조각 독립 편집. +- offset=0 / offset≠0 양쪽 동작. +- 마루이동/벽이동 후 재그리기에도 유지(연장은 `__extended`/`attributes.extended` 로 영속). +- **임시 계측(`logSkRoofLineAlignment` 정의 + 호출부 3곳 `:1279`/`:1285`/`:1413`) 제거** 후 커밋. diff --git a/src/util/skeleton-utils.js b/src/util/skeleton-utils.js index e283c472..66ea9afb 100644 --- a/src/util/skeleton-utils.js +++ b/src/util/skeleton-utils.js @@ -1,6 +1,6 @@ import { LINE_TYPE, POLYGON_TYPE } from '@/common/common' import { SkeletonBuilder } from '@/lib/skeletons' -import { calcLineActualSize, calcLineActualSize2, calcLinePlaneSize, toGeoJSON } from '@/util/qpolygon-utils' +import { calcLineActualSize, calcLineActualSize2, calcLinePlaneSize, splitRoofLinesToInnerLines, toGeoJSON } from '@/util/qpolygon-utils' import { QLine } from '@/components/fabric/QLine' import { getDegreeByChon } from '@/util/canvas-util' import Big from 'big.js' @@ -1283,6 +1283,13 @@ export const skeletonBuilder = (roofId, canvas, textMode) => { // gap 을 시각화. innerLines 와 별개 collection. 기존 working code (processInBoth 등) 무영향. drawBaselineToRooflineHelpers(roof, canvas, changRoofLinePoints, roofLinePoints) + // [docs §9-3] 추녀 연장 후 처마변(roof.lines)을 split → 편집 가능한 innerLine(hip/roofLine)으로 등록. + // 단순 우진각: 추녀가 처마 코너(변 끝점)에 닿아 분할점 없음 → 처마 4변 통짜. 복합 형상: 중간 접점에서 분할. + splitRoofLinesToInnerLines(roof, roof.innerLines, canvas, textMode) + // 처마선 innerLine 이 외곽선을 전담하므로 roof 원본 외곽(stroke)은 숨긴다. + // (안 숨기면 처마선 삭제 시 roof 의 black stroke 가 노출됨 — usePolygon.js 의 roof 기본 stroke:'black') + roof.set({ stroke: 'transparent' }) + // 캔버스에 스켈레톤 상태 저장 if (!canvas.skeletonStates) { canvas.skeletonStates = {} -- 2.47.2 From 53ecbcca0f7d6f5b0db47a98902dadc7c3dc86c4 Mon Sep 17 00:00:00 2001 From: Jaeyoung Lee Date: Fri, 12 Jun 2026 14:45:12 +0900 Subject: [PATCH 38/60] =?UTF-8?q?fix:=20=EC=B2=98=EB=A7=88=EC=84=A0=20spli?= =?UTF-8?q?t=20=EC=8B=9C=20roof=20=EC=9B=90=EB=B3=B8=20=EC=99=B8=EA=B3=BD?= =?UTF-8?q?=EC=84=A0=20=EC=88=A8=EA=B9=80=EC=9D=84=20=EA=B3=B5=EC=9A=A9=20?= =?UTF-8?q?=ED=95=A8=EC=88=98=EB=A1=9C=20=ED=86=B5=ED=95=A9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit [작업내용] : - splitRoofLinesToInnerLines 가 처마변 innerLine화 후 roof 원본 외곽(stroke)을 transparent 로 숨기도록 통합 - 변별/용마루 양 경로 공통 적용 — 처마선 삭제 시 roof 기본 stroke:'black' 이 노출되던 문제 해결 - 용마루 호출부(skeletonBuilder)의 개별 roof.set 제거 (split 함수가 담당) Co-Authored-By: Claude Opus 4.8 (1M context) --- src/util/qpolygon-utils.js | 7 ++++++- src/util/skeleton-utils.js | 3 --- 2 files changed, 6 insertions(+), 4 deletions(-) diff --git a/src/util/qpolygon-utils.js b/src/util/qpolygon-utils.js index fb23d06c..03d72c6a 100644 --- a/src/util/qpolygon-utils.js +++ b/src/util/qpolygon-utils.js @@ -5443,7 +5443,8 @@ export const drawRoofByAttribute = (roofId, canvas, textMode) => { } /** - * roof.lines(처마 외곽선)를 innerLine 끝점 기준으로 split 해 편집 가능한 innerLine(hip/roofLine)으로 만든다. + * roof.lines(처마 외곽선)를 innerLine 끝점 기준으로 split 해 편집 가능한 innerLine(hip/roofLine)으로 만들고, + * 처마선 innerLine 이 외곽선을 전담하므로 roof 원본 외곽선(stroke)은 숨긴다(transparent). * 변별/용마루 경로 공용. innerLines 에 결과를 in-place push 하고 반환한다. * (용마루 경로는 추녀 연장 후 호출해야 종점이 처마변 위에 있어 분할점이 잡힌다 — docs §9) * @param {fabric.Object} roof - 대상 지붕 (roof.lines 사용) @@ -5521,6 +5522,10 @@ export const splitRoofLinesToInnerLines = (roof, innerLines, canvas, textMode) = } }) + // 처마선 innerLine 이 외곽선을 전담하므로 roof 원본 외곽(stroke)은 숨긴다. + // (안 숨기면 처마선 삭제 시 roof 의 기본 stroke:'black'(usePolygon.js) 이 노출됨) + roof.set({ stroke: 'transparent' }) + return innerLines } diff --git a/src/util/skeleton-utils.js b/src/util/skeleton-utils.js index 66ea9afb..06ad38ff 100644 --- a/src/util/skeleton-utils.js +++ b/src/util/skeleton-utils.js @@ -1286,9 +1286,6 @@ export const skeletonBuilder = (roofId, canvas, textMode) => { // [docs §9-3] 추녀 연장 후 처마변(roof.lines)을 split → 편집 가능한 innerLine(hip/roofLine)으로 등록. // 단순 우진각: 추녀가 처마 코너(변 끝점)에 닿아 분할점 없음 → 처마 4변 통짜. 복합 형상: 중간 접점에서 분할. splitRoofLinesToInnerLines(roof, roof.innerLines, canvas, textMode) - // 처마선 innerLine 이 외곽선을 전담하므로 roof 원본 외곽(stroke)은 숨긴다. - // (안 숨기면 처마선 삭제 시 roof 의 black stroke 가 노출됨 — usePolygon.js 의 roof 기본 stroke:'black') - roof.set({ stroke: 'transparent' }) // 캔버스에 스켈레톤 상태 저장 if (!canvas.skeletonStates) { -- 2.47.2 From fd83fa3aa010d703ba7620ac11dd08f87e9fc67c Mon Sep 17 00:00:00 2001 From: Jaeyoung Lee Date: Fri, 12 Jun 2026 14:57:38 +0900 Subject: [PATCH 39/60] =?UTF-8?q?fix:=20=EB=B0=95=EA=B3=B5(gable)=20?= =?UTF-8?q?=EC=A7=80=EB=B6=95=EB=8F=84=20=EC=B2=98=EB=A7=88=EC=84=A0=20?= =?UTF-8?q?=EC=99=B8=EA=B3=BD=20=EC=A0=84=EB=8B=B4=20=EC=8B=9C=20roof=20?= =?UTF-8?q?=EC=9B=90=EB=B3=B8=20=EC=99=B8=EA=B3=BD=EC=84=A0=20=EC=88=A8?= =?UTF-8?q?=EA=B9=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit [작업내용] : - drawGableRoof 가 처마선 innerLine 생성(roof.innerLines.push) 후 roof 원본 외곽(stroke)을 transparent 로 숨김 - 박공은 split 공용 함수를 거치지 않으므로 drawGableRoof 내에서 직접 처리 - 처마선 삭제 시 roof 기본 stroke:'black' 이 노출되던 문제 해결 (변별/용마루와 동일 동작) Co-Authored-By: Claude Opus 4.8 (1M context) --- src/util/qpolygon-utils.js | 3 +++ 1 file changed, 3 insertions(+) diff --git a/src/util/qpolygon-utils.js b/src/util/qpolygon-utils.js index 03d72c6a..40f7e695 100644 --- a/src/util/qpolygon-utils.js +++ b/src/util/qpolygon-utils.js @@ -1628,6 +1628,9 @@ export const drawGableRoof = (roofId, canvas, textMode) => { drawRoofPlane(backward) }) roof.innerLines.push(...ridgeLines, ...innerLines) + // 처마선 innerLine 이 외곽선을 전담하므로 roof 원본 외곽(stroke)은 숨긴다. + // (박공은 split 공용 함수를 안 거치므로 여기서 직접 처리 — usePolygon.js 의 roof 기본 stroke:'black') + roof.set({ stroke: 'transparent' }) canvas .getObjects() .filter((obj) => obj.name === 'check') -- 2.47.2 From 96585240ef49eca350cb2a70deab4d6a9a540c15 Mon Sep 17 00:00:00 2001 From: "hyojun.choi" Date: Fri, 12 Jun 2026 18:29:17 +0900 Subject: [PATCH 40/60] =?UTF-8?q?=EC=98=A4=ED=94=84=EC=85=8B=20=EC=84=A4?= =?UTF-8?q?=EC=A0=95=EC=97=90=20=EC=8B=9C=20=EA=B8=B8=EC=9D=B4=20=EB=B0=98?= =?UTF-8?q?=EC=98=81=20=EC=A0=9C=EB=8C=80=EB=A1=9C=20=EC=95=88=EB=90=98?= =?UTF-8?q?=EB=8A=94=20=ED=98=84=EC=83=81=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/hooks/roofcover/useWallLineOffsetSetting.js | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/src/hooks/roofcover/useWallLineOffsetSetting.js b/src/hooks/roofcover/useWallLineOffsetSetting.js index e9e22ea8..bfe2c4b0 100644 --- a/src/hooks/roofcover/useWallLineOffsetSetting.js +++ b/src/hooks/roofcover/useWallLineOffsetSetting.js @@ -516,6 +516,14 @@ export function useWallLineOffsetSetting(id) { } } + // 좌표가 변경된 라인의 planeSize/actualSize 재계산 — 길이 라벨은 attributes.planeSize 저장값을 표시하므로 + // 재계산 없이 fire('modified') 만 하면 이전 길이가 그대로 남는다. + ;[currentLine, prevLine, nextLine].forEach((line) => { + if (!line?.attributes) return + const newSize = calcLinePlaneSize({ x1: line.x1, y1: line.y1, x2: line.x2, y2: line.y2 }) + line.attributes = { ...line.attributes, planeSize: newSize, actualSize: newSize } + }) + const newOuterLines = canvas.getObjects().filter((obj) => obj.name === 'outerLine') newOuterLines.sort((a, b) => a.idx - b.idx) newOuterLines.forEach((line, idx) => { -- 2.47.2 From 0944aa3a8c8d645782dc2ccaca1c814cc1890344 Mon Sep 17 00:00:00 2001 From: Jaeyoung Lee Date: Mon, 15 Jun 2026 13:09:56 +0900 Subject: [PATCH 41/60] =?UTF-8?q?fix:=20ErrorBoundary=20fallback=20?= =?UTF-8?q?=EB=A0=8C=EB=8D=94=20=EC=8B=9C=20errorInfo=20null=20=ED=81=AC?= =?UTF-8?q?=EB=9E=98=EC=8B=9C=20=EB=B0=A9=EC=A7=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit [작업내용] : - getDerivedStateFromError 가 errorInfo 를 채우지 않아 첫 fallback 렌더에서 this.state.errorInfo 가 null - componentStack 접근에 옵셔널 체이닝(?.) 추가 — componentDidCatch 의 setState 후 정상 표시 - ErrorBoundary 가 자기 자신을 크래시시켜 원본 에러를 가리던 이중 에러 해소 Co-Authored-By: Claude Opus 4.8 (1M context) --- src/components/common/ErrorBoundary.jsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/components/common/ErrorBoundary.jsx b/src/components/common/ErrorBoundary.jsx index 677a67fa..dad497a0 100644 --- a/src/components/common/ErrorBoundary.jsx +++ b/src/components/common/ErrorBoundary.jsx @@ -40,7 +40,7 @@ class ErrorBoundary extends React.Component {

Timestamp: {new Date().toISOString()}

Error: {this.state.error && this.state.error.toString()}

Stack Trace:

-
{this.state.errorInfo.componentStack}
+
{this.state.errorInfo?.componentStack}