qcast-front/src/hooks/roofcover/useMovementSetting.js
hyojun.choi 4d8fc60cf1 동이동 형올림내림 undo 했을때 선 색깔 이상
보조선 잡고 삭제시 지붕의 보조선은 삭제 안되고있음.
도머 설치 시 복사 후 undo 세번해야 다사라짐
도머 오프셋이동 시 안됨 수정
2026-06-05 13:57:51 +09:00

1020 lines
44 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'
import { useUndoRedo } from '@/hooks/useUndoRedo'
// [정책 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'
// [2026-05-18 라벨 5mm 단축 fix 1차] 0.5 → 0.1. 인접 edge 잔여 1mm → SHOULDER_ABSORBED(<10) 자연 흡수, 라벨 1mm 차는 round-trip 인정 범위.
const OVER_EPS = 0.1
//동선이동 형 올림 내림
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 { saveSnapshot } = useUndoRedo()
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' })
}
})
})
// ─────────────────────────────────────────────────────────────────────────
// [2차 마루이동 클릭불가 보강 2026-04-30]
// 증상: 1차 마루이동 성공 → 2차 팝업 오픈 시 ridge 클릭해도 선택 안됨.
// mouseMove 마다 [MV-TRACE] selectedObject.current=null → skip 무한 발생.
// 원인: 위 forEach 가 roof.innerLines 를 순회 → drawHelpLine 으로 ridge 재생성 시
// innerLines 가 stale 이거나 새 ridge 가 빠져 selectable=true 누락 →
// 클릭 → fabric selection:created 미발화 → currentObjectState=null 유지 →
// useEffect[currentObject] 가 selectedObject.current 를 못 갱신.
// 방침: 기존 roof.innerLines 경로는 그대로 두고(정상 케이스 유지),
// canvas 레벨 ridge 직접 조회로 selectable=true 보강. 누락 시만 효과,
// 정상 시 동일 객체에 동일 set 재호출이라 부작용 없음.
// ─────────────────────────────────────────────────────────────────────────
if (type === TYPE.FLOW_LINE) {
canvas
.getObjects()
.filter((obj) => obj.name === LINE_TYPE.SUBLINE.RIDGE)
.forEach((ridge) => {
ridge.set({ selectable: true, strokeWidth: 5, stroke: '#1083E3' })
ridge.bringToFront()
})
}
/** 외벽선 관련 속성 처리*/
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
}
restoreLineStyles()
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])
/** 동선이동/형올림내림 적용 전(평상) 선 스타일로 되돌린다.
* saveSnapshot 직전과 팝업 cleanup 양쪽에서 호출해, undo 시 강조색이 스냅샷에 박혀 복원되지 않도록 한다. */
const restoreLineStyles = () => {
const roofs = canvas.getObjects().filter((obj) => obj.name === POLYGON_TYPE.ROOF)
roofs.forEach((roof) => {
roof.set({ stroke: '#1083E3' })
roof.innerLines.forEach((line) => line.set({ selectable: true, strokeWidth: 2, stroke: '#1083E3' }))
})
const walls = canvas.getObjects().filter((obj) => obj.name === POLYGON_TYPE.WALL)
walls.forEach((wall) => {
if (wall.baseLines.length === 0) {
wall.baseLines = canvas.getObjects().filter((obj) => obj.name === 'baseLine' && obj.attributes.wallId === wall.id)
}
wall.baseLines.forEach((baseLine) => {
baseLine.set({ selectable: false, visible: false })
})
})
const outerLines = canvas.getObjects().filter((obj) => obj.name === 'outerLine')
outerLines.forEach((line) => line.set({ visible: true }))
canvas.renderAll()
}
const clearRef = () => {
if (type === TYPE.FLOW_LINE) {
// 안전한 ref 접근
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);
// [라디오 토글 drift 2026-05-12] strict y1===y2 는 (-1153,-1153.1) 같은 0.1 drift 케이스에서
// false → 수직 분기로 떨어져 top/bottom 라디오 갱신 코드 미실행 → 桁을 上げる 고정 버그.
// line 311 과 동일한 tolerance(0.5) 적용.
if (Math.abs(target.y1 - target.y2) < 0.5) {
//수평벽
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()
}
// [undo 색상 fix] 스냅샷은 동선이동/형올림내림 적용 직전 상태를 저장한다.
// 팝업 오픈 시 입힌 강조색이 스냅샷에 박히면 undo 시 강조색이 복원되므로,
// 스냅샷 저장 전에 평상 선 스타일로 되돌린다.
restoreLineStyles()
saveSnapshot()
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
})
// ─────────────────────────────────────────────────────────────────────────
// [FLOW_LINE x-overlap 사전필터 2026-04-30 수정]
// 증상 1 (기존 ‘동률’ 케이스): 수평 마루 1회 이동 → wall.baseLines 양쪽 수직선이 다 길어짐.
// 증상 2 (오늘 발견 ‘반대측 wall 선택’): 1차 down 이동 후 2차 이동 시
// 대상 wall(idx=1) 이 멀어져, ridge x-range 무관한 반대측 wall(idx=5) 이
// y-distance 만으로 "가까움" 을 차지해 잘못 선택. 멀리 있는 vertical wall 이
// 변형됨.
// 원인: 거리 최소 필터(아래 sort+filter)가 x-overlap 무관하게 동작.
// 기존 동률필터는 length>1 일 때만 동작하여 이미 거리최소필터로 1개만
// 남은 케이스 보강 안 됨.
// 수정: 거리최소필터 **이전** 에 ridge 좌표범위와 겹치는 baseLine 만 우선
// 추리고, 그 결과가 0개면 fallback 으로 전체 후보 유지(기존 동작).
// 이후 거리최소필터가 overlap 후보 중에서만 선택 → 반대측 wall 차단.
// UP_DOWN 은 항상 1개라 영향 없음.
// ─────────────────────────────────────────────────────────────────────────
if (typeRef.current === TYPE.FLOW_LINE && targetBaseLines.length > 0) {
const targetIsHorizontal = Math.abs(target.y1 - target.y2) < 0.5
const tMin = targetIsHorizontal ? Math.min(target.x1, target.x2) : Math.min(target.y1, target.y2)
const tMax = targetIsHorizontal ? Math.max(target.x1, target.x2) : Math.max(target.y1, target.y2)
const overlapping = targetBaseLines.filter((item) => {
const ln = item.line
const lMin = targetIsHorizontal ? Math.min(ln.x1, ln.x2) : Math.min(ln.y1, ln.y2)
const lMax = targetIsHorizontal ? Math.max(ln.x1, ln.x2) : Math.max(ln.y1, ln.y2)
return !(lMax < tMin || lMin > tMax)
})
if (overlapping.length > 0 && overlapping.length < targetBaseLines.length) {
if (process.env.NEXT_PUBLIC_RUN_MODE === 'local') {
console.log(
`[FLOW_LINE-FILTER] 사전필터: ${targetBaseLines.length}개 중 ridge ${targetIsHorizontal ? 'x' : 'y'}=${tMin}~${tMax} 겹침 ${overlapping.length}개만 잔존`
)
}
targetBaseLines = overlapping
}
}
// 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/2 적용 범위] UP_DOWN(형이동) 에서만 적용.
// FLOW_LINE(마루이동) 은 ridge 가 wall 내부라 getSelectLinePosition 이 'unknown' 반환 →
// early return / 방향 가드 적용 시 wallbaseLine 동반 이동이 통째로 차단됨.
const __isUpDown = typeRef.current === TYPE.UP_DOWN
// [FIX-1] UP_DOWN 한정: linePosition='unknown' 이면 부호 결정 불가 → 이동 자체 스킵.
if (__isUpDown && linePosition !== 'top' && linePosition !== 'bottom' && linePosition !== 'left' && linePosition !== 'right') {
if (__isLocal) console.warn(`[BR-TRACE] (UP_DOWN) 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
// [방향 가드 drift 2026-05-12] strict y1===y2 는 UI/Big.js round 로 (-1153, -1153.1) 같은
// 0.1 drift 케이스에서 false → 수직 오판정 → FIX-2 가드가 정상 수평 라인을 skip.
// 동일 파일 line 575 (Math.abs<0.2) 와 feedback_same_point_tolerance 메모리에 맞춰 0.5 tolerance.
const __horizCurr = Math.abs(currentLine.y1 - currentLine.y2) < 0.5
// [FIX-2] UP_DOWN 한정: mixed-orientation 가드. linePosition 가족과 currentLine 방향 불일치 → 이 라인 skip.
// FLOW_LINE 에서는 lineVector 가 이미 방향별로 targetBaseLines 를 필터링 → 별도 가드 불필요.
if (__isUpDown && __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,
}
}