qcast-front/src/hooks/module/useModule.js

1297 lines
45 KiB
JavaScript

import { BATCH_TYPE, POLYGON_TYPE } from '@/common/common'
import { QPolygon } from '@/components/fabric/QPolygon'
import { canvasState } from '@/store/canvasAtom'
import { polygonToTurfPolygon } from '@/util/canvas-util'
import { useRecoilValue } from 'recoil'
import * as turf from '@turf/turf'
import { useSwal } from '../useSwal'
import { useModuleBasicSetting } from './useModuleBasicSetting'
import { useMessage } from '../useMessage'
import { selectedModuleState } from '@/store/selectedModuleOptions'
import { useCircuitTrestle } from '../useCirCuitTrestle'
import { useTrestle } from './useTrestle'
import { useTurf } from '@/hooks/common/useTurf'
import { useUndoRedo } from '@/hooks/useUndoRedo'
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',
RIGHT: 'right',
HORIZONTAL_SIDE: 'horizontalSide',
TOP: 'top',
BOTTOM: 'bottom',
VERTICAL_SIDE: 'verticalSide',
NONE: 'none',
}
export const MODULE_INSERT_TYPE = {
LEFT: 'left',
RIGHT: 'right',
TOP: 'up',
BOTTOM: 'down',
}
export const MODULE_ALIGN_TYPE = {
VERTICAL: 'vertical',
HORIZONTAL: 'horizontal',
}
export function useModule() {
const canvas = useRecoilValue(canvasState)
const { swalFire } = useSwal()
const { getMessage } = useMessage()
const { checkModuleDisjointObjects } = useModuleBasicSetting()
const selectedModules = useRecoilValue(selectedModuleState)
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 = {}) => {
// [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) => ({
x: p.x + deltaX,
y: p.y + deltaY,
}))
return new QPolygon(newPoints, {
fill: module.fill,
stroke: module.stroke,
strokeWidth: module.strokeWidth,
opacity: module.opacity,
selectable: module.selectable,
lockMovementX: module.lockMovementX,
lockMovementY: module.lockMovementY,
lockRotation: module.lockRotation,
lockScalingX: module.lockScalingX,
lockScalingY: module.lockScalingY,
parentId: module.parentId,
initOptions: module.initOptions,
direction: module.direction,
arrow: module.arrow,
name: module.name,
surfaceId: module.surfaceId,
moduleInfo: module.moduleInfo,
left: newLeft,
top: newTop,
width: module.width,
height: module.height,
toFixed: 2,
sort: false,
...overrides,
})
}
const moduleMove = (length, direction) => {
const selectedObj = canvas.getActiveObjects() //선택된 객체들을 가져옴
const selectedIds = selectedObj.map((obj) => obj.id) // selectedObj의 ID 추출
if (selectedObj[0].circuit) {
swalFire({
title: getMessage('can.not.move.module'),
icon: 'error',
type: 'alert',
})
return
}
saveSnapshot()
// canvas.discardActiveObject() //선택해제
const isSetupModules = getOtherModules(selectedObj)
const selectedModules = canvas.getObjects().filter((obj) => selectedIds.includes(obj.id) && obj.name === 'module') //선택했던 객체들만 가져옴
const setupSurface = canvas.getObjects().find((obj) => obj.name === POLYGON_TYPE.MODULE_SETUP_SURFACE && obj.id === selectedModules[0].surfaceId)
let isWarning = false
const objects = getObjects()
if (selectedModules) {
selectedModules.forEach((module) => {
const { top, left } = getPosotion(module, direction, length, false)
module.originCoords = {
left: module.left,
top: module.top,
fill: module.fill,
}
module.set({ top, left, strokeWidth: 0.3 })
module.setCoords()
canvas.renderAll()
if (isOverlapOtherModules(module, isSetupModules) || isOverlapObjects(module, objects) || isOutsideSurface(module, setupSurface)) {
isWarning = true
module.set({ fill: 'red' })
}
module.set({ strokeWidth: 3 })
canvas.renderAll()
})
const surfaceId = selectedModules[0].surfaceId
if (isWarning) {
swalFire({
title: getMessage('can.not.move.module'),
icon: 'error',
type: 'alert',
confirmFn: () => {
selectedModules.forEach((module) => {
module.set({
left: module.originCoords.left,
top: module.originCoords.top,
fill: module.originCoords.fill,
})
module.dirty = true
module.setCoords()
})
canvas.requestRenderAll()
recalculateAllModulesCoords(surfaceId)
},
})
} else {
recalculateAllModulesCoords(surfaceId)
}
}
}
const moduleMultiMove = (type, length, direction) => {
if (canvas.getActiveObjects().length === 0) return
if (canvas.getActiveObjects().length > 1) {
swalFire({
title: '여러 개의 모듈을 선택할 수 없습니다.',
icon: 'error',
type: 'alert',
})
canvas.discardActiveObject()
return
}
const activeModule = canvas.getObjects().find((obj) => canvas.getActiveObjects()[0].id === obj.id)
const surfaces = canvas.getObjects().filter((obj) => obj.name === POLYGON_TYPE.MODULE_SETUP_SURFACE)
if (activeModule.circuit) {
if (surfaces.some((surface) => surface.isComplete)) {
swalFire({
title: getMessage('can.not.move.module'),
icon: 'error',
type: 'alert',
})
return
}
removeTrestleMaterials()
}
saveSnapshot()
const modules = type === 'row' ? getRowModules(activeModule) : getColumnModules(activeModule)
const otherModules = getOtherModules(modules)
const objects = getObjects()
const moduleSetupSurface = canvas.getObjects().find((obj) => obj.name === POLYGON_TYPE.MODULE_SETUP_SURFACE && obj.id === activeModule.surfaceId)
let isWarning = false
activeModule.set({ strokeWidth: 0.3 })
modules.forEach((module) => {
const { top, left } = getPosotion(module, direction, length, false)
module.originPos = {
top: module.top,
left: module.left,
fill: module.fill,
}
module.set({ top, left })
module.setCoords()
canvas.renderAll()
if (isOverlapOtherModules(module, otherModules) || isOverlapObjects(module, objects) || isOutsideSurface(module, moduleSetupSurface)) {
isWarning = true
module.set({ fill: 'red' })
}
})
activeModule.set({ strokeWidth: 3 })
canvas.renderAll()
const surfaceId = activeModule.surfaceId
if (isWarning) {
swalFire({
title: getMessage('can.not.move.module'),
icon: 'error',
type: 'alert',
confirmFn: () => {
modules.forEach((module) => {
module.set({ top: module.originPos.top, left: module.originPos.left, fill: module.originPos.fill })
module.dirty = true
module.setCoords()
})
canvas.requestRenderAll()
recalculateAllModulesCoords(surfaceId)
},
})
} else {
recalculateAllModulesCoords(surfaceId)
}
}
const moduleMoveAll = (length, direction, surfaceArray) => {
saveSnapshot()
surfaceArray.forEach((surface) => {
const modules = canvas
.getObjects()
.filter((module) => module.name === POLYGON_TYPE.MODULE)
.filter((module) => module.surfaceId === surface.id)
const objects = getObjects()
let isWarning = false
modules.forEach((module) => {
const { top, left } = getPosotion(module, direction, length, false)
module.originPos = {
top: module.top,
left: module.left,
fill: module.fill,
}
module.set({ top, left })
module.setCoords()
canvas.renderAll()
if (isOverlapObjects(module, objects) || isOutsideSurface(module, surface)) {
isWarning = true
module.set({ fill: 'red' })
}
})
canvas.renderAll()
const surfaceId = surface.id
if (isWarning) {
swalFire({
title: getMessage('can.not.move.module'),
icon: 'error',
type: 'alert',
confirmFn: () => {
modules.forEach((module) => {
module.set({ top: module.originPos.top, left: module.originPos.left, fill: module.originPos.fill })
module.dirty = true
module.setCoords()
})
canvas.requestRenderAll()
recalculateAllModulesCoords(surfaceId)
},
})
} else {
recalculateAllModulesCoords(surfaceId)
}
})
}
const moduleCopyAll = (length, direction, surfaceArray) => {
saveSnapshot()
surfaceArray.forEach((surface) => {
const modules = canvas
.getObjects()
.filter((module) => module.name === POLYGON_TYPE.MODULE)
.filter((module) => module.surfaceId === surface.id)
const objects = getObjects()
const copyModules = []
let copyModule = null
let isWarning = false
let moduleLength = 0
if (['up', 'down'].includes(direction)) {
modules.sort((a, b) => a.top - b.top)
moduleLength = Number(modules[modules.length - 1].top) + Number(modules[modules.length - 1].height) - Number(modules[0].top)
} else if (['left', 'right'].includes(direction)) {
modules.sort((a, b) => a.left - b.left)
moduleLength = Number(modules[modules.length - 1].left) + Number(modules[modules.length - 1].width) - Number(modules[0].left)
}
modules.forEach((module) => {
const { top, left } = getPosotion(module, direction, Number(length) + Number(moduleLength) * 10, false)
copyModule = createModuleCopy(module, left, top)
canvas.add(copyModule)
copyModules.push(copyModule)
copyModule.setCoords()
if (isOverlapObjects(copyModule, objects) || isOutsideSurface(copyModule, surface)) {
isWarning = true
copyModule.set({ fill: 'red' })
}
canvas.renderAll()
})
const surfaceId = surface.id
if (isWarning) {
swalFire({
title: getMessage('can.not.copy.module'),
icon: 'error',
type: 'alert',
confirmFn: () => {
canvas.remove(...copyModules)
canvas.requestRenderAll()
recalculateAllModulesCoords(surfaceId)
},
})
} else {
surface.set({ modules: [...surface.modules, ...copyModules] })
recalculateAllModulesCoords(surfaceId)
}
})
setModuleStatisticsData()
}
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()
const otherModules = canvas.getObjects().filter((obj) => obj.surfaceId === modules[0].surfaceId && obj.name === POLYGON_TYPE.MODULE)
const moduleSetupSurface = canvas.getObjects().find((obj) => obj.name === POLYGON_TYPE.MODULE_SETUP_SURFACE && obj.id === modules[0].surfaceId)
let isWarning = false
let copyModules = []
let copyModule = null
canvas.discardActiveObject() //선택해제
modules.forEach((module) => {
const { top, left } = getPosotion(module, direction, length, true)
copyModule = createModuleCopy(module, left, top)
copyModules.push(copyModule)
canvas.add(copyModule)
canvas.renderAll()
if (
isOverlapOtherModules(copyModule, otherModules) ||
isOverlapObjects(copyModule, objects) ||
isOutsideSurface(copyModule, moduleSetupSurface)
) {
isWarning = true
copyModule.set({ fill: 'red' })
canvas.renderAll()
}
})
const surfaceId = modules[0].surfaceId
if (isWarning) {
swalFire({
title: getMessage('can.not.copy.module'),
icon: 'error',
type: 'alert',
confirmFn: () => {
canvas.remove(...copyModules)
canvas.requestRenderAll()
recalculateAllModulesCoords(surfaceId)
},
})
} else {
moduleSetupSurface.set({ modules: [...moduleSetupSurface.modules, ...copyModules] })
recalculateAllModulesCoords(surfaceId)
}
setModuleStatisticsData()
}
const moduleMultiCopy = (type, length, direction) => {
if (canvas.getActiveObjects().length === 0) return
if (canvas.getActiveObjects().length > 1) {
swalFire({
title: '여러 개의 모듈을 선택할 수 없습니다.',
icon: 'error',
type: 'alert',
})
canvas.discardActiveObject()
return
}
const activeModule = canvas.getObjects().find((obj) => canvas.getActiveObjects()[0].id === obj.id)
activeModule.set({ strokeWidth: 1 })
if (activeModule.circuit) {
const surfaces = canvas.getObjects().filter((obj) => obj.name === POLYGON_TYPE.MODULE_SETUP_SURFACE)
if (surfaces.some((surface) => surface.isComplete)) {
swalFire({
title: getMessage('can.not.copy.module'),
icon: 'error',
type: 'alert',
})
return
}
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()
const copyModules = []
let copyModule = null
const moduleSetupSurface = canvas.getObjects().find((obj) => obj.name === POLYGON_TYPE.MODULE_SETUP_SURFACE && obj.id === modules[0].surfaceId)
let isWarning = false
let moduleLength = 0
if (['up', 'down'].includes(direction)) {
moduleLength = Number(modules[modules.length - 1].top) + Number(modules[modules.length - 1].height) - Number(modules[0].top)
} else if (['left', 'right'].includes(direction)) {
moduleLength = Number(modules[modules.length - 1].left) + Number(modules[modules.length - 1].width) - Number(modules[0].left)
}
modules.forEach((module) => {
const { top, left } = getPosotion(module, direction, Number(length), true)
copyModule = createModuleCopy(module, left, top, { stroke: 'black' })
canvas.add(copyModule)
copyModules.push(copyModule)
copyModule.setCoords()
if (
isOverlapOtherModules(copyModule, otherModules) ||
isOverlapObjects(copyModule, objects) ||
isOutsideSurface(copyModule, moduleSetupSurface)
) {
isWarning = true
copyModule.set({ fill: 'red' })
}
canvas.renderAll()
})
activeModule.set({ strokeWidth: 3 })
const surfaceId = activeModule.surfaceId
if (isWarning) {
swalFire({
title: getMessage('can.not.copy.module'),
icon: 'error',
type: 'alert',
confirmFn: () => {
canvas.remove(...copyModules)
canvas.requestRenderAll()
recalculateAllModulesCoords(surfaceId)
},
})
} else {
moduleSetupSurface.set({ modules: [...moduleSetupSurface.modules, ...copyModules] })
setModuleStatisticsData()
recalculateAllModulesCoords(surfaceId)
}
}
const moduleColumnRemove = (type) => {
if (isFixedModule()) {
return
}
const activeModule = canvas.getObjects().find((obj) => canvas.getActiveObjects()[0].id === obj.id)
const columnModules = getColumnModules(activeModule)
const otherModules = getOtherModules(columnModules)
const objects = getObjects()
let targetModules = []
const rightModules = otherModules.filter((module) => activeModule.left < module.left).sort((a, b) => a.left - b.left)
const leftModules = otherModules.filter((module) => activeModule.left > module.left).sort((a, b) => b.left - a.left)
let width = -1
const moduleSetupSurface = canvas.getObjects().find((obj) => obj.name === POLYGON_TYPE.MODULE_SETUP_SURFACE && obj.id === activeModule.surfaceId)
let isWarning = false
if (moduleSetupSurface.isComplete) {
resetSurface()
}
const surfaces = canvas.getObjects().filter((obj) => obj.name === POLYGON_TYPE.MODULE_SETUP_SURFACE)
if (surfaces.some((surface) => surface.isComplete)) {
removeTrestleMaterials()
}
saveSnapshot()
canvas.discardActiveObject()
moduleSetupSurface.set({ modules: otherModules })
canvas.remove(...columnModules)
canvas.renderAll()
if (type === MODULE_REMOVE_TYPE.LEFT) {
rightModules.forEach((module) => {
module.originPos = {
left: module.left,
top: module.top,
fill: module.fill,
}
if (width === -1) width = module.left - activeModule.left
module.set({ left: module.left - width })
module.dirty = true
module.setCoords()
canvas.renderAll()
if (isOverlapOtherModules(module, leftModules) || isOverlapObjects(module, objects) || isOutsideSurface(module, moduleSetupSurface)) {
module.set({ fill: 'red' })
isWarning = true
}
})
canvas.requestRenderAll()
targetModules = rightModules
} else if (type === MODULE_REMOVE_TYPE.RIGHT) {
leftModules.forEach((module) => {
module.originPos = {
left: module.left,
top: module.top,
fill: module.fill,
}
if (width === -1) width = activeModule.left - module.left
module.set({ left: module.left + width })
module.dirty = true
module.setCoords()
canvas.renderAll()
if (isOverlapOtherModules(module, rightModules) || isOverlapObjects(module, objects) || isOutsideSurface(module, moduleSetupSurface)) {
module.set({ fill: 'red' })
isWarning = true
}
})
canvas.requestRenderAll()
targetModules = leftModules
} else if (type === MODULE_REMOVE_TYPE.HORIZONTAL_SIDE) {
const sideModules = [...leftModules, ...rightModules]
leftModules.forEach((module) => {
module.originPos = {
left: module.left,
top: module.top,
fill: module.fill,
}
if (width === -1) width = activeModule.left - module.left
module.set({ left: module.left + width / 2 })
module.dirty = true
module.setCoords()
canvas.renderAll()
})
rightModules.forEach((module) => {
module.originPos = {
left: module.left,
top: module.top,
fill: module.fill,
}
if (width === -1) width = module.left - activeModule.left
module.set({ left: module.left - width / 2 })
module.dirty = true
module.setCoords()
canvas.renderAll()
})
sideModules.forEach((module) => {
if (
isOverlapOtherModules(
module,
sideModules.filter((m) => m.id !== module.id),
) ||
isOverlapObjects(module, objects) ||
isOutsideSurface(module, moduleSetupSurface)
) {
isWarning = true
module.set({ fill: 'red' })
}
})
targetModules = sideModules
}
canvas.renderAll()
const surfaceId = activeModule.surfaceId
if (isWarning) {
swalFire({
title: getMessage('can.not.remove.module'),
icon: 'error',
type: 'alert',
confirmFn: () => {
moduleSetupSurface.modules = [...moduleSetupSurface.modules, ...columnModules]
canvas.add(...columnModules)
targetModules.forEach((module) => {
module.set({ top: module.originPos.top, left: module.originPos.left, fill: module.originPos.fill })
module.dirty = true
module.setCoords()
})
columnModules.forEach((module) => {
module.dirty = true
module.setCoords()
})
canvas.requestRenderAll()
recalculateAllModulesCoords(surfaceId)
},
})
} else {
moduleSetupSurface.modules = moduleSetupSurface.modules.filter(
(module) => !columnModules.map((copyModule) => copyModule.id).includes(module.id),
)
setModuleStatisticsData()
recalculateAllModulesCoords(surfaceId)
}
}
const moduleRowRemove = (type) => {
const activeModule = canvas.getObjects().find((obj) => canvas.getActiveObjects()[0].id === obj.id)
const rowModules = getRowModules(activeModule)
const otherModules = getOtherModules(rowModules)
const objects = getObjects()
let targetModules = []
const topModules = otherModules.filter((module) => activeModule.top > module.top).sort((a, b) => b.top - a.top)
const bottomModules = otherModules.filter((module) => activeModule.top < module.top).sort((a, b) => a.top - b.top)
let height = -1
const moduleSetupSurface = canvas.getObjects().find((obj) => obj.name === POLYGON_TYPE.MODULE_SETUP_SURFACE && obj.id === activeModule.surfaceId)
let isWarning = false
const surfaces = canvas.getObjects().filter((obj) => obj.name === POLYGON_TYPE.MODULE_SETUP_SURFACE)
if (surfaces.some((surface) => surface.isComplete)) {
removeTrestleMaterials()
}
saveSnapshot()
canvas.discardActiveObject()
moduleSetupSurface.set({ modules: otherModules })
canvas.remove(...rowModules)
canvas.renderAll()
if (type === MODULE_REMOVE_TYPE.TOP) {
bottomModules.forEach((module) => {
module.originPos = {
left: module.left,
top: module.top,
fill: module.fill,
}
if (height === -1) height = module.top - activeModule.top
module.set({ top: module.top - height })
module.dirty = true
module.setCoords()
canvas.renderAll()
if (isOverlapOtherModules(module, topModules) || isOverlapObjects(module, objects) || isOutsideSurface(module, moduleSetupSurface)) {
isWarning = true
module.set({ fill: 'red' })
}
})
canvas.requestRenderAll()
targetModules = bottomModules
} else if (type === MODULE_REMOVE_TYPE.BOTTOM) {
topModules.forEach((module) => {
module.originPos = {
left: module.left,
top: module.top,
fill: module.fill,
}
if (height === -1) height = activeModule.top - module.top
module.set({ top: module.top + activeModule.height })
module.dirty = true
module.setCoords()
canvas.renderAll()
if (isOverlapOtherModules(module, bottomModules) || isOverlapObjects(module, objects) || isOutsideSurface(module, moduleSetupSurface)) {
isWarning = true
module.set({ fill: 'red' })
}
})
canvas.requestRenderAll()
targetModules = topModules
} else if (type === MODULE_REMOVE_TYPE.VERTICAL_SIDE) {
topModules.forEach((module) => {
module.originPos = {
left: module.left,
top: module.top,
fill: module.fill,
}
// if (height === -1) height = activeModule.top - module.top
if (height === -1) height = activeModule.height
module.set({ top: module.top + height / 2 })
module.dirty = true
module.setCoords()
})
bottomModules.forEach((module) => {
module.originPos = {
left: module.left,
top: module.top,
fill: module.fill,
}
// if (height === -1) height = module.top - activeModule.top
if (height === -1) height = activeModule.height
module.set({ top: module.top - height / 2 })
module.dirty = true
module.setCoords()
})
canvas.requestRenderAll()
const sideModules = [...topModules, ...bottomModules]
sideModules.forEach((module) => {
if (
isOverlapOtherModules(
module,
sideModules.filter((m) => m.id !== module.id),
) ||
isOverlapObjects(module, objects) ||
isOutsideSurface(module, moduleSetupSurface)
) {
isWarning = true
module.set({ fill: 'red' })
}
})
targetModules = sideModules
}
canvas.renderAll()
const surfaceId = activeModule.surfaceId
if (isWarning && type !== MODULE_REMOVE_TYPE.NONE) {
targetModules.forEach((rect) => rect.set({ fill: 'red' }))
swalFire({
title: getMessage('can.not.remove.module'),
icon: 'error',
type: 'alert',
confirmFn: () => {
moduleSetupSurface.modules = [...moduleSetupSurface.modules, ...rowModules]
canvas.add(...rowModules)
targetModules.forEach((module) => {
module.set({ top: module.originPos.top, left: module.originPos.left, fill: module.originPos.fill })
module.dirty = true
module.setCoords()
})
rowModules.forEach((module) => {
module.dirty = true
module.setCoords()
})
canvas.requestRenderAll()
recalculateAllModulesCoords(surfaceId)
},
})
}
setModuleStatisticsData()
recalculateAllModulesCoords(surfaceId)
}
const moduleColumnInsert = (type) => {
const activeModule = canvas.getObjects().find((obj) => canvas.getActiveObjects()[0].id === obj.id)
const columnModules = getColumnModules(activeModule)
let otherModules = getOtherModules(columnModules)
const targetModules =
type === MODULE_INSERT_TYPE.LEFT
? otherModules.filter((module) => module.left < activeModule.left).sort((a, b) => a.left - b.left)
: otherModules.filter((module) => module.left > activeModule.left).sort((a, b) => a.left - b.left)
const objects = getObjects()
const copyModules = []
const moduleSetupSurface = canvas.getObjects().find((obj) => obj.name === POLYGON_TYPE.MODULE_SETUP_SURFACE && obj.id === activeModule.surfaceId)
let isWarning = false
const { moduleIntvlHor = 0, moduleIntvlVer = 0 } = moduleSetupSurface.trestleDetail || {}
canvas.discardActiveObject()
targetModules.forEach((module) => {
const { top, left } = getPosotion(module, type, moduleIntvlHor, true)
module.originPos = {
left: module.left,
top: module.top,
fill: module.fill,
}
module.set({ left, top })
canvas.renderAll()
if (isOverlapObjects(module, objects) || isOutsideSurface(module, moduleSetupSurface)) {
isWarning = true
module.set({ fill: 'red' })
}
module.setCoords()
})
canvas.renderAll()
otherModules = getOtherModules(columnModules)
columnModules.forEach((module) => {
const { top, left } = getPosotion(module, type, moduleIntvlHor, true)
const copyModule = createModuleCopy(module, left, top)
canvas.add(copyModule)
copyModules.push(copyModule)
copyModule.setCoords()
canvas.renderAll()
if (
isOverlapOtherModules(copyModule, otherModules) ||
isOverlapObjects(copyModule, objects) ||
isOutsideSurface(copyModule, moduleSetupSurface)
) {
isWarning = true
copyModule.set({ fill: 'red' })
}
module.setCoords()
})
canvas.renderAll()
const surfaceId = activeModule.surfaceId
if (isWarning) {
swalFire({
title: getMessage('can.not.insert.module'),
icon: 'error',
type: 'alert',
confirmFn: () => {
targetModules.forEach((module) => {
module.set({ top: module.originPos.top, left: module.originPos.left, fill: module.originPos.fill })
module.dirty = true
module.setCoords()
})
canvas.remove(...copyModules)
canvas.requestRenderAll()
recalculateAllModulesCoords(surfaceId)
},
})
} else {
moduleSetupSurface.modules = [...moduleSetupSurface.modules, ...copyModules]
setModuleStatisticsData()
recalculateAllModulesCoords(surfaceId)
}
}
const isFixedModule = () => {
const completeSurfaces = canvas.getObjects().filter((obj) => obj.name === POLYGON_TYPE.MODULE_SETUP_SURFACE && obj.isComplete)
if (completeSurfaces.length > 0) {
swalFire({
title: getMessage('modal.module.can.not.edit'),
type: 'alert',
icon: 'error',
})
return true
}
return false
}
const muduleRowInsert = (type) => {
if (isFixedModule()) {
return
}
const activeModule = canvas.getObjects().find((obj) => canvas.getActiveObjects()[0].id === obj.id)
const rowModules = getRowModules(activeModule)
let otherModules = getOtherModules(rowModules)
const targetModules =
type === MODULE_INSERT_TYPE.TOP
? otherModules.filter((module) => module.top < activeModule.top).sort((a, b) => a.top - b.top)
: otherModules.filter((module) => module.top > activeModule.top).sort((a, b) => a.top - b.top)
const objects = getObjects()
const copyModules = []
const moduleSetupSurface = canvas.getObjects().find((obj) => obj.name === POLYGON_TYPE.MODULE_SETUP_SURFACE && obj.id === activeModule.surfaceId)
let isWarning = false
const { moduleIntvlHor = 0, moduleIntvlVer = 0 } = moduleSetupSurface.trestleDetail || {}
canvas.discardActiveObject()
targetModules.forEach((module) => {
const { top, left } = getPosotion(module, type, moduleIntvlVer, true)
module.originPos = {
left: module.left,
top: module.top,
fill: module.fill,
}
module.set({ left, top })
if (isOverlapObjects(module, objects) || isOutsideSurface(module, moduleSetupSurface)) {
isWarning = true
module.set({ fill: 'red' })
}
module.setCoords()
})
canvas.renderAll()
otherModules = getOtherModules(rowModules)
rowModules.forEach((module) => {
const { top, left } = getPosotion(module, type, moduleIntvlVer, true)
const copyModule = createModuleCopy(module, left, top)
canvas.add(copyModule)
copyModules.push(copyModule)
copyModule.setCoords()
canvas.renderAll()
if (
isOverlapOtherModules(copyModule, otherModules) ||
isOverlapObjects(copyModule, objects) ||
isOutsideSurface(copyModule, moduleSetupSurface)
) {
isWarning = true
copyModule.set({ fill: 'red' })
}
module.setCoords()
})
canvas.renderAll()
const surfaceId = activeModule.surfaceId
if (isWarning) {
swalFire({
title: getMessage('can.not.insert.module'),
icon: 'error',
type: 'alert',
confirmFn: () => {
targetModules.forEach((module) => {
module.set({ top: module.originPos.top, left: module.originPos.left, fill: module.originPos.fill })
module.dirty = true
module.setCoords()
})
canvas.remove(...copyModules)
canvas.requestRenderAll()
recalculateAllModulesCoords(surfaceId)
},
})
} else {
moduleSetupSurface.modules = [...moduleSetupSurface.modules, ...copyModules]
setModuleStatisticsData()
recalculateAllModulesCoords(surfaceId)
}
}
const alignModule = (type, surfaceArray) => {
surfaceArray.forEach((surface) => {
const modules = surface.modules
const objects = getObjects()
let [top, bottom, left, right] = [0, 0, 0, 0]
top = Math.min(...modules.map((module) => module.top))
bottom = Math.max(...modules.map((module) => module.top + module.height))
left = Math.min(...modules.map((module) => module.left))
right = Math.max(...modules.map((module) => module.left + module.width))
const moduleSurfacePos = {
top: Math.min(...surface.points.map((point) => point.y)),
left: Math.min(...surface.points.map((point) => point.x)),
}
const [height, width] = [bottom - top, right - left]
const verticalCenterLength = moduleSurfacePos.top + surface.height / 2 - (top + height / 2)
const horizontalCenterLength = moduleSurfacePos.left + surface.width / 2 - (left + width / 2)
let isWarning = false
canvas.discardActiveObject()
modules.forEach((module) => {
module.originPos = {
left: module.left,
top: module.top,
fill: module.fill,
}
if (type === MODULE_ALIGN_TYPE.VERTICAL) {
module.set({ top: module.top + verticalCenterLength })
} else if (type === MODULE_ALIGN_TYPE.HORIZONTAL) {
module.set({ left: module.left + horizontalCenterLength })
}
canvas.renderAll()
module.setCoords()
if (isOverlapObjects(module, objects) || isOutsideSurface(module, surface)) {
isWarning = true
module.set({ fill: 'red' })
}
})
canvas.renderAll()
const surfaceId = surface.id
if (isWarning) {
swalFire({
title: getMessage('can.not.align.module'),
icon: 'error',
type: 'alert',
confirmFn: () => {
modules.forEach((module) => {
module.set({ top: module.originPos.top, left: module.originPos.left, fill: module.originPos.fill })
module.dirty = true
module.setCoords()
})
canvas.requestRenderAll()
recalculateAllModulesCoords(surfaceId)
},
})
} else {
recalculateAllModulesCoords(surfaceId)
}
})
}
// 회로가 없을때만 삭제 가능
const modulesRemove = () => {
// [MODULE-MULTI-SELECT 2026-06-10] 단일/복수 선택 모두 처리. 회로가 걸린 모듈이 하나라도 있으면 차단.
const activeModules = canvas.getActiveObjects().filter((obj) => obj.name === POLYGON_TYPE.MODULE)
if (activeModules.length === 0) return
if (activeModules.some((module) => module.circuit)) {
swalFire({
title: getMessage('can.not.remove.module'),
icon: 'error',
type: 'alert',
})
return
}
saveSnapshot()
// [MODULE-MULTI-SELECT fix 2026-06-18] 단수 버전 잔재(surface/modules/activeModule 미정의 참조)로 인한
// ReferenceError 제거. 모듈 제거는 아래 activeModules.forEach, surface.modules 갱신은 affectedSurfaces
// 루프가 이미 처리하므로 중복이기도 함.
const surfaceIds = [...new Set(activeModules.map((module) => module.surfaceId))]
const affectedSurfaces = canvas.getObjects().filter((obj) => obj.name === POLYGON_TYPE.MODULE_SETUP_SURFACE && surfaceIds.includes(obj.id))
if (affectedSurfaces.some((surface) => surface.isComplete)) {
removeTrestleMaterials()
}
activeModules.forEach((module) => canvas.remove(module))
affectedSurfaces.forEach((surface) => {
const remaining = canvas.getObjects().filter((obj) => obj.surfaceId === surface.id && obj.name === POLYGON_TYPE.MODULE)
surface.set({ modules: remaining })
})
canvas.discardActiveObject()
canvas.renderAll()
setModuleStatisticsData()
surfaceIds.forEach((surfaceId) => recalculateAllModulesCoords(surfaceId))
}
const moduleRoofRemove = (surfaceArray) => {
surfaceArray.forEach((surface) => {
surface.modules = []
canvas
.getObjects()
.filter((module) => module.name === POLYGON_TYPE.MODULE && module.surfaceId === surface.id)
.forEach((module) => {
canvas.remove(module.circuit)
canvas.remove(module)
})
})
canvas.renderAll()
setModuleStatisticsData()
}
const isOverlapOtherModules = (module, otherModules) => {
if (otherModules.length === 0) return false
return otherModules.some(
(otherModule) =>
turf.booleanOverlap(polygonToTurfPolygon(module, true), polygonToTurfPolygon(otherModule, true)) ||
turf.booleanWithin(polygonToTurfPolygon(module, true), polygonToTurfPolygon(otherModule, true)),
)
}
const isOverlapObjects = (module, objects) => {
return !checkModuleDisjointObjects(polygonToTurfPolygon(module, true), objects)
}
const isOutsideSurface = (module, moduleSetupSurface) => {
return !checkModuleDisjointSurface(polygonToTurfPolygon(module, true), polygonToTurfPolygon(moduleSetupSurface, true), 0.5)
}
const getRowModules = (target) => {
return canvas
.getObjects()
.filter((obj) => target.surfaceId === obj.surfaceId && obj.name === POLYGON_TYPE.MODULE && Math.abs(obj.top - target.top) < 1)
.sort((a, b) => a.left - b.left)
}
const getColumnModules = (target) => {
return canvas
.getObjects()
.filter((obj) => target.surfaceId === obj.surfaceId && obj.name === POLYGON_TYPE.MODULE && Math.abs(obj.left - target.left) < 1)
.sort((a, b) => a.top - b.top)
}
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
if (direction === 'up') {
top = Number(target.top) - Number(length) / 10
top = hasMargin ? top - Number(target.height) : top
} else if (direction === 'down') {
top = Number(target.top) + Number(length) / 10
top = hasMargin ? top + Number(target.height) : top
} else if (direction === 'left') {
left = Number(target.left) - Number(length) / 10
left = hasMargin ? left - Number(target.width) : left
} else if (direction === 'right') {
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 }
}
const getOtherModules = (modules) => {
const moduleIds = modules.map((module) => module.id)
return canvas
.getObjects()
.filter((obj) => obj.surfaceId === modules[0].surfaceId && obj.name === POLYGON_TYPE.MODULE && !moduleIds.includes(obj.id))
}
const getObjects = () => {
return canvas?.getObjects().filter((obj) => [BATCH_TYPE.OPENING, BATCH_TYPE.TRIANGLE_DORMER, BATCH_TYPE.PENTAGON_DORMER].includes(obj.name))
}
const resetSurface = () => {
resetCircuits()
removeTrestleMaterials()
}
/**
* 모든 모듈의 좌표를 재계산
* 열/단 삭제, 복사, 추가 후 호출하여 선택 영역(bounding box)을 업데이트
* @param {string} surfaceId - 특정 surface의 모듈만 재계산 (선택적)
*/
const recalculateAllModulesCoords = (surfaceId = null) => {
if (!canvas) return
const modules = canvas
.getObjects()
.filter((obj) => obj.name === POLYGON_TYPE.MODULE)
.filter((obj) => (surfaceId ? obj.surfaceId === surfaceId : true))
// 모듈의 캐시를 초기화하고 좌표 재계산
modules.forEach((module) => {
// Fabric.js 내부 캐시 초기화
delete module.oCoords
delete module.aCoords
delete module.lineCoords
delete module.__corner
delete module.matrixCache
delete module.ownMatrixCache
// dirty 플래그 설정 및 좌표 재계산
module.dirty = true
module.setCoords()
})
// 렌더링
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,
moduleMoveAll,
moduleCopy,
moduleMultiCopy,
moduleCopyAll,
moduleColumnRemove,
moduleRowRemove,
moduleColumnInsert,
muduleRowInsert,
modulesRemove,
moduleRoofRemove,
alignModule,
recalculateAllModulesCoords,
restoreCorruptedModules,
}
}