[2240] 손상 plan 좌표 가드/복원 — 로드 가드+저장 차단+복원 자동호출 #864
@ -7,6 +7,7 @@ import * as turf from '@turf/turf'
|
|||||||
import { LINE_TYPE, POLYGON_TYPE } from '@/common/common'
|
import { LINE_TYPE, POLYGON_TYPE } from '@/common/common'
|
||||||
import Big from 'big.js'
|
import Big from 'big.js'
|
||||||
import { drawSkeletonRidgeRoof, drawSkeletonRidgeRoofFromBaseLines, verifyMoveBoundary } from '@/util/skeleton-utils'
|
import { drawSkeletonRidgeRoof, drawSkeletonRidgeRoofFromBaseLines, verifyMoveBoundary } from '@/util/skeleton-utils'
|
||||||
|
import { logger } from '@/util/logger'
|
||||||
|
|
||||||
// ========================================================================
|
// ========================================================================
|
||||||
// [DEBUG] 캔버스 라인 라벨 — 로컬 전용 (NEXT_PUBLIC_RUN_MODE === 'local')
|
// [DEBUG] 캔버스 라인 라벨 — 로컬 전용 (NEXT_PUBLIC_RUN_MODE === 'local')
|
||||||
@ -134,6 +135,33 @@ export const QPolygon = fabric.util.createClass(fabric.Polygon, {
|
|||||||
points = snapNearAxisEdges(points, 2)
|
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) => {
|
points.forEach((point) => {
|
||||||
point.x = Number(point.x.toFixed(this.toFixed))
|
point.x = Number(point.x.toFixed(this.toFixed))
|
||||||
|
|||||||
@ -8,6 +8,7 @@ import QContextMenu from '@/components/common/context-menu/QContextMenu'
|
|||||||
import PanelBatchStatistics from '@/components/floor-plan/modal/panelBatch/PanelBatchStatistics'
|
import PanelBatchStatistics from '@/components/floor-plan/modal/panelBatch/PanelBatchStatistics'
|
||||||
import ImgLoad from '@/components/floor-plan/modal/ImgLoad'
|
import ImgLoad from '@/components/floor-plan/modal/ImgLoad'
|
||||||
import { useCanvas } from '@/hooks/useCanvas'
|
import { useCanvas } from '@/hooks/useCanvas'
|
||||||
|
import { useModule } from '@/hooks/module/useModule'
|
||||||
import { usePlan } from '@/hooks/usePlan'
|
import { usePlan } from '@/hooks/usePlan'
|
||||||
import { useContextMenu } from '@/hooks/useContextMenu'
|
import { useContextMenu } from '@/hooks/useContextMenu'
|
||||||
import { useCanvasConfigInitialize } from '@/hooks/common/useCanvasConfigInitialize'
|
import { useCanvasConfigInitialize } from '@/hooks/common/useCanvasConfigInitialize'
|
||||||
@ -60,6 +61,7 @@ export default function CanvasFrame() {
|
|||||||
const { basicSetting, fetchBasicSettings } = useCanvasSetting()
|
const { basicSetting, fetchBasicSettings } = useCanvasSetting()
|
||||||
const { selectedMenu, setSelectedMenu } = useCanvasMenu()
|
const { selectedMenu, setSelectedMenu } = useCanvasMenu()
|
||||||
const { initEvent } = useEvent()
|
const { initEvent } = useEvent()
|
||||||
|
const { restoreCorruptedModules } = useModule()
|
||||||
const canvasSetting = useRecoilValue(canvasSettingState)
|
const canvasSetting = useRecoilValue(canvasSettingState)
|
||||||
const loadCanvas = () => {
|
const loadCanvas = () => {
|
||||||
if (!canvas) return
|
if (!canvas) return
|
||||||
@ -109,6 +111,11 @@ export default function CanvasFrame() {
|
|||||||
})
|
})
|
||||||
|
|
||||||
initEvent()
|
initEvent()
|
||||||
|
|
||||||
|
// [PLAN-CORRUPT-RESTORE 2026-05-18] 손상 module 복원 — QPolygon 로드 가드가
|
||||||
|
// __corrupted 마크한 module 좌표를 같은 surface 의 정상 module 패턴으로 추론 후 빨강 marking.
|
||||||
|
// 정상 plan 에는 영향 0 (__corrupted 없으면 early return).
|
||||||
|
restoreCorruptedModules()
|
||||||
})
|
})
|
||||||
} else {
|
} else {
|
||||||
setSelectedMenu(null)
|
setSelectedMenu(null)
|
||||||
|
|||||||
@ -12,6 +12,24 @@ import { selectedModuleState } from '@/store/selectedModuleOptions'
|
|||||||
import { useCircuitTrestle } from '../useCirCuitTrestle'
|
import { useCircuitTrestle } from '../useCirCuitTrestle'
|
||||||
import { useTrestle } from './useTrestle'
|
import { useTrestle } from './useTrestle'
|
||||||
import { useTurf } from '@/hooks/common/useTurf'
|
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 = {
|
export const MODULE_REMOVE_TYPE = {
|
||||||
LEFT: 'left',
|
LEFT: 'left',
|
||||||
@ -47,6 +65,17 @@ export function useModule() {
|
|||||||
|
|
||||||
// clone() 대신 직접 새 QPolygon을 생성하여 Maximum call stack 방지
|
// clone() 대신 직접 새 QPolygon을 생성하여 Maximum call stack 방지
|
||||||
const createModuleCopy = (module, newLeft, newTop, overrides = {}) => {
|
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 deltaX = newLeft - module.left
|
||||||
const deltaY = newTop - module.top
|
const deltaY = newTop - module.top
|
||||||
const newPoints = module.points.map((p) => ({
|
const newPoints = module.points.map((p) => ({
|
||||||
@ -1024,6 +1053,15 @@ export function useModule() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const getPosotion = (target, direction, length, hasMargin = false) => {
|
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 top = target.top
|
||||||
let left = target.left
|
let left = target.left
|
||||||
|
|
||||||
@ -1040,6 +1078,7 @@ export function useModule() {
|
|||||||
left = Number(target.left) + Number(length) / 10
|
left = Number(target.left) + Number(length) / 10
|
||||||
left = hasMargin ? left + Number(target.width) : left
|
left = hasMargin ? left + Number(target.width) : left
|
||||||
}
|
}
|
||||||
|
__nanDiag('getPosotion output', { targetId: target?.id, direction, length, top, left })
|
||||||
return { top, left }
|
return { top, left }
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -1091,6 +1130,130 @@ export function useModule() {
|
|||||||
canvas.renderAll()
|
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 {
|
return {
|
||||||
moduleMove,
|
moduleMove,
|
||||||
moduleMultiMove,
|
moduleMultiMove,
|
||||||
@ -1106,5 +1269,6 @@ export function useModule() {
|
|||||||
moduleRoofRemove,
|
moduleRoofRemove,
|
||||||
alignModule,
|
alignModule,
|
||||||
recalculateAllModulesCoords,
|
recalculateAllModulesCoords,
|
||||||
|
restoreCorruptedModules,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -31,6 +31,7 @@ import { useCanvasMenu } from './common/useCanvasMenu'
|
|||||||
import { QcastContext } from '@/app/QcastProvider'
|
import { QcastContext } from '@/app/QcastProvider'
|
||||||
import { unescapeString } from '@/util/common-utils'
|
import { unescapeString } from '@/util/common-utils'
|
||||||
import { useTrestle } from '@/hooks/module/useTrestle'
|
import { useTrestle } from '@/hooks/module/useTrestle'
|
||||||
|
import { logger } from '@/util/logger'
|
||||||
|
|
||||||
// putCanvasStatus 의 in-flight 요청 — module-scope 로 두어 usePlan() 인스턴스 간 공유.
|
// putCanvasStatus 의 in-flight 요청 — module-scope 로 두어 usePlan() 인스턴스 간 공유.
|
||||||
// 큰 fabric JSON 의 동시 PUT 이 SQL Server 데드락 victim 을 양산해 온 이력 때문에 직전 호출은 abort.
|
// 큰 fabric JSON 의 동시 PUT 이 SQL Server 데드락 victim 을 양산해 온 이력 때문에 직전 호출은 abort.
|
||||||
@ -177,6 +178,44 @@ export function usePlan(params = {}) {
|
|||||||
* @param {boolean} saveAlert - 저장 완료 알림 표시 여부
|
* @param {boolean} saveAlert - 저장 완료 알림 표시 여부
|
||||||
*/
|
*/
|
||||||
const saveCanvas = async (saveAlert = true) => {
|
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) 에 시각적 지붕재 패턴은 칠해져 있는데
|
// [ROOF-MATERIAL-SAVE-GUARD 2026-05-08] 면(roof) 에 시각적 지붕재 패턴은 칠해져 있는데
|
||||||
// 데이터(roofMaterial) 가 비어있는 "불일치" 케이스만 저장 차단.
|
// 데이터(roofMaterial) 가 비어있는 "불일치" 케이스만 저장 차단.
|
||||||
// - fill 비어있고 roofMaterial 도 없음 → 미선택 상태, 저장 허용
|
// - fill 비어있고 roofMaterial 도 없음 → 미선택 상태, 저장 허용
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user