qcast-front/src/hooks/roofcover/useMovementSetting.js
2026-04-29 17:47:46 +09:00

939 lines
38 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import { useRecoilValue } from 'recoil'
import { canvasState, currentObjectState } from '@/store/canvasAtom'
import { usePopup } from '@/hooks/usePopup'
import { useMessage } from '@/hooks/useMessage'
import { useEffect, useRef, useState } from 'react'
import { useEvent } from '@/hooks/useEvent'
import { useSwal } from '@/hooks/useSwal'
import { LINE_TYPE, POLYGON_TYPE } from '@/common/common'
import Big from 'big.js'
import { calcLinePlaneSize } from '@/util/qpolygon-utils'
import { getSelectLinePosition } from '@/util/skeleton-utils'
import { useMouse } from '@/hooks/useMouse'
// [정책 flag] 오버 이동(인접 라인 반전 = polygon self-cross) 입력 처리 방법.
// 'clamp' : (기본/2026-04-24 최종) 평행 위치-OVER_EPS 까지 클램핑. wall.baseLines 는 항상 정상 polygon, SK 확장 정상.
// 'reject' : (2026-04-22 정책) silent skip — 오버면 해당 target 의 mutation 자체를 건너뜀. 토스트 없음.
// 'passthrough' : 가드 전면 스킵 — 사용자가 입력한 raw 값을 그대로 적용. (과거 crossed 허용 동작; polygon 왜곡 위험)
// 교체 방법: 이 상수만 변경. OVER_GUARD 분기가 자동 반응.
const OVER_MOVE_POLICY = 'clamp'
const OVER_EPS = 0.5
//동선이동 형 올림 내림
export function useMovementSetting(id) {
const TYPE = {
FLOW_LINE: 'flowLine', // 동선이동
UP_DOWN: 'updown', //형 올림내림
}
const canvas = useRecoilValue(canvasState)
const { initEvent, addCanvasMouseEventListener } = useEvent()
const { closePopup } = usePopup()
const { getMessage } = useMessage()
const { getIntersectMousePoint } = useMouse()
const currentObject = useRecoilValue(currentObjectState)
const selectedObject = useRef(null)
const buttonType = [
{ id: 1, name: getMessage('modal.movement.flow.line.move'), type: TYPE.FLOW_LINE },
{ id: 2, name: getMessage('modal.movement.flow.line.updown'), type: TYPE.UP_DOWN },
]
const [type, setType] = useState(TYPE.FLOW_LINE)
const typeRef = useRef(type)
const { swalFire } = useSwal()
const FLOW_LINE_REF = {
POINTER_INPUT_REF: useRef(null),
FILLED_INPUT_REF: useRef(null),
DOWN_LEFT_RADIO_REF: useRef(null),
UP_RIGHT_RADIO_REF: useRef(null),
}
const UP_DOWN_REF = {
POINTER_INPUT_REF: useRef(null),
FILLED_INPUT_REF: useRef(null),
UP_RADIO_REF: useRef(null),
DOWN_RADIO_REF: useRef(null),
}
const CONFIRM_LINE_REF = useRef(null)
const FOLLOW_LINE_REF = useRef(null)
// [MV-TRACE] 직전 mouseMove 가 input 박스 갱신에 성공했는지. false 면 mouseDown 에서 wallbaseLine 미그리기.
const lastMoveValidRef = useRef(false)
/** 동선이동, 형이동 선택시 속성 처리*/
useEffect(() => {
typeRef.current = type
selectedObject.current = null
if (FOLLOW_LINE_REF.current != null) {
canvas.remove(FOLLOW_LINE_REF.current)
canvas.renderAll()
FOLLOW_LINE_REF.current = null
}
if (CONFIRM_LINE_REF.current != null) {
canvas.remove(CONFIRM_LINE_REF.current)
canvas.renderAll()
CONFIRM_LINE_REF.current = null
}
clearRef()
canvas.discardActiveObject()
/** 전체 object 선택 불가 */
canvas.getObjects().forEach((obj) => {
obj.set({ selectable: false })
})
/** 지붕선 관련 속성 처리*/
const roofs = canvas.getObjects().filter((obj) => obj.name === POLYGON_TYPE.ROOF)
roofs.forEach((roof) => {
roof.set({ stroke: '#000000' })
roof.innerLines.forEach((line) => {
if (type === TYPE.FLOW_LINE && line.name === LINE_TYPE.SUBLINE.RIDGE) {
line.set({ selectable: true, strokeWidth: 5, stroke: '#1083E3' })
line.bringToFront()
} else {
line.set({ selectable: false, strokeWidth: 2, stroke: '#000000' })
}
})
})
/** 외벽선 관련 속성 처리*/
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((line) => {
if (type === TYPE.UP_DOWN) {
line.set({ selectable: true, visible: true, stroke: '#1085E5', strokeWidth: 5 })
line.setCoords()
line.bringToFront()
} else {
line.set({ selectable: false, visible: false })
}
})
})
/** outerLines 속성처리*/
const outerLines = canvas.getObjects().filter((obj) => obj.name === 'outerLine')
outerLines.forEach((line) => line.set({ visible: true }))
canvas.renderAll()
}, [type])
/** 팝업창이 열릴때,닫힐때 속성들을 처리*/
useEffect(() => {
addCanvasMouseEventListener('mouse:move', mouseMoveEvent)
addCanvasMouseEventListener('mouse:down', mouseDownEvent)
return () => {
canvas.discardActiveObject()
if (FOLLOW_LINE_REF.current != null) {
canvas.remove(FOLLOW_LINE_REF.current)
FOLLOW_LINE_REF.current = null
}
if (CONFIRM_LINE_REF.current != null) {
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()
initEvent()
}
}, [])
useEffect(() => {
const roofs = canvas?.getObjects().filter((obj) => obj.name === POLYGON_TYPE.ROOF)
if (roofs.length === 0) {
swalFire({ text: getMessage('roof.line.not.found') })
closePopup(id)
return
}
}, [])
/** object 선택이 변경될 때 처리*/
useEffect(() => {
if (FOLLOW_LINE_REF.current != null) {
canvas.remove(FOLLOW_LINE_REF.current)
canvas.renderAll()
FOLLOW_LINE_REF.current = null
}
if (selectedObject.current != null) {
selectedObject.current.set({ stroke: '#1083E3' })
selectedObject.current = null
}
if (!currentObject) return
clearRef()
canvas
.getObjects()
.filter((obj) => obj.name === 'checkPoint' || obj.name === 'checkLine')
.forEach((obj) => canvas.remove(obj))
canvas.renderAll()
if (CONFIRM_LINE_REF.current != null) {
canvas.remove(CONFIRM_LINE_REF.current)
canvas.renderAll()
CONFIRM_LINE_REF.current = null
}
currentObject.set({ stroke: '#EA10AC' })
selectedObject.current = currentObject
const followLine = new fabric.Line([currentObject.x1, currentObject.y1, currentObject.x2, currentObject.y2], {
stroke: '#000000',
strokeWidth: 4,
selectable: false,
name: 'followLine',
})
canvas.add(followLine)
followLine.bringToFront()
FOLLOW_LINE_REF.current = followLine
canvas.renderAll()
}, [currentObject])
const clearRef = () => {
if (type === TYPE.FLOW_LINE) {
// 안전한 ref 접근
if (FLOW_LINE_REF.POINTER_INPUT_REF.current) {
FLOW_LINE_REF.POINTER_INPUT_REF.current.value = ''
}
if (FLOW_LINE_REF.FILLED_INPUT_REF.current) {
FLOW_LINE_REF.FILLED_INPUT_REF.current.value = ''
}
const upRightChecked = FLOW_LINE_REF.UP_RIGHT_RADIO_REF.current?.checked || false
const downLeftChecked = FLOW_LINE_REF.DOWN_LEFT_RADIO_REF.current?.checked || false
if (upRightChecked || downLeftChecked) {
if (FLOW_LINE_REF.UP_RIGHT_RADIO_REF.current) {
FLOW_LINE_REF.UP_RIGHT_RADIO_REF.current.checked = !downLeftChecked
}
if (FLOW_LINE_REF.DOWN_LEFT_RADIO_REF.current) {
FLOW_LINE_REF.DOWN_LEFT_RADIO_REF.current.checked = !upRightChecked
}
} else {
if (FLOW_LINE_REF.DOWN_LEFT_RADIO_REF.current) {
FLOW_LINE_REF.DOWN_LEFT_RADIO_REF.current.checked = true
}
if (FLOW_LINE_REF.UP_RIGHT_RADIO_REF.current) {
FLOW_LINE_REF.UP_RIGHT_RADIO_REF.current.checked = false
}
}
}
if (type === TYPE.UP_DOWN) {
// 안전한 ref 접근
if (UP_DOWN_REF.POINTER_INPUT_REF.current) {
UP_DOWN_REF.POINTER_INPUT_REF.current.value = ''
}
if (UP_DOWN_REF.FILLED_INPUT_REF.current) {
UP_DOWN_REF.FILLED_INPUT_REF.current.value = ''
}
if (UP_DOWN_REF.UP_RADIO_REF.current) {
UP_DOWN_REF.UP_RADIO_REF.current.checked = true
}
if (UP_DOWN_REF.DOWN_RADIO_REF.current) {
UP_DOWN_REF.DOWN_RADIO_REF.current.checked = false
}
}
}
let currentCalculatedValue = 0
const mouseMoveEvent = (e) => {
// [MV-TRACE local]
const __isLocalMV = process.env.NEXT_PUBLIC_RUN_MODE === 'local'
// 기존에는 activeObject를 사용했으나, 이 기능에서는 선택된 라인을 비선택(selectable:false) 상태로 두므로
// 항상 selectedObject.current를 기준으로 계산한다.
const target = selectedObject.current
if (!target) {
if (__isLocalMV) console.warn('[MV-TRACE] selectedObject.current=null → skip')
lastMoveValidRef.current = false
return
}
const { top: targetTop, left: targetLeft } = target
const __mp = getIntersectMousePoint(e) || {}
const __mx = __mp.x
const __my = __mp.y
if (!Number.isFinite(targetTop) || !Number.isFinite(targetLeft) || !Number.isFinite(__mx) || !Number.isFinite(__my)) {
if (__isLocalMV) {
console.warn(
`[MV-TRACE] 좌표 무효 → skip targetTop=${targetTop} targetLeft=${targetLeft} mx=${__mx} my=${__my}`
)
}
lastMoveValidRef.current = false
return
}
const currentX = Big(__mx)
const currentY = Big(__my)
let value = ''
let direction = ''
if (Math.abs(target.y1 - target.y2) < 0.5) {
// 수평 라인
value = Big(targetTop).minus(currentY).times(10).round(0)
// 방향 감지
if (value.toNumber() > 0) {
direction = 'up' // 마우스가 라인 위쪽에 있음 (위로 움직임)
} else if (value.toNumber() < 0) {
direction = 'down' // 마우스가 라인 아래쪽에 있음 (아래로 움직임)
}
} else {
// 수직 라인
value = Big(targetLeft).minus(currentX).times(10).round(0).neg()
// 방향 감지
if (value.toNumber() > 0) {
direction = 'right' // 마우스가 라인 오른쪽에 있음 (오른쪽으로 움직임)
} else if (value.toNumber() < 0) {
direction = 'left' // 마우스가 라인 왼쪽에 있음 (왼쪽으로 움직임)
}
}
// followLine도 포인터를 따라가도록 동기화 (하나의 mouse:move 핸들러만 사용)
const followLine = FOLLOW_LINE_REF.current
if (followLine) {
if (followLine.x1 === followLine.x2) {
// 수직 라인: x만 이동
followLine.left = currentX.toNumber() - 2
} else {
// 수평 라인: y만 이동
followLine.top = currentY.toNumber() - 2
}
followLine.bringToFront()
followLine.setCoords && followLine.setCoords()
canvas.renderAll()
}
// 방향 정보를 사용하여 라디오 버튼 상태 업데이트
currentCalculatedValue = value.toNumber()
let __inputUpdated = false
if (typeRef.current === TYPE.FLOW_LINE) {
if (FLOW_LINE_REF.POINTER_INPUT_REF.current) {
FLOW_LINE_REF.POINTER_INPUT_REF.current.value = value.toNumber()
__inputUpdated = true
} else if (__isLocalMV) {
console.warn('[MV-TRACE] FLOW_LINE.POINTER_INPUT_REF.current=null → input 갱신 실패')
}
} else {
if (UP_DOWN_REF.POINTER_INPUT_REF.current) {
UP_DOWN_REF.POINTER_INPUT_REF.current.value = value.abs().toNumber()
__inputUpdated = true
} else if (__isLocalMV) {
console.warn('[MV-TRACE] UP_DOWN.POINTER_INPUT_REF.current=null → input 갱신 실패')
}
const midX = Big(target.x1).plus(target.x2).div(2)
const midY = Big(target.y1).plus(target.y2).div(2)
const wall = canvas.getObjects().find((obj) => obj.id === target.attributes.wallId)
const result = getSelectLinePosition(wall, target, {
testDistance: 5, // 테스트 거리
debug: true, // 디버깅 로그 출력
})
//console.log("1111litarget:::::", target);
//console.log("1111linePosition:::::", result.position); // 'top', 'bottom', 'left', 'right'
let linePosition = result.position
//console.log("1111linePosition:::::", direction, linePosition);
if (target.y1 === target.y2) {
//수평벽
const setRadioStates = (isUp) => {
if (UP_DOWN_REF.UP_RADIO_REF.current) {
UP_DOWN_REF.UP_RADIO_REF.current.checked = isUp
}
if (UP_DOWN_REF.DOWN_RADIO_REF.current) {
UP_DOWN_REF.DOWN_RADIO_REF.current.checked = !isUp
}
}
if (linePosition === 'top') {
setRadioStates(value.s !== -1)
} else if (linePosition === 'bottom') {
setRadioStates(value.s !== 1)
}
if (direction === 'up') {
}
/*
checkPoint = { x: midX.toNumber(), y: midY.plus(10).toNumber() }
if (wall.inPolygon(checkPoint)) { //선택라인이 내부
if (value.s === -1) {
console.log('1value:::', value.s)
if (UP_DOWN_REF.UP_RADIO_REF.current) UP_DOWN_REF.UP_RADIO_REF.current.checked = false
if (UP_DOWN_REF.DOWN_RADIO_REF.current) UP_DOWN_REF.DOWN_RADIO_REF.current.checked = true
} else {
if (UP_DOWN_REF.UP_RADIO_REF.current) UP_DOWN_REF.UP_RADIO_REF.current.checked = true
if (UP_DOWN_REF.DOWN_RADIO_REF.current) UP_DOWN_REF.DOWN_RADIO_REF.current.checked = false
}
} else { //
if (value.s === 1) { //선택라인이 외부
console.log('2value:::', value.s)
if (UP_DOWN_REF.UP_RADIO_REF.current) UP_DOWN_REF.UP_RADIO_REF.current.checked = false
if (UP_DOWN_REF.DOWN_RADIO_REF.current) UP_DOWN_REF.DOWN_RADIO_REF.current.checked = true
} else {
if (UP_DOWN_REF.UP_RADIO_REF.current) UP_DOWN_REF.UP_RADIO_REF.current.checked = true
if (UP_DOWN_REF.DOWN_RADIO_REF.current) UP_DOWN_REF.DOWN_RADIO_REF.current.checked = false
}
}
*/
} else {
const setRadioStates = (isUp) => {
if (UP_DOWN_REF.UP_RADIO_REF.current) {
UP_DOWN_REF.UP_RADIO_REF.current.checked = isUp
}
if (UP_DOWN_REF.DOWN_RADIO_REF.current) {
UP_DOWN_REF.DOWN_RADIO_REF.current.checked = !isUp
}
}
if (linePosition === 'left') {
setRadioStates(value.s !== 1)
} else if (linePosition === 'right') {
setRadioStates(value.s !== -1)
}
/*
checkPoint = { x: midX.plus(10).toNumber(), y: midY.toNumber() }
if (wall.inPolygon(checkPoint)) {
if (value.s === 1) {
console.log('3value:::', value.s)
if (UP_DOWN_REF.UP_RADIO_REF.current) UP_DOWN_REF.UP_RADIO_REF.current.checked = false
if (UP_DOWN_REF.DOWN_RADIO_REF.current) UP_DOWN_REF.DOWN_RADIO_REF.current.checked = true
} else {
if (UP_DOWN_REF.UP_RADIO_REF.current) UP_DOWN_REF.UP_RADIO_REF.current.checked = true
if (UP_DOWN_REF.DOWN_RADIO_REF.current) UP_DOWN_REF.DOWN_RADIO_REF.current.checked = false
}
} else {
if (value.s === -1) {
console.log('-1value:::', value.s)
if (UP_DOWN_REF.UP_RADIO_REF.current) UP_DOWN_REF.UP_RADIO_REF.current.checked = false
if (UP_DOWN_REF.DOWN_RADIO_REF.current) UP_DOWN_REF.DOWN_RADIO_REF.current.checked = true
} else {
if (UP_DOWN_REF.UP_RADIO_REF.current) UP_DOWN_REF.UP_RADIO_REF.current.checked = true
if (UP_DOWN_REF.DOWN_RADIO_REF.current) UP_DOWN_REF.DOWN_RADIO_REF.current.checked = false
}
}
*/
}
}
// [MV-TRACE] 이번 mouseMove 가 input 박스 갱신에 성공했는지 기록.
// 실패 시 mouseDownEvent 가 wallbaseLine 그리기/handleSave 를 차단.
lastMoveValidRef.current = __inputUpdated
if (__isLocalMV) {
console.log(
`[MV-TRACE] type=${typeRef.current} value=${value?.toString?.()} dir=${direction} ` +
`inputUpdated=${__inputUpdated} ` +
`selectedObject=${selectedObject.current ? 'set' : 'null'}`
)
}
}
const mouseDownEvent = (e) => {
canvas
.getObjects()
.filter((obj) => obj.name === 'checkPoint' || obj.name === 'checkLine')
.forEach((obj) => canvas.remove(obj))
canvas.renderAll()
const target = selectedObject.current
if (!target) return
// [MV-TRACE] 직전 mouseMove 가 input 박스 갱신에 실패했다면 wallbaseLine 미그리기.
if (!lastMoveValidRef.current) {
if (process.env.NEXT_PUBLIC_RUN_MODE === 'local') {
console.warn('[MV-TRACE] mouseDown abort: 직전 mouseMove input 갱신 실패 → wallbaseLine 미그리기')
}
return
}
const roofId = target.attributes.roofId
const followLine = canvas.getObjects().find((obj) => obj.name === 'followLine')
const confirmLine = new fabric.Line([followLine.x1, followLine.y1, followLine.x2, followLine.y2], {
left: followLine.left,
top: followLine.top,
stroke: '#000000',
strokeWidth: 4,
selectable: false,
parentId: roofId,
name: 'confirmLine',
target,
})
canvas.add(confirmLine)
canvas.renderAll()
CONFIRM_LINE_REF.current = confirmLine
handleSave()
}
const handleSave = () => {
if (CONFIRM_LINE_REF.current !== null) {
canvas.remove(CONFIRM_LINE_REF.current)
CONFIRM_LINE_REF.current = null
canvas.renderAll()
}
if (FOLLOW_LINE_REF.current !== null) {
canvas.remove(FOLLOW_LINE_REF.current)
FOLLOW_LINE_REF.current = null
canvas.renderAll()
}
if (UP_DOWN_REF.current !== null) {
canvas.remove(UP_DOWN_REF.current)
UP_DOWN_REF.current = null
canvas.renderAll()
}
const target = selectedObject.current !== null ? selectedObject.current : CONFIRM_LINE_REF.current?.target
if (!target) return
const roofId = target.attributes.roofId
const roof = canvas.getObjects().find((obj) => obj.id === roofId)
// 현이동, 동이동 추가
let flPointValue = FLOW_LINE_REF.POINTER_INPUT_REF.current?.value ?? 0
let flFilledValue = FLOW_LINE_REF.FILLED_INPUT_REF.current?.value ?? 0
flPointValue = flFilledValue > 0 || flFilledValue < 0 ? flFilledValue : flPointValue
const moveFlowLine = typeRef.current === TYPE.FLOW_LINE ? flPointValue : 0
let udPointValue = UP_DOWN_REF.POINTER_INPUT_REF.current?.value ?? 0
let udFilledValue = UP_DOWN_REF.FILLED_INPUT_REF.current?.value ?? 0
udPointValue = udFilledValue > 0 ? udFilledValue : udPointValue
const moveUpDown = typeRef.current === TYPE.UP_DOWN ? udPointValue : 0
roof.moveFlowLine = parseInt(moveFlowLine, 10) || 0
roof.moveUpDown = parseInt(moveUpDown, 10) || 0
roof.moveDirect = ''
roof.moveSelectLine = target
//console.log("target::::", target, roof.moveSelectLine)
const wall = canvas.getObjects().find((obj) => obj.name === POLYGON_TYPE.WALL && obj.attributes.roofId === roofId)
const baseLines = wall.baseLines
let centerPoint = wall.getCenterPoint()
let targetBaseLines = []
let isGableRoof
if (typeRef.current === TYPE.FLOW_LINE) {
const gableType = [LINE_TYPE.WALLLINE.GABLE, LINE_TYPE.WALLLINE.HIPANDGABLE]
if (baseLines.find((line) => gableType.includes(line.attributes.type))) {
isGableRoof = true
let gableStartIndex = baseLines.findIndex((line) => gableType.includes(line.attributes.type))
baseLines.forEach((line, index) => {
if (isGableRoof) {
const isEvenLine = (index - gableStartIndex) % 2 === 0
if (isEvenLine && !gableType.includes(line.attributes.type)) {
isGableRoof = false
}
}
})
} else {
isGableRoof = false
}
const lineVector =
Math.abs(target.y1 - target.y2) < 0.2
? FLOW_LINE_REF.UP_RIGHT_RADIO_REF.current.checked
? 'up'
: 'down'
: FLOW_LINE_REF.UP_RIGHT_RADIO_REF.current.checked
? 'right'
: 'left'
let checkBaseLines, currentBaseLines
roof.moveDirect = lineVector
switch (lineVector) {
case 'up':
checkBaseLines = baseLines.filter((line) => line.y1 === line.y2 && line.y1 < target.y1)
currentBaseLines = checkBaseLines.filter((line) => {
const minX = Math.min(target.x1, target.x2)
const maxX = Math.max(target.x1, target.x2)
return minX <= line.x1 && line.x1 <= maxX && minX <= line.x2 && line.x2 <= maxX
})
if (isGableRoof && currentBaseLines.length > 0) {
currentBaseLines.forEach((line) =>
targetBaseLines.push({
line,
distance: Big(line.y1).minus(target.y1).abs().toNumber(),
}),
)
} else {
checkBaseLines.forEach((line) =>
targetBaseLines.push({
line,
distance: Big(target.y1).minus(line.y1).abs().toNumber(),
}),
)
}
baseLines
.filter((line) => line.y1 === line.y2 && line.y1 < target.y1)
.forEach((line) => targetBaseLines.push({ line, distance: Big(target.y1).minus(line.y1).abs().toNumber() }))
break
case 'down':
checkBaseLines = baseLines.filter((line) => line.y1 === line.y2 && line.y1 > target.y1)
currentBaseLines = checkBaseLines.filter((line) => {
const minX = Math.min(target.x1, target.x2)
const maxX = Math.max(target.x1, target.x2)
return minX <= line.x1 && line.x1 <= maxX && minX <= line.x2 && line.x2 <= maxX
})
if (isGableRoof && currentBaseLines.length > 0) {
currentBaseLines.forEach((line) =>
targetBaseLines.push({
line,
distance: Big(line.y1).minus(target.y1).abs().toNumber(),
}),
)
} else {
checkBaseLines.forEach((line) =>
targetBaseLines.push({
line,
distance: Big(line.y1).minus(target.y1).abs().toNumber(),
}),
)
}
break
case 'right':
checkBaseLines = baseLines.filter((line) => line.x1 === line.x2 && line.x1 > target.x1)
currentBaseLines = checkBaseLines.filter((line) => {
const minY = Math.min(target.y1, target.y2)
const maxY = Math.max(target.y1, target.y2)
return minY <= line.y1 && line.y1 <= maxY && minY <= line.y2 && line.y2 <= maxY
})
if (isGableRoof && currentBaseLines.length > 0) {
currentBaseLines.forEach((line) =>
targetBaseLines.push({
line,
distance: Big(line.x1).minus(target.x1).abs().toNumber(),
}),
)
} else {
checkBaseLines.forEach((line) =>
targetBaseLines.push({
line,
distance: Big(target.x1).minus(line.x1).abs().toNumber(),
}),
)
}
break
case 'left':
checkBaseLines = baseLines.filter((line) => line.x1 === line.x2 && line.x1 < target.x1)
currentBaseLines = checkBaseLines.filter((line) => {
const minY = Math.min(target.y1, target.y2)
const maxY = Math.max(target.y1, target.y2)
return minY <= line.y1 && line.y1 <= maxY && minY <= line.y2 && line.y2 <= maxY
})
if (isGableRoof && currentBaseLines.length > 0) {
currentBaseLines.forEach((line) =>
targetBaseLines.push({
line,
distance: Big(line.x1).minus(target.x1).abs().toNumber(),
}),
)
} else {
checkBaseLines.forEach((line) =>
targetBaseLines.push({
line,
distance: Big(target.x1).minus(line.x1).abs().toNumber(),
}),
)
}
break
}
} else {
roof.moveDirect = UP_DOWN_REF.UP_RADIO_REF.current.checked ? 'out' : UP_DOWN_REF.DOWN_RADIO_REF.current.checked ? 'in' : 'out'
targetBaseLines.push({ line: target, distance: 0 })
}
// Remove duplicate lines
const uniqueLines = new Map()
targetBaseLines = targetBaseLines.filter((item) => {
const key = `${item.line.x1},${item.line.y1},${item.line.x2},${item.line.y2}`
if (!uniqueLines.has(key)) {
uniqueLines.set(key, true)
return true
}
return false
})
// Sort by distance
targetBaseLines.sort((a, b) => a.distance - b.distance)
targetBaseLines = targetBaseLines.filter((line) => line.distance === targetBaseLines[0].distance)
if (isGableRoof) {
const zeroLengthLines = targetBaseLines.filter(
(line) => Math.sqrt(Math.pow(line.line.x2 - line.line.x1, 2) + Math.pow(line.line.y2 - line.line.y1, 2)) < 1,
)
if (zeroLengthLines.length > 0) {
zeroLengthLines.forEach((line) => {
const findLine = line.line
const findCoords = [
{ x: findLine.x1, y: findLine.y1 },
{ x: findLine.x2, y: findLine.y2 },
]
wall.baseLines
.filter((baseLine) => {
return findCoords.some(
(coord) =>
(Math.abs(coord.x - baseLine.x1) < 0.1 && Math.abs(coord.y - baseLine.y1) < 0.1) ||
(Math.abs(coord.x - baseLine.x2) < 0.1 && Math.abs(coord.y - baseLine.y2) < 0.1),
)
})
.forEach((baseLine) => {
const isAlready = targetBaseLines.find((target) => target.line === baseLine)
if (isAlready) return
targetBaseLines.push({ line: baseLine, distance: targetBaseLines[0].distance })
})
})
}
}
let value
if (typeRef.current === TYPE.FLOW_LINE) {
value = (() => {
const filledValue = FLOW_LINE_REF.FILLED_INPUT_REF.current?.value
const pointerValue = FLOW_LINE_REF.POINTER_INPUT_REF.current?.value
if (filledValue && !isNaN(filledValue) && filledValue.trim() !== '') {
return Big(filledValue).times(2)
} else if (pointerValue && !isNaN(pointerValue) && pointerValue.trim() !== '') {
return Big(pointerValue).times(2)
}
return Big(0) // 기본값으로 0 반환 또는 다른 적절한 기본값
})()
if (Math.abs(target.y1 - target.y2) < 0.5) {
value = value.neg()
}
} else {
console.log('error::', UP_DOWN_REF.POINTER_INPUT_REF.current.value)
value = Big(UP_DOWN_REF?.FILLED_INPUT_REF?.current?.value?.trim() || UP_DOWN_REF?.POINTER_INPUT_REF?.current?.value?.trim() || '0')
const midX = Big(target.x1).plus(target.x2).div(2)
const midY = Big(target.y1).plus(target.y2).div(2)
const wall = canvas.getObjects().find((obj) => obj.id === target.attributes.wallId)
let checkPoint
if (target.y1 === target.y2) {
checkPoint = { x: midX.toNumber(), y: midY.plus(10).times(value.s).toNumber() }
} else {
checkPoint = { x: midX.plus(10).times(value.s).toNumber(), y: midY.toNumber() }
}
const inPolygon = wall.inPolygon(checkPoint)
// if (UP_DOWN_REF.UP_RADIO_REF.current.checked && inPolygon) {
// value = value.neg()
// } else if (UP_DOWN_REF.DOWN_RADIO_REF.current.checked && !inPolygon) {
// value = value.neg()
// }
}
// console.log("2222titarget:::::", target);
// console.log("2222저장된 moveSelectLine:", roof.moveSelectLine);
// console.log("222wall::::", wall.points)
const result = getSelectLinePosition(wall, target, {
testDistance: 5, // 테스트 거리
debug: true, // 디버깅 로그 출력
})
//console.log("2222linePosition:::::", result.position);
//console.log("222moveDirect:::::", roof.moveDirect);
// 디버깅용 분류 결과 확인
let linePosition = result.position
roof.movePosition = linePosition
value = value.div(10)
// [BR-TRACE local] handleSave 분기 진입 컨텍스트
const __isLocal = process.env.NEXT_PUBLIC_RUN_MODE === 'local'
if (__isLocal) {
console.log(
`[BR-TRACE] handleSave.enter linePosition=${linePosition} ` +
`roof.moveDirect=${roof.moveDirect} ` +
`UP.checked=${!!UP_DOWN_REF?.UP_RADIO_REF?.current?.checked} ` +
`DOWN.checked=${!!UP_DOWN_REF?.DOWN_RADIO_REF?.current?.checked} ` +
`target=(${target.x1},${target.y1})-(${target.x2},${target.y2}) ` +
`targetBaseLines=${targetBaseLines.length}`
)
}
// [FIX-1] linePosition='unknown' (또는 없음) 이면 부호 결정 불가 → 이동 자체 스킵.
// 기존: unknown 일 때 negation 안 거치고 부호 그대로 들어가 in/out 의도와 반대로 적용되는 case 발생.
if (linePosition !== 'top' && linePosition !== 'bottom' && linePosition !== 'left' && linePosition !== 'right') {
if (__isLocal) console.warn(`[BR-TRACE] linePosition=${linePosition} → 이동 스킵 (early return)`)
roof.drawHelpLine()
initEvent()
closePopup(id)
return
}
// 클로저 격리용: value 는 단일 사실(원본 부호) 로 두고, iter 마다 localValue 로만 부호 결정.
const __valueOriginal = value
const __targetIsHoriz = (linePosition === 'top' || linePosition === 'bottom')
targetBaseLines
.filter((line) => Math.sqrt(Math.pow(line.line.x2 - line.line.x1, 2) + Math.pow(line.line.y2 - line.line.y1, 2)) >= 1)
.forEach((targetItem, __iter) => {
const currentLine = targetItem.line
const __horizCurr = currentLine.y1 === currentLine.y2
// [FIX-2] mixed-orientation 가드: linePosition 가족과 currentLine 방향 불일치 → 이 라인은 스킵.
// gable 보강 push (zeroLengthLines.forEach) 가 좌표 매칭만으로 인접 라인을 추가하면서
// 가로/세로 혼재된 targetBaseLines 가 만들어지는 경우 방어.
if (__horizCurr !== __targetIsHoriz) {
if (__isLocal) {
console.warn(
`[BR-TRACE] iter=${__iter} ⚠️ 방향 불일치 skip: currentLine ${__horizCurr ? '수평' : '수직'} ` +
`↔ linePosition=${linePosition} (예상 ${__targetIsHoriz ? '수평' : '수직'})`
)
}
return
}
// [FIX-3] 클로저 격리: 외부 value 변이 금지. iter 마다 localValue 로만 부호 결정.
let localValue = __valueOriginal
if (UP_DOWN_REF?.DOWN_RADIO_REF?.current?.checked) {
if (linePosition === 'bottom' || linePosition === 'right') {
localValue = localValue.neg()
if (__isLocal) console.log(`[BR-TRACE] iter=${__iter} DOWN+(${linePosition}) → localValue.neg() ${__valueOriginal.toString()}${localValue.toString()}`)
} else if (__isLocal) {
console.log(`[BR-TRACE] iter=${__iter} DOWN no-neg (linePosition=${linePosition}) localValue=${localValue.toString()}`)
}
} else {
if (linePosition === 'top' || linePosition === 'left') {
localValue = localValue.neg()
if (__isLocal) console.log(`[BR-TRACE] iter=${__iter} UP+(${linePosition}) → localValue.neg() ${__valueOriginal.toString()}${localValue.toString()}`)
} else if (__isLocal) {
console.log(`[BR-TRACE] iter=${__iter} UP no-neg (linePosition=${linePosition}) localValue=${localValue.toString()}`)
}
}
const index = baseLines.findIndex((line) => line === currentLine)
const nextLine = baseLines[(index + 1) % baseLines.length]
const prevLine = baseLines[(index - 1 + baseLines.length) % baseLines.length]
let deltaX = 0
let deltaY = 0
if (__horizCurr) {
deltaY = localValue.toNumber()
if (__isLocal) console.log(`[BR-TRACE] iter=${__iter} 수평 currentLine idx=${index} deltaY=${deltaY}`)
} else {
deltaX = localValue.toNumber()
if (__isLocal) console.log(`[BR-TRACE] iter=${__iter} 수직 currentLine idx=${index} deltaX=${deltaX}`)
}
// [OVER_GUARD] 오버 이동(인접 라인 반전) 처리. 정책은 파일 상단 OVER_MOVE_POLICY 로 전환.
// 한계 계산 (공통):
// nLimit = (nextLine 의 free 끝점 좌표) - (currentLine 의 해당 끝점 좌표)
// pLimit = (prevLine 의 free 끝점 좌표) - (currentLine 의 해당 시작점 좌표)
// delta 와 같은 부호인 한계만 후보(반대 부호 한계는 인접 라인이 길어지는 방향).
// 정책별 동작:
// 'passthrough' → 가드 전체 스킵
// 'reject' → |delta| > |한계-EPS| 이면 해당 target 은 mutation 없이 건너뜀 (silent skip)
// 'clamp' → |delta| 를 |한계-EPS| 로 잘라 평행 직전까지 이동. roof.moveUpDown 도 동일 비율 동기화.
if (OVER_MOVE_POLICY !== 'passthrough') {
const __isHoriz = currentLine.y1 === currentLine.y2
const __raw = __isHoriz ? deltaY : deltaX
const __sgn = Math.sign(__raw)
if (__sgn !== 0) {
const __nLimit = __isHoriz ? (nextLine.y2 - currentLine.y2) : (nextLine.x2 - currentLine.x2)
const __pLimit = __isHoriz ? (prevLine.y1 - currentLine.y1) : (prevLine.x1 - currentLine.x1)
let __absLimit = Infinity
if (Math.sign(__nLimit) === __sgn) __absLimit = Math.min(__absLimit, Math.abs(__nLimit))
if (Math.sign(__pLimit) === __sgn) __absLimit = Math.min(__absLimit, Math.abs(__pLimit))
// [EPS] 평행 정확히 도달 시 인접 baseLine zero-length → polygon 중복 꼭짓점 → SkeletonBuilder 헛선 발생.
// OVER_EPS 여유 두어 인접 baseLine 길이 OVER_EPS(=0.5) 로 남기고 SHOULDER_ABSORBED(planeSize<1) 가 자연 흡수.
const __safeAbsLimit = Math.max(0, __absLimit - OVER_EPS)
const __isOver = Math.abs(__raw) > __safeAbsLimit + 0.001
if (__isOver && OVER_MOVE_POLICY === 'reject') {
// silent skip: mutation 자체 건너뜀. movingLineFromSkeleton 의 roof.points 확장도 막으려면 moveUpDown=0.
console.warn(
`[handleSave] OVER 거부(reject): ${__isHoriz ? 'deltaY' : 'deltaX'}=${__raw.toFixed(1)} > limit=${__safeAbsLimit.toFixed(1)} → target skip`
)
roof.moveUpDown = 0
return
}
if (OVER_MOVE_POLICY === 'clamp') {
const __limited = __sgn * Math.min(Math.abs(__raw), __safeAbsLimit)
if (Math.abs(__limited - __raw) > 0.001) {
console.warn(
`[handleSave] OVER 클램핑: ${__isHoriz ? 'deltaY' : 'deltaX'} ${__raw.toFixed(1)}${__limited.toFixed(1)} (nLimit=${__nLimit.toFixed(1)} pLimit=${__pLimit.toFixed(1)}, EPS=${OVER_EPS})`
)
if (__isHoriz) deltaY = __limited
else deltaX = __limited
// [SYNC roof.moveUpDown] movingLineFromSkeleton 은 roof.moveUpDown(mm) 로 roof.points 확장.
// delta 만 줄이고 moveUpDown 방치 시 wall 은 평행까지, roof.points 는 원본 양만큼 이동 →
// polygon 자기교차 → removeNonOrthogonalPoints 가 꼭짓점 축소 → "SK 가 wall 안에서 멈춤".
// __limited(cm) × 10 = mm.
roof.moveUpDown = Math.round(Math.abs(__limited) * 10)
}
}
}
}
currentLine.set({
x1: currentLine.x1 + deltaX,
y1: currentLine.y1 + deltaY,
x2: currentLine.x2 + deltaX,
y2: currentLine.y2 + deltaY,
startPoint: { x: currentLine.x1 + deltaX, y: currentLine.y1 + deltaY },
endPoint: { x: currentLine.x2 + deltaX, y: currentLine.y2 + deltaY },
})
const currentSize = calcLinePlaneSize({
x1: currentLine.x1,
y1: currentLine.y1,
x2: currentLine.x2,
y2: currentLine.y2,
})
currentLine.attributes.planeSize = currentSize
currentLine.attributes.actualSize = currentSize
nextLine.set({
x1: currentLine.x2,
y1: currentLine.y2,
startPoint: { x: currentLine.x2, y: currentLine.y2 },
})
const nextSize = calcLinePlaneSize({ x1: nextLine.x1, y1: nextLine.y1, x2: nextLine.x2, y2: nextLine.y2 })
nextLine.attributes.planeSize = nextSize
nextLine.attributes.actualSize = nextSize
prevLine.set({
x2: currentLine.x1,
y2: currentLine.y1,
endPoint: { x: currentLine.x1, y: currentLine.y1 },
})
const prevSize = calcLinePlaneSize({ x1: prevLine.x1, y1: prevLine.y1, x2: prevLine.x2, y2: prevLine.y2 })
prevLine.attributes.planeSize = prevSize
prevLine.attributes.actualSize = prevSize
})
roof.drawHelpLine()
initEvent()
closePopup(id)
}
// javascript
return {
TYPE,
closePopup,
buttonType,
type,
setType,
FLOW_LINE_REF,
UP_DOWN_REF,
handleSave,
}
}