From e74604d3f9b3a361a56e7e72c3b433bf03bd485d Mon Sep 17 00:00:00 2001 From: ysCha Date: Mon, 18 May 2026 16:19:37 +0900 Subject: [PATCH] =?UTF-8?q?[2240]=20=EC=86=90=EC=83=81=20plan=20=EC=A2=8C?= =?UTF-8?q?=ED=91=9C=20=EA=B0=80=EB=93=9C/=EB=B3=B5=EC=9B=90=20=E2=80=94?= =?UTF-8?q?=20=EB=A1=9C=EB=93=9C=20=EA=B0=80=EB=93=9C+=EC=A0=80=EC=9E=A5?= =?UTF-8?q?=20=EC=B0=A8=EB=8B=A8+=EB=B3=B5=EC=9B=90=20=EC=9E=90=EB=8F=99?= =?UTF-8?q?=ED=98=B8=EC=B6=9C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/components/fabric/QPolygon.js | 28 ++++ src/components/floor-plan/CanvasFrame.jsx | 7 + src/hooks/module/useModule.js | 164 ++++++++++++++++++++++ src/hooks/usePlan.js | 39 +++++ 4 files changed, 238 insertions(+) diff --git a/src/components/fabric/QPolygon.js b/src/components/fabric/QPolygon.js index d264a2fe..44dfea52 100644 --- a/src/components/fabric/QPolygon.js +++ b/src/components/fabric/QPolygon.js @@ -7,6 +7,7 @@ import * as turf from '@turf/turf' import { LINE_TYPE, POLYGON_TYPE } from '@/common/common' import Big from 'big.js' import { drawSkeletonRidgeRoof, drawSkeletonRidgeRoofFromBaseLines, verifyMoveBoundary } from '@/util/skeleton-utils' +import { logger } from '@/util/logger' // ======================================================================== // [DEBUG] 캔버스 라인 라벨 — 로컬 전용 (NEXT_PUBLIC_RUN_MODE === 'local') @@ -134,6 +135,33 @@ export const QPolygon = fabric.util.createClass(fabric.Polygon, { points = snapNearAxisEdges(points, 2) } + // [PLAN-CORRUPT-COORD 2026-05-18] 손상 plan 로드 가드 — NaN 좌표가 JSON.stringify 로 null 직렬화되어 + // DB 에 박힌 케이스(예: S241X135260518004 의 module/lines 60+ 좌표 null) 에서 + // toFixed throw 로 페이지 전체 크래시되는 문제 차단. null/NaN/non-finite 좌표는 0 으로 치환 + + // __corrupted 플래그 설정 → useModuleBasicSetting 의 0-dim batch object skip 가드와 연쇄해 cascade 차단. + let __corruptedCoord = false + if (Array.isArray(points)) { + points.forEach((point) => { + if (!point) return + if (point.x == null || !Number.isFinite(Number(point.x))) { + point.x = 0 + __corruptedCoord = true + } + if (point.y == null || !Number.isFinite(Number(point.y))) { + point.y = 0 + __corruptedCoord = true + } + }) + } + if (__corruptedCoord) { + this.__corrupted = true + logger.warn('[PLAN-CORRUPT-COORD 2026-05-18] corrupted polygon coords substituted with 0', { + id: options.id, + name: options.name, + parentId: options.parentId, + }) + } + // 소수점 전부 제거 points.forEach((point) => { point.x = Number(point.x.toFixed(this.toFixed)) diff --git a/src/components/floor-plan/CanvasFrame.jsx b/src/components/floor-plan/CanvasFrame.jsx index c195a39a..3ce710a6 100644 --- a/src/components/floor-plan/CanvasFrame.jsx +++ b/src/components/floor-plan/CanvasFrame.jsx @@ -8,6 +8,7 @@ import QContextMenu from '@/components/common/context-menu/QContextMenu' import PanelBatchStatistics from '@/components/floor-plan/modal/panelBatch/PanelBatchStatistics' import ImgLoad from '@/components/floor-plan/modal/ImgLoad' import { useCanvas } from '@/hooks/useCanvas' +import { useModule } from '@/hooks/module/useModule' import { usePlan } from '@/hooks/usePlan' import { useContextMenu } from '@/hooks/useContextMenu' import { useCanvasConfigInitialize } from '@/hooks/common/useCanvasConfigInitialize' @@ -60,6 +61,7 @@ export default function CanvasFrame() { const { basicSetting, fetchBasicSettings } = useCanvasSetting() const { selectedMenu, setSelectedMenu } = useCanvasMenu() const { initEvent } = useEvent() + const { restoreCorruptedModules } = useModule() const canvasSetting = useRecoilValue(canvasSettingState) const loadCanvas = () => { if (!canvas) return @@ -109,6 +111,11 @@ export default function CanvasFrame() { }) initEvent() + + // [PLAN-CORRUPT-RESTORE 2026-05-18] 손상 module 복원 — QPolygon 로드 가드가 + // __corrupted 마크한 module 좌표를 같은 surface 의 정상 module 패턴으로 추론 후 빨강 marking. + // 정상 plan 에는 영향 0 (__corrupted 없으면 early return). + restoreCorruptedModules() }) } else { setSelectedMenu(null) diff --git a/src/hooks/module/useModule.js b/src/hooks/module/useModule.js index d4a0e7de..061e1c42 100644 --- a/src/hooks/module/useModule.js +++ b/src/hooks/module/useModule.js @@ -12,6 +12,24 @@ import { selectedModuleState } from '@/store/selectedModuleOptions' import { useCircuitTrestle } from '../useCirCuitTrestle' import { useTrestle } from './useTrestle' import { useTurf } from '@/hooks/common/useTurf' +import { logger } from '@/util/logger' + +// [MODULE-NAN-DIAG 2026-05-18] NaN 좌표가 어디서 module 로 진입하는지 추적용. +// S241X135260518004 처럼 plan 에 좌표 null 박힌 케이스 재발 시 발견 즉시 stack trace 와 입력값 기록. +// production 빌드에선 logger 가 DCE 로 제거됨 → 개발/디버깅 환경 한정. +function __nanDiag(tag, payload) { + const flat = {} + let bad = false + for (const [k, v] of Object.entries(payload)) { + if (v == null || (typeof v === 'number' && !Number.isFinite(v))) { + bad = true + } + flat[k] = v + } + if (bad) { + logger.warn(`[MODULE-NAN-DIAG 2026-05-18] ${tag}`, flat) + } +} export const MODULE_REMOVE_TYPE = { LEFT: 'left', @@ -47,6 +65,17 @@ export function useModule() { // clone() 대신 직접 새 QPolygon을 생성하여 Maximum call stack 방지 const createModuleCopy = (module, newLeft, newTop, overrides = {}) => { + // [MODULE-NAN-DIAG 2026-05-18] createModuleCopy 진입 시 입력 검증. + // 본 함수가 cascade NaN 의 가장 큰 진입점: newLeft/newTop 중 하나라도 NaN 이면 + // deltaX/Y → newPoints 전부 NaN → 저장 시 null 직렬화. + __nanDiag('createModuleCopy input', { + moduleId: module?.id, + moduleLeft: module?.left, + moduleTop: module?.top, + newLeft, + newTop, + pointsLen: module?.points?.length, + }) const deltaX = newLeft - module.left const deltaY = newTop - module.top const newPoints = module.points.map((p) => ({ @@ -1024,6 +1053,15 @@ export function useModule() { } const getPosotion = (target, direction, length, hasMargin = false) => { + // [MODULE-NAN-DIAG 2026-05-18] length 가 '' 또는 non-number, target.top/left 가 null 이면 NaN 반환 → createModuleCopy cascade + __nanDiag('getPosotion input', { + targetId: target?.id, + targetTop: target?.top, + targetLeft: target?.left, + direction, + length, + lengthType: typeof length, + }) let top = target.top let left = target.left @@ -1040,6 +1078,7 @@ export function useModule() { left = Number(target.left) + Number(length) / 10 left = hasMargin ? left + Number(target.width) : left } + __nanDiag('getPosotion output', { targetId: target?.id, direction, length, top, left }) return { top, left } } @@ -1091,6 +1130,130 @@ export function useModule() { canvas.renderAll() } + /** + * [PLAN-CORRUPT-RESTORE 2026-05-18] 손상 module 좌표 복원. + * + * QPolygon 로드 가드가 좌표 0 치환 + __corrupted 플래그 단 module 들을 대상으로, + * 같은 surface 의 정상 module 패턴(width / top row)에서 잠정 좌표 추론 후 + * 빨강 stroke 으로 marking → 사용자가 화면에서 즉시 식별하고 위치 조정/삭제 결정. + * + * 그림의 목적(지붕→모듈→견적→사진)을 보호: 자동 제거 대신 사용자에게 데이터를 돌려준다. + * + * @returns {{ restored: number, failed: number }} + */ + const restoreCorruptedModules = () => { + if (!canvas) return { restored: 0, failed: 0 } + + const corrupted = canvas.getObjects().filter((obj) => obj.__corrupted && obj.name === POLYGON_TYPE.MODULE) + if (corrupted.length === 0) return { restored: 0, failed: 0 } + + let restored = 0 + let failed = 0 + + corrupted.forEach((target) => { + const siblings = canvas + .getObjects() + .filter( + (obj) => + obj.name === POLYGON_TYPE.MODULE && + obj.surfaceId === target.surfaceId && + !obj.__corrupted && + Number.isFinite(obj.width) && + obj.width > 0 && + Number.isFinite(obj.left), + ) + + if (siblings.length === 0) { + logger.warn('[PLAN-CORRUPT-RESTORE 2026-05-18] no sibling — cannot restore', { + id: target.id, + surfaceId: target.surfaceId, + top: target.top, + height: target.height, + }) + failed++ + return + } + + // 정상 module 의 width 차용 (같은 moduleInfo 면 동일) + const refWidth = siblings[0].width + const refHeight = Number.isFinite(target.height) && target.height > 0 ? target.height : siblings[0].height + const top = Number.isFinite(target.top) ? target.top : siblings[0].top + + // 같은 row 매칭 (top 차이가 높이의 절반 미만) + const sameRow = siblings.filter((s) => Math.abs(s.top - top) < refHeight / 2) + + let left + if (sameRow.length > 0) { + // 같은 row 의 가장 오른쪽 module 옆 (잠정 — 사용자가 드래그로 조정) + const rightmost = sameRow.reduce((max, s) => (s.left > max.left ? s : max), sameRow[0]) + left = rightmost.left + rightmost.width + 10 + } else { + // row 매칭 실패 — 가장 가까운 top 의 module left 차용 + const closest = siblings.reduce((nearest, s) => (Math.abs(s.top - top) < Math.abs(nearest.top - top) ? s : nearest), siblings[0]) + left = closest.left + } + + const points = [ + { x: left, y: top }, + { x: left + refWidth, y: top }, + { x: left + refWidth, y: top + refHeight }, + { x: left, y: top + refHeight }, + ] + + // 새 QPolygon 으로 교체 (initialize 시 lines 재생성 + __corrupted 해제). + // id 유지로 surface.modules 등 외부 참조 호환. + const restoredModule = new QPolygon(points, { + fill: target.fill, + stroke: 'red', + strokeWidth: 2, + opacity: target.opacity, + selectable: true, + lockMovementX: false, + lockMovementY: false, + parentId: target.parentId, + surfaceId: target.surfaceId, + moduleInfo: target.moduleInfo, + direction: target.direction, + name: target.name, + left, + top, + width: refWidth, + height: refHeight, + toFixed: 2, + sort: false, + id: target.id, + }) + restoredModule.__restored = true + + canvas.remove(target) + canvas.add(restoredModule) + + logger.warn('[PLAN-CORRUPT-RESTORE 2026-05-18] restored', { + id: target.id, + surfaceId: target.surfaceId, + left, + top, + refWidth, + refHeight, + siblingCount: siblings.length, + sameRowCount: sameRow.length, + }) + restored++ + }) + + canvas.requestRenderAll() + + if (restored > 0 || failed > 0) { + swalFire({ + icon: 'info', + title: '破損モジュール復元', + text: `復元: ${restored} 件 / 復元不可: ${failed} 件\n赤い枠線で表示されたモジュールを確認し、位置調整または削除後に保存してください。`, + }) + } + + return { restored, failed } + } + return { moduleMove, moduleMultiMove, @@ -1106,5 +1269,6 @@ export function useModule() { moduleRoofRemove, alignModule, recalculateAllModulesCoords, + restoreCorruptedModules, } } diff --git a/src/hooks/usePlan.js b/src/hooks/usePlan.js index d0b4b05d..2c5d548c 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 { logger } from '@/util/logger' // putCanvasStatus 의 in-flight 요청 — module-scope 로 두어 usePlan() 인스턴스 간 공유. // 큰 fabric JSON 의 동시 PUT 이 SQL Server 데드락 victim 을 양산해 온 이력 때문에 직전 호출은 abort. @@ -177,6 +178,44 @@ export function usePlan(params = {}) { * @param {boolean} saveAlert - 저장 완료 알림 표시 여부 */ const saveCanvas = async (saveAlert = true) => { + // [PLAN-CORRUPT-COORD-SAVE-GUARD 2026-05-18] 손상 plan(예: S241X135260518004) 저장 차단형 가드. + // 그림의 목적: 지붕→모듈→견적→사진. 깨진 모듈도 사용자가 그린 데이터 → 자동 제거 금지. + // 손상 객체 발견 시 저장 차단 + swal 안내 → 사용자가 화면에서 복원/재배치/명시적 삭제 결정. + // 향후 [PLAN-CORRUPT-RESTORE] 작업으로 메타 데이터(top/height/surfaceId)에서 좌표 복원 예정. + const corruptedObjs = canvas.getObjects().filter((obj) => obj.__corrupted) + if (corruptedObjs.length > 0) { + // 자세한 객체별 진단 (top/height/fill/moduleInfo/points-sample 까지 — 복원 단서로 사용) + const detail = corruptedObjs.map((o) => ({ + id: o.id, + name: o.name, + parentId: o.parentId, + surfaceId: o.surfaceId, + top: o.top, + left: o.left, + width: o.width, + height: o.height, + fill: typeof o.fill === 'string' ? o.fill : o.fill?.type, + opacity: o.opacity, + direction: o.direction, + moduleInfo: o.moduleInfo + ? { itemId: o.moduleInfo.itemId, moduleTpCd: o.moduleInfo.moduleTpCd, capacity: o.moduleInfo.capacity } + : null, + pointsSample: Array.isArray(o.points) ? o.points.slice(0, 4) : null, + })) + logger.error('[PLAN-CORRUPT-COORD-SAVE-GUARD 2026-05-18] 保存失敗 — corrupted objects', { + count: corruptedObjs.length, + objectNo: currentCanvasPlan?.objectNo, + planNo: currentCanvasPlan?.planNo, + detail, + }) + swalFire({ + icon: 'error', + title: '保存に失敗しました', + text: `破損したモジュール ${corruptedObjs.length} 件が含まれています。\n再配置または明示的に削除してから再度保存してください。\n(詳細はコンソールログをご確認ください)`, + }) + return + } + // [ROOF-MATERIAL-SAVE-GUARD 2026-05-08] 면(roof) 에 시각적 지붕재 패턴은 칠해져 있는데 // 데이터(roofMaterial) 가 비어있는 "불일치" 케이스만 저장 차단. // - fill 비어있고 roofMaterial 도 없음 → 미선택 상태, 저장 허용 -- 2.47.2