dev #931
@ -18,8 +18,7 @@ import { drawSkeletonRidgeRoof, drawSkeletonRidgeRoofFromBaseLines, verifyMoveBo
|
||||
const DEBUG_LABEL_NAME = '__debugLabel'
|
||||
|
||||
function __isDebugLabelsEnabled() {
|
||||
// 디버그 라벨(H-1, B-4, RG-1 등) 표시 비활성화
|
||||
return false
|
||||
return process.env.NEXT_PUBLIC_RUN_MODE === 'local'
|
||||
}
|
||||
|
||||
function __classifyLineForLabel(obj) {
|
||||
|
||||
@ -58,6 +58,8 @@ export function useMovementSetting(id) {
|
||||
|
||||
const CONFIRM_LINE_REF = useRef(null)
|
||||
const FOLLOW_LINE_REF = useRef(null)
|
||||
// [MV-TRACE] 직전 mouseMove 가 input 박스 갱신에 성공했는지. false 면 mouseDown 에서 wallbaseLine 미그리기.
|
||||
const lastMoveValidRef = useRef(false)
|
||||
|
||||
/** 동선이동, 형이동 선택시 속성 처리*/
|
||||
useEffect(() => {
|
||||
@ -94,6 +96,28 @@ export function useMovementSetting(id) {
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────
|
||||
// [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) => {
|
||||
@ -255,23 +279,33 @@ export function useMovementSetting(id) {
|
||||
let currentCalculatedValue = 0
|
||||
|
||||
const mouseMoveEvent = (e) => {
|
||||
//console.log('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) return
|
||||
|
||||
// 디버깅 로그 추가
|
||||
// if (typeRef.current === TYPE.UP_DOWN) {
|
||||
// console.log('UP_DOWN_REF.POINTER_INPUT_REF.current:', UP_DOWN_REF.POINTER_INPUT_REF.current);
|
||||
// if (!UP_DOWN_REF.POINTER_INPUT_REF.current) {
|
||||
// console.warn('UP_DOWN_REF.POINTER_INPUT_REF.current is null/undefined');
|
||||
// }
|
||||
// }
|
||||
if (!target) {
|
||||
if (__isLocalMV) console.warn('[MV-TRACE] selectedObject.current=null → skip')
|
||||
lastMoveValidRef.current = false
|
||||
return
|
||||
}
|
||||
|
||||
const { top: targetTop, left: targetLeft } = target
|
||||
const currentX = Big(getIntersectMousePoint(e).x)
|
||||
const currentY = Big(getIntersectMousePoint(e).y)
|
||||
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 = ''
|
||||
@ -317,15 +351,20 @@ export function useMovementSetting(id) {
|
||||
|
||||
currentCalculatedValue = value.toNumber()
|
||||
|
||||
let __inputUpdated = false
|
||||
if (typeRef.current === TYPE.FLOW_LINE) {
|
||||
// ref가 존재하는지 확인 후 값 설정
|
||||
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 {
|
||||
// UP_DOWN 타입일 때 안전한 접근
|
||||
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)
|
||||
@ -424,6 +463,16 @@ export function useMovementSetting(id) {
|
||||
*/
|
||||
}
|
||||
}
|
||||
// [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) => {
|
||||
@ -433,10 +482,17 @@ export function useMovementSetting(id) {
|
||||
.forEach((obj) => canvas.remove(obj))
|
||||
canvas.renderAll()
|
||||
|
||||
//const target = selectedObject.current
|
||||
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')
|
||||
|
||||
@ -498,6 +554,7 @@ export function useMovementSetting(id) {
|
||||
//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
|
||||
@ -640,6 +697,41 @@ export function useMovementSetting(id) {
|
||||
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)
|
||||
@ -726,35 +818,81 @@ export function useMovementSetting(id) {
|
||||
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((target) => {
|
||||
const currentLine = target.line
|
||||
.forEach((targetItem, __iter) => {
|
||||
const currentLine = targetItem.line
|
||||
const __horizCurr = currentLine.y1 === currentLine.y2
|
||||
|
||||
//console.log("linePosition::::::::::::::", linePosition)
|
||||
// [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) {
|
||||
//position확인
|
||||
if (linePosition === 'bottom' || linePosition === 'right') {
|
||||
//console.log("1value::::::::::::::", value.toString())
|
||||
value = value.neg()
|
||||
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') {
|
||||
//console.log("1value::::::::::::::", value.toString())
|
||||
value = value.neg()
|
||||
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()}`)
|
||||
}
|
||||
}
|
||||
|
||||
//console.log("2value::::::::::::::", value.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 (currentLine.y1 === currentLine.y2) {
|
||||
deltaY = value.toNumber()
|
||||
if (__horizCurr) {
|
||||
deltaY = localValue.toNumber()
|
||||
if (__isLocal) console.log(`[BR-TRACE] iter=${__iter} 수평 currentLine idx=${index} deltaY=${deltaY}`)
|
||||
} else {
|
||||
deltaX = value.toNumber()
|
||||
deltaX = localValue.toNumber()
|
||||
if (__isLocal) console.log(`[BR-TRACE] iter=${__iter} 수직 currentLine idx=${index} deltaX=${deltaX}`)
|
||||
}
|
||||
|
||||
// [OVER_GUARD] 오버 이동(인접 라인 반전) 처리. 정책은 파일 상단 OVER_MOVE_POLICY 로 전환.
|
||||
|
||||
@ -32,6 +32,7 @@ import { roofsState } from '@/store/roofAtom'
|
||||
import { useText } from '@/hooks/useText'
|
||||
import { QLine } from '@/components/fabric/QLine'
|
||||
import { useUndoRedo } from '@/hooks/useUndoRedo'
|
||||
import { calcLineActualSize2 } from '@/util/qpolygon-utils'
|
||||
|
||||
export function useRoofAllocationSetting(id) {
|
||||
const canvas = useRecoilValue(canvasState)
|
||||
@ -410,7 +411,32 @@ export function useRoofAllocationSetting(id) {
|
||||
if (roof.separatePolygon.length === 0) {
|
||||
roof.innerLines.forEach((line) => {
|
||||
if ((!line.attributes.actualSize || line.attributes?.actualSize === 0) && line.length > 1) {
|
||||
line.set({ attributes: { ...line.attributes, actualSize: line.attributes.planeSize } })
|
||||
// ─────────────────────────────────────────────────────────────────
|
||||
// [hip actualSize fallback 2026-04-30]
|
||||
// 기존: actualSize 미설정/0 시 planeSize 로 fallback → diagonal(hip)
|
||||
// 까지 actualSize=planeSize 가 되어 伏せ図/配置面 라벨 토글 무효화.
|
||||
// 수정: diagonal 은 pitch 로 actualSize 산출(calcLineActualSize2),
|
||||
// horizontal/vertical 만 planeSize fallback 유지.
|
||||
// pitch 우선순위: line.attributes.pitch → roof.attributes.pitch
|
||||
// → roof.roofMaterial.pitch → 0(편평).
|
||||
// ─────────────────────────────────────────────────────────────────
|
||||
const dx = Math.abs((line.x2 ?? 0) - (line.x1 ?? 0))
|
||||
const dy = Math.abs((line.y2 ?? 0) - (line.y1 ?? 0))
|
||||
const isDiagonal = dx > 0.5 && dy > 0.5
|
||||
let newActualSize
|
||||
if (isDiagonal) {
|
||||
const pitch = line.attributes?.pitch ?? roof.attributes?.pitch ?? roof.roofMaterial?.pitch ?? 0
|
||||
newActualSize = Number(calcLineActualSize2({ x1: line.x1, y1: line.y1, x2: line.x2, y2: line.y2 }, getDegreeByChon(pitch)))
|
||||
} else {
|
||||
newActualSize = line.attributes.planeSize
|
||||
}
|
||||
line.set({ attributes: { ...line.attributes, actualSize: newActualSize } })
|
||||
// lengthText 에 actualSize 즉시 propagate (없으면 모드 토글이 if(obj.actualSize)
|
||||
// 가드에 막혀 토글 무효화).
|
||||
const lengthText = canvas.getObjects().find((o) => o.name === 'lengthText' && o.parentId === line.id)
|
||||
if (lengthText) {
|
||||
lengthText.set({ actualSize: newActualSize })
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
@ -441,6 +467,150 @@ export function useRoofAllocationSetting(id) {
|
||||
return result
|
||||
}
|
||||
|
||||
/**
|
||||
* extensionLine + 동일 직선상 SK(HIP/RIDGE/VALLEY) 1:1 통합.
|
||||
* 빨강 보조선(extensionLine)과 baseLine 코너에서 같은 방향으로 뻗어나가는 SK 라인을
|
||||
* 한 QLine 으로 합쳐서 roofBase.innerLines 에 둔다.
|
||||
* - 대각선 길이가 단일값으로 산출됨
|
||||
* - 각도 기반 면적 계산이 한 라인 단위로 가능
|
||||
* - splitPolygonWithLines 의 graph 토폴로지가 정상 연결됨
|
||||
* 호출 위치: apply() 내 roofBase.lines 병합 직후, split 직전.
|
||||
*/
|
||||
const integrateExtensionLines = (roofBase) => {
|
||||
if (!roofBase?.lines || !Array.isArray(roofBase.innerLines)) return
|
||||
|
||||
const extLines = roofBase.lines.filter((l) => l.lineName === 'extensionLine')
|
||||
if (extLines.length === 0) {
|
||||
console.log(`[INTEGRATE] roofBase.id=${roofBase.id} extensionLine 없음 → skip`)
|
||||
return
|
||||
}
|
||||
|
||||
const TOL = 1.0
|
||||
const isSamePt = (p1, p2) => Math.hypot(p1.x - p2.x, p1.y - p2.y) < TOL
|
||||
const isCollinear = (l1, l2) => {
|
||||
const v1x = l1.x2 - l1.x1,
|
||||
v1y = l1.y2 - l1.y1
|
||||
const v2x = l2.x2 - l2.x1,
|
||||
v2y = l2.y2 - l2.y1
|
||||
const m1 = Math.hypot(v1x, v1y) || 1
|
||||
const m2 = Math.hypot(v2x, v2y) || 1
|
||||
const cross = (v1x * v2y - v1y * v2x) / (m1 * m2)
|
||||
return Math.abs(cross) < 0.02 // ~1.1°
|
||||
}
|
||||
|
||||
const removedExt = []
|
||||
const removedSk = []
|
||||
const merged = []
|
||||
// [2026-04-30] sk 가 SK 빌드 시점에 이미 ext 끝점까지 연장된 경우(sk.__extended).
|
||||
// merge 하면 mergedLine 이 sk 의 옛(축소된) 좌표로 되돌아가므로 스킵.
|
||||
// ext 는 roofBase.lines 에서만 제거하고 canvas 에는 invisible 데이터로 그대로 둠.
|
||||
const extLinesOnly = []
|
||||
|
||||
extLines.forEach((ext) => {
|
||||
const extP1 = { x: ext.x1, y: ext.y1 }
|
||||
const extP2 = { x: ext.x2, y: ext.y2 }
|
||||
|
||||
const sk = roofBase.innerLines.find((sl) => {
|
||||
if (removedSk.includes(sl)) return false
|
||||
// [auxiliaryLine 제외 2026-04-30] integrateExtensionLines 는 SK auto-build
|
||||
// hip stub + ext 의 1:1 통합용. 사용자가 그린 auxiliaryLine 은 이미 완전한
|
||||
// 분할선이므로 ext 와 병합하면 좌표가 어긋남(코너 끝점 손실).
|
||||
if (sl.name === 'auxiliaryLine') return false
|
||||
const skP1 = { x: sl.x1, y: sl.y1 }
|
||||
const skP2 = { x: sl.x2, y: sl.y2 }
|
||||
const sharesP1 = isSamePt(extP1, skP1) || isSamePt(extP1, skP2)
|
||||
const sharesP2 = isSamePt(extP2, skP1) || isSamePt(extP2, skP2)
|
||||
if ((sharesP1 ? 1 : 0) + (sharesP2 ? 1 : 0) !== 1) return false
|
||||
return isCollinear(ext, sl)
|
||||
})
|
||||
|
||||
if (!sk) {
|
||||
console.log(`[INTEGRATE] ext 짝없음 ` + `(${extP1.x.toFixed(1)},${extP1.y.toFixed(1)})→(${extP2.x.toFixed(1)},${extP2.y.toFixed(1)})`)
|
||||
return
|
||||
}
|
||||
|
||||
// [2026-04-30] sk 가 이미 SK 빌드 단계에서 연장된 경우 → merge 스킵.
|
||||
if (sk.__extended) {
|
||||
console.log(`[INTEGRATE] sk 이미 연장됨(id=${sk.id}) → merge 스킵, ext 는 lines 에서만 제거(canvas 유지)`)
|
||||
extLinesOnly.push(ext)
|
||||
return
|
||||
}
|
||||
|
||||
const skP1 = { x: sk.x1, y: sk.y1 }
|
||||
const skP2 = { x: sk.x2, y: sk.y2 }
|
||||
const sharedPt = isSamePt(extP1, skP1) || isSamePt(extP1, skP2) ? extP1 : extP2
|
||||
const extOuter = isSamePt(extP1, sharedPt) ? extP2 : extP1
|
||||
const skOuter = isSamePt(skP1, sharedPt) ? skP2 : skP1
|
||||
|
||||
const extLen = Math.hypot(extP2.x - extP1.x, extP2.y - extP1.y)
|
||||
const skLen = Math.hypot(skP2.x - skP1.x, skP2.y - skP1.y)
|
||||
const totalLen = extLen + skLen
|
||||
|
||||
// SK 의 planeSize/actualSize 를 비율로 확장 (SK 길이가 0 이면 그대로)
|
||||
// [정밀도 0.01 유지] 저장값 소수점 2자리 (면적 계산 정확도). 표시는 addLengthText 에서 round.
|
||||
const skPlane = sk.attributes?.planeSize ?? 0
|
||||
const skActual = sk.attributes?.actualSize ?? 0
|
||||
const ratio = skLen > 0 ? totalLen / skLen : 1
|
||||
const newPlane = Math.round(skPlane * ratio * 100) / 100
|
||||
const newActual = Math.round(skActual * ratio * 100) / 100
|
||||
|
||||
const mergedLine = new QLine([extOuter.x, extOuter.y, skOuter.x, skOuter.y], {
|
||||
parentId: sk.parentId,
|
||||
parent: sk.parent,
|
||||
stroke: sk.stroke,
|
||||
strokeWidth: sk.strokeWidth,
|
||||
fontSize: sk.fontSize,
|
||||
visible: sk.visible,
|
||||
selectable: sk.selectable,
|
||||
name: sk.name,
|
||||
lineName: sk.lineName,
|
||||
direction: sk.direction,
|
||||
roofId: sk.roofId,
|
||||
attributes: {
|
||||
...(sk.attributes || {}),
|
||||
planeSize: newPlane,
|
||||
actualSize: newActual,
|
||||
},
|
||||
})
|
||||
mergedLine.length = totalLen
|
||||
|
||||
console.log(
|
||||
`[INTEGRATE] merge ` +
|
||||
`ext=(${extP1.x.toFixed(1)},${extP1.y.toFixed(1)})→(${extP2.x.toFixed(1)},${extP2.y.toFixed(1)}) len=${extLen.toFixed(1)} ` +
|
||||
`sk[${sk.lineName || sk.name}]=(${skP1.x.toFixed(1)},${skP1.y.toFixed(1)})→(${skP2.x.toFixed(1)},${skP2.y.toFixed(1)}) len=${skLen.toFixed(1)} ` +
|
||||
`→ total=${totalLen.toFixed(1)} (plane ${skPlane}→${newPlane}, actual ${skActual}→${newActual})`,
|
||||
)
|
||||
|
||||
removedExt.push(ext)
|
||||
removedSk.push(sk)
|
||||
merged.push(mergedLine)
|
||||
})
|
||||
|
||||
if (merged.length === 0 && extLinesOnly.length === 0) {
|
||||
console.log(`[INTEGRATE] 통합 대상 없음 ext=${extLines.length}`)
|
||||
return
|
||||
}
|
||||
|
||||
// roofBase.lines 에서: 기존 merge 로 제거할 ext + __extended 케이스의 ext 둘 다 제외
|
||||
// → split 단계에서 ext 가 외곽 처마처럼 처리되는 것 방지.
|
||||
roofBase.lines = roofBase.lines.filter((l) => !removedExt.includes(l) && !extLinesOnly.includes(l))
|
||||
roofBase.innerLines = roofBase.innerLines.filter((l) => !removedSk.includes(l))
|
||||
roofBase.innerLines = [...roofBase.innerLines, ...merged]
|
||||
|
||||
// canvas 정리:
|
||||
// - 기존 merge 케이스: 원본 ext + 원본 sk 둘 다 canvas 제거 (mergedLine 이 대체)
|
||||
// - __extended 케이스: ext 는 invisible 데이터로 canvas 에 그대로 유지 (디버그용), sk 는 이미 연장됨
|
||||
removedExt.forEach((l) => canvas.remove(l))
|
||||
removedSk.forEach((l) => canvas.remove(l))
|
||||
|
||||
console.log(
|
||||
`[INTEGRATE] 완료 roofBase.id=${roofBase.id} ` +
|
||||
`ext제거=${removedExt.length} sk제거=${removedSk.length} merged=${merged.length} ` +
|
||||
`extKeptInCanvas=${extLinesOnly.length} ` +
|
||||
`→ lines=${roofBase.lines.length} innerLines=${roofBase.innerLines.length}`,
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* 지붕면 할당
|
||||
*/
|
||||
@ -448,13 +618,35 @@ export function useRoofAllocationSetting(id) {
|
||||
saveSnapshot()
|
||||
const roofBases = canvas.getObjects().filter((obj) => obj.name === POLYGON_TYPE.ROOF && !obj.roofMaterial)
|
||||
const wallLines = canvas.getObjects().filter((obj) => obj.name === POLYGON_TYPE.WALL)
|
||||
console.log(`[ALLOC] apply() 진입. roofBases=${roofBases.length}`)
|
||||
roofBases.forEach((roofBase) => {
|
||||
try {
|
||||
// 지붕 할당 로직에 extensionLine 추가
|
||||
const roofEaveHelpLines = canvas.getObjects().filter((obj) =>
|
||||
(obj.lineName === 'eaveHelpLine' || obj.lineName === 'extensionLine') &&
|
||||
obj.roofId === roofBase.id
|
||||
const roofEaveHelpLines = canvas
|
||||
.getObjects()
|
||||
.filter((obj) => (obj.lineName === 'eaveHelpLine' || obj.lineName === 'extensionLine') && obj.roofId === roofBase.id)
|
||||
// [ALLOC-DEBUG] canvas 픽업 스냅샷 (삭제 직전 상태)
|
||||
const snapByLineName = {}
|
||||
const snapByName = {}
|
||||
roofEaveHelpLines.forEach((o) => {
|
||||
snapByLineName[o.lineName] = (snapByLineName[o.lineName] || 0) + 1
|
||||
snapByName[o.name || '?'] = (snapByName[o.name || '?'] || 0) + 1
|
||||
})
|
||||
console.log(
|
||||
`[ALLOC-DEBUG] roofBase.id=${roofBase.id} ` +
|
||||
`roofBase.lines=${roofBase.lines?.length || 0} ` +
|
||||
`roofBase.innerLines=${roofBase.innerLines?.length || 0} ` +
|
||||
`canvas픽업=${roofEaveHelpLines.length} ` +
|
||||
`byLineName=${JSON.stringify(snapByLineName)} ` +
|
||||
`byName=${JSON.stringify(snapByName)}`,
|
||||
)
|
||||
roofEaveHelpLines.forEach((o, i) => {
|
||||
console.log(
|
||||
` [ALLOC-DEBUG] picked[${i}] name=${o.name} lineName=${o.lineName} ` +
|
||||
`(${o.x1?.toFixed(1)},${o.y1?.toFixed(1)})→(${o.x2?.toFixed(1)},${o.y2?.toFixed(1)}) ` +
|
||||
`len=${Math.hypot((o.x2 || 0) - (o.x1 || 0), (o.y2 || 0) - (o.y1 || 0)).toFixed(1)}`,
|
||||
)
|
||||
})
|
||||
// console.log('roofBase.id:', roofBase.id)
|
||||
// console.log('roofEaveHelpLines:', roofEaveHelpLines)
|
||||
// console.log('extensionLines found:', roofEaveHelpLines.filter(l => l.lineName === 'extensionLine'))
|
||||
@ -466,42 +658,50 @@ export function useRoofAllocationSetting(id) {
|
||||
// Filter out lines from roofBase.lines that share any points with newEaveLines
|
||||
|
||||
// extensionLine과 일반 eaveHelpLine 분리
|
||||
const extensionLines = newEaveLines.filter(line => line.lineName === 'extensionLine')
|
||||
const normalEaveLines = newEaveLines.filter(line => line.lineName === 'eaveHelpLine')
|
||||
const extensionLines = newEaveLines.filter((line) => line.lineName === 'extensionLine')
|
||||
const normalEaveLines = newEaveLines.filter((line) => line.lineName === 'eaveHelpLine')
|
||||
// console.log('extensionLines count:', extensionLines.length)
|
||||
// console.log('normalEaveLines count:', normalEaveLines.length)
|
||||
|
||||
// 일반 eaveHelpLine만 Overlap 판단에 사용
|
||||
const linesToKeep = roofBase.lines.filter(roofLine => {
|
||||
const shouldRemove = normalEaveLines.some(eaveLine => {
|
||||
const linesToKeep = roofBase.lines.filter((roofLine) => {
|
||||
const shouldRemove = normalEaveLines.some((eaveLine) => {
|
||||
// 1. 기본적인 포인트 일치 확인
|
||||
const rX1 = roofLine.x1, rY1 = roofLine.y1, rX2 = roofLine.x2, rY2 = roofLine.y2;
|
||||
const eX1 = eaveLine.x1, eY1 = eaveLine.y1, eX2 = eaveLine.x2, eY2 = eaveLine.y2;
|
||||
const rX1 = roofLine.x1,
|
||||
rY1 = roofLine.y1,
|
||||
rX2 = roofLine.x2,
|
||||
rY2 = roofLine.y2
|
||||
const eX1 = eaveLine.x1,
|
||||
eY1 = eaveLine.y1,
|
||||
eX2 = eaveLine.x2,
|
||||
eY2 = eaveLine.y2
|
||||
|
||||
const isP1Matched = (Math.abs(rX1 - eX1) < 0.1 && Math.abs(rY1 - eY1) < 0.1) || (Math.abs(rX1 - eX2) < 0.1 && Math.abs(rY1 - eY2) < 0.1);
|
||||
const isP2Matched = (Math.abs(rX2 - eX1) < 0.1 && Math.abs(rY2 - eY1) < 0.1) || (Math.abs(rX2 - eX2) < 0.1 && Math.abs(rY2 - eY2) < 0.1);
|
||||
const isP1Matched =
|
||||
(Math.abs(rX1 - eX1) < 0.1 && Math.abs(rY1 - eY1) < 0.1) || (Math.abs(rX1 - eX2) < 0.1 && Math.abs(rY1 - eY2) < 0.1)
|
||||
const isP2Matched =
|
||||
(Math.abs(rX2 - eX1) < 0.1 && Math.abs(rY2 - eY1) < 0.1) || (Math.abs(rX2 - eX2) < 0.1 && Math.abs(rY2 - eY2) < 0.1)
|
||||
|
||||
if (isP1Matched || isP2Matched) {
|
||||
// 2. 일직선(평행)인지 확인
|
||||
const dx1 = rX2 - rX1;
|
||||
const dy1 = rY2 - rY1;
|
||||
const dx2 = eX2 - eX1;
|
||||
const dy2 = eY2 - eY1;
|
||||
const crossProduct = Math.abs(dx1 * dy2 - dy1 * dx2);
|
||||
const mag1 = Math.sqrt(dx1 * dx1 + dy1 * dy1);
|
||||
const mag2 = Math.sqrt(dx2 * dx2 + dy2 * dy2);
|
||||
const isStraight = (mag1 * mag2) === 0 ? true : (crossProduct / (mag1 * mag2) < 0.01);
|
||||
const dx1 = rX2 - rX1
|
||||
const dy1 = rY2 - rY1
|
||||
const dx2 = eX2 - eX1
|
||||
const dy2 = eY2 - eY1
|
||||
const crossProduct = Math.abs(dx1 * dy2 - dy1 * dx2)
|
||||
const mag1 = Math.sqrt(dx1 * dx1 + dy1 * dy1)
|
||||
const mag2 = Math.sqrt(dx2 * dx2 + dy2 * dy2)
|
||||
const isStraight = mag1 * mag2 === 0 ? true : crossProduct / (mag1 * mag2) < 0.01
|
||||
|
||||
if (isStraight) {
|
||||
// 3. [핵심] 몸통이 포개지는지(Overlap) 확인
|
||||
// 한 선의 끝점이 다른 선의 "내부"에 들어와 있는지 체크
|
||||
const isPointInside = (x, y, x1, y1, x2, y2) => {
|
||||
const dotProduct = (x - x1) * (x2 - x1) + (y - y1) * (y2 - y1);
|
||||
if (dotProduct < 0.1) return false; // 시작점 바깥쪽
|
||||
const squaredLength = (x2 - x1) ** 2 + (y2 - y1) ** 2;
|
||||
if (dotProduct > squaredLength - 0.1) return false; // 끝점 바깥쪽
|
||||
return true; // 선의 내부(몸통)에 있음
|
||||
};
|
||||
const dotProduct = (x - x1) * (x2 - x1) + (y - y1) * (y2 - y1)
|
||||
if (dotProduct < 0.1) return false // 시작점 바깥쪽
|
||||
const squaredLength = (x2 - x1) ** 2 + (y2 - y1) ** 2
|
||||
if (dotProduct > squaredLength - 0.1) return false // 끝점 바깥쪽
|
||||
return true // 선의 내부(몸통)에 있음
|
||||
}
|
||||
|
||||
// roofLine의 끝점 중 하나가 eaveLine의 몸통 안에 있거나,
|
||||
// eaveLine의 끝점 중 하나가 roofLine의 몸통 안에 있으면 "포개짐"으로 판단
|
||||
@ -509,22 +709,34 @@ export function useRoofAllocationSetting(id) {
|
||||
isPointInside(rX1, rY1, eX1, eY1, eX2, eY2) ||
|
||||
isPointInside(rX2, rY2, eX1, eY1, eX2, eY2) ||
|
||||
isPointInside(eX1, eY1, rX1, rY1, rX2, rY2) ||
|
||||
isPointInside(eX2, eY2, rX1, rY1, rX2, rY2);
|
||||
isPointInside(eX2, eY2, rX1, rY1, rX2, rY2)
|
||||
|
||||
if (isOverlapping) {
|
||||
console.log('Removing overlapping line:', roofLine);
|
||||
return true; // 포개지는 경우에만 삭제
|
||||
console.log('Removing overlapping line:', roofLine)
|
||||
return true // 포개지는 경우에만 삭제
|
||||
}
|
||||
}
|
||||
}
|
||||
return false; // 끝점만 닿아 있거나 직각인 경우는 살림
|
||||
});
|
||||
return !shouldRemove;
|
||||
});
|
||||
// Combine remaining lines with newEaveLines
|
||||
roofBase.lines = [...linesToKeep, ...normalEaveLines, ...extensionLines];
|
||||
// console.log('Final roofBase.lines count:', roofBase.lines.length)
|
||||
// console.log('extensionLines in final:', roofBase.lines.filter(l => l.lineName === 'extensionLine').length)
|
||||
return false // 끝점만 닿아 있거나 직각인 경우는 살림
|
||||
})
|
||||
return !shouldRemove
|
||||
})
|
||||
// Combine remaining lines with newEaveLines
|
||||
roofBase.lines = [...linesToKeep, ...normalEaveLines, ...extensionLines]
|
||||
// [ALLOC-DEBUG] 병합 결과 breakdown
|
||||
console.log(
|
||||
`[ALLOC-DEBUG] 병합완료 roofBase.id=${roofBase.id} ` +
|
||||
`linesToKeep=${linesToKeep.length} ` +
|
||||
`normalEave=${normalEaveLines.length} ` +
|
||||
`extension=${extensionLines.length} ` +
|
||||
`total=${roofBase.lines.length}`,
|
||||
)
|
||||
roofBase.lines.forEach((ln, i) => {
|
||||
console.log(
|
||||
` [ALLOC-DEBUG] merged[${i}] name=${ln.name || '?'} lineName=${ln.lineName || '?'} ` +
|
||||
`(${ln.x1?.toFixed(1)},${ln.y1?.toFixed(1)})→(${ln.x2?.toFixed(1)},${ln.y2?.toFixed(1)})`,
|
||||
)
|
||||
})
|
||||
} else {
|
||||
roofBase.lines = [...roofEaveHelpLines]
|
||||
}
|
||||
@ -561,6 +773,18 @@ export function useRoofAllocationSetting(id) {
|
||||
})
|
||||
roofBase.lines = newRoofLines
|
||||
}
|
||||
// extensionLine + 동일직선 SK 1:1 통합 (대각선 단일길이/각도 면적 산출)
|
||||
integrateExtensionLines(roofBase)
|
||||
|
||||
// [ALLOC-DEBUG] split 직전 최종 입력
|
||||
console.log(
|
||||
`[ALLOC-DEBUG] split 직전 roofBase.id=${roofBase.id} ` +
|
||||
`separatePolygon=${roofBase.separatePolygon?.length || 0} ` +
|
||||
`lines=${roofBase.lines?.length || 0} ` +
|
||||
`innerLines=${roofBase.innerLines?.length || 0} ` +
|
||||
`points=${roofBase.points?.length || 0} ` +
|
||||
`→ ${roofBase.separatePolygon?.length > 0 ? 'splitPolygonWithSeparate' : 'splitPolygonWithLines'}`,
|
||||
)
|
||||
if (roofBase.separatePolygon.length > 0) {
|
||||
splitPolygonWithSeparate(roofBase.separatePolygon)
|
||||
} else {
|
||||
|
||||
@ -247,6 +247,7 @@ export function useRoofShapePassivitySetting(id) {
|
||||
}
|
||||
|
||||
wall.lines = [...lines]
|
||||
|
||||
// 기존 그려진 지붕이 없다면
|
||||
|
||||
if (isFix.current) {
|
||||
|
||||
@ -189,9 +189,14 @@ export const useLine = () => {
|
||||
actualSize: calcLineActualSizeByLineLength(lineLength, getDegreeByChon(pitch)),
|
||||
}
|
||||
} else if (isDiagonal) {
|
||||
// [face 비대칭 보정 2026-04-30] 같은 hip 가 인접 두 face 에서 다른 축(yLength vs xLength)
|
||||
// 으로 계산되어 좌표 round drift 가 actualSize 라벨에 1mm 차로 노출(예: 5048 vs 5049).
|
||||
// axis 평균을 써서 양 face 가 동일값을 산출하도록 통일.
|
||||
const yLength = Math.abs(y2 - y1) * 10
|
||||
const xLength = Math.abs(x2 - x1) * 10
|
||||
const axisLength = (yLength + xLength) / 2
|
||||
|
||||
const h = yLength * Math.tan(getDegreeByChon(pitch) * (Math.PI / 180))
|
||||
const h = axisLength * Math.tan(getDegreeByChon(pitch) * (Math.PI / 180))
|
||||
|
||||
const actualSize = Math.sqrt(h ** 2 + lineLength ** 2)
|
||||
line.attributes = { ...line.attributes, actualSize: actualSize }
|
||||
@ -203,9 +208,12 @@ export const useLine = () => {
|
||||
actualSize: calcLineActualSizeByLineLength(lineLength, getDegreeByChon(pitch)),
|
||||
}
|
||||
} else if (isDiagonal) {
|
||||
// [face 비대칭 보정 2026-04-30] south/north 분기와 동일하게 axis 평균 사용.
|
||||
const yLength = Math.abs(y2 - y1) * 10
|
||||
const xLength = Math.abs(x2 - x1) * 10
|
||||
const axisLength = (yLength + xLength) / 2
|
||||
|
||||
const h = xLength * Math.tan(getDegreeByChon(pitch) * (Math.PI / 180))
|
||||
const h = axisLength * Math.tan(getDegreeByChon(pitch) * (Math.PI / 180))
|
||||
|
||||
const actualSize = Math.sqrt(h ** 2 + lineLength ** 2)
|
||||
line.attributes = { ...line.attributes, actualSize: actualSize }
|
||||
|
||||
@ -1,10 +1,13 @@
|
||||
import { useRecoilValue } from 'recoil'
|
||||
import { corridorDimensionSelector } from '@/store/settingAtom'
|
||||
import { canvasState } from '@/store/canvasAtom'
|
||||
import { canvasState, globalPitchState } from '@/store/canvasAtom'
|
||||
import { calcLineActualSize2 } from '@/util/qpolygon-utils'
|
||||
import { getDegreeByChon } from '@/util/canvas-util'
|
||||
|
||||
export function useText() {
|
||||
const canvas = useRecoilValue(canvasState)
|
||||
const corridorDimension = useRecoilValue(corridorDimensionSelector)
|
||||
const globalPitch = useRecoilValue(globalPitchState)
|
||||
|
||||
const changeCorridorDimensionText = (columnText) => {
|
||||
let { column } = corridorDimension
|
||||
@ -21,18 +24,94 @@ export function useText() {
|
||||
})
|
||||
})
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────
|
||||
// [라벨 토글 fallback 2026-04-30]
|
||||
// 기존: obj.planeSize / obj.actualSize 가 lengthText 에 set 되어있어야만 토글 적용.
|
||||
// hip(대각선)을 비롯해 일부 경로(skeleton SK 빌더, split 후 재생성 등)는
|
||||
// lengthText 의 attribute 동기화가 누락되어 토글 시 if(obj.xxxSize) 가드에 막힘.
|
||||
// 수정: parent line(QLine) 의 attributes 에서 직접 fallback 으로 읽어오고,
|
||||
// lengthText 에도 즉시 propagate 해서 다음 토글부터 정상 인식.
|
||||
// ─────────────────────────────────────────────────────────────────────────
|
||||
const resolveSize = (obj, key) => {
|
||||
if (obj[key]) return obj[key]
|
||||
const parent = obj.parent ?? canvas.getObjects().find((o) => o.id === obj.parentId)
|
||||
const v = parent?.attributes?.[key]
|
||||
if (v) {
|
||||
obj.set({ [key]: v })
|
||||
return v
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
// [hip actualSize 즉석 재계산 2026-04-30]
|
||||
// 기존 SK 가 pitch=0 으로 빌드되어 hip 의 actualSize === planeSize 가 된 경우,
|
||||
// realDimension 토글 시 pitch 폴백으로 즉석 재계산.
|
||||
// parent line.attributes.pitch → roof.pitch → roof.roofMaterial.pitch.
|
||||
// 재계산 결과는 obj.actualSize 와 line.attributes.actualSize 양쪽에 propagate.
|
||||
const recomputeHipActualIfFlat = (obj) => {
|
||||
const parent = obj.parent ?? canvas.getObjects().find((o) => o.id === obj.parentId)
|
||||
if (!parent) return null
|
||||
// [isDiagonal 판정 — parent 좌표 사용]
|
||||
// QLine lengthText 의 minX/maxX/minY/maxY 는 this.length(라인길이) 로 설정되어
|
||||
// 수평 ridge 도 dy>0 이 되는 버그. 반드시 parent.x1/y1/x2/y2 로 판정.
|
||||
const px1 = Number(parent.x1)
|
||||
const py1 = Number(parent.y1)
|
||||
const px2 = Number(parent.x2)
|
||||
const py2 = Number(parent.y2)
|
||||
const dx = Number.isFinite(px1) && Number.isFinite(px2) ? Math.abs(px2 - px1) : 0
|
||||
const dy = Number.isFinite(py1) && Number.isFinite(py2) ? Math.abs(py2 - py1) : 0
|
||||
// 임계 5.0mm — addRawLine 의 HIP/RIDGE 분류 임계와 일치 (drift 흡수).
|
||||
const isDiagonal = dx > 5.0 && dy > 5.0
|
||||
if (!isDiagonal) return null
|
||||
const plane = obj.planeSize ?? parent?.attributes?.planeSize
|
||||
const actual = obj.actualSize ?? parent?.attributes?.actualSize
|
||||
if (!plane) return null
|
||||
if (actual && Math.abs(actual - plane) > 0.5) return null // 이미 정상
|
||||
const roof = canvas.getObjects().find((o) => o.id === parent?.parentId)
|
||||
const pitch =
|
||||
(parent?.attributes?.pitch ? parent.attributes.pitch : null) ??
|
||||
(roof?.pitch ? roof.pitch : null) ??
|
||||
(roof?.roofMaterial?.pitch ? roof.roofMaterial.pitch : null) ??
|
||||
globalPitch ??
|
||||
4
|
||||
if (!pitch) return null
|
||||
// x1/x2/y1/y2 가 number 가 아니면 calcLinePlaneSize 의 Big() 호출에서 throw.
|
||||
const x1 = Number(parent.x1)
|
||||
const y1 = Number(parent.y1)
|
||||
const x2 = Number(parent.x2)
|
||||
const y2 = Number(parent.y2)
|
||||
if (!Number.isFinite(x1) || !Number.isFinite(y1) || !Number.isFinite(x2) || !Number.isFinite(y2)) {
|
||||
return null
|
||||
}
|
||||
let newActual = null
|
||||
try {
|
||||
newActual = Number(calcLineActualSize2({ x1, y1, x2, y2 }, getDegreeByChon(pitch)))
|
||||
} catch (e) {
|
||||
return null
|
||||
}
|
||||
if (!newActual || Math.abs(newActual - plane) < 0.5) return null
|
||||
// 표시용 obj.actualSize 만 갱신. parent.attributes 갱신 시 fabric set 사이드이펙트
|
||||
// (Big.js 사슬) 가 다른 곳을 트리거할 수 있어 회피.
|
||||
obj.set({ actualSize: newActual })
|
||||
return newActual
|
||||
}
|
||||
|
||||
switch (column) {
|
||||
case 'corridorDimension':
|
||||
lengthTexts.forEach((obj) => {
|
||||
if (obj.planeSize) {
|
||||
obj.set({ text: obj.planeSize.toString() })
|
||||
const v = resolveSize(obj, 'planeSize')
|
||||
if (v) {
|
||||
obj.set({ text: v.toString() })
|
||||
}
|
||||
})
|
||||
break
|
||||
case 'realDimension':
|
||||
lengthTexts.forEach((obj) => {
|
||||
if (obj.actualSize) {
|
||||
obj.set({ text: obj.actualSize.toString() })
|
||||
// hip 이고 actualSize===planeSize 면 pitch 로 즉석 재계산.
|
||||
recomputeHipActualIfFlat(obj)
|
||||
const v = resolveSize(obj, 'actualSize')
|
||||
if (v) {
|
||||
obj.set({ text: v.toString() })
|
||||
}
|
||||
})
|
||||
break
|
||||
@ -42,6 +121,102 @@ export function useText() {
|
||||
})
|
||||
break
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────
|
||||
// [hip 라벨 cross-display 통일 2026-04-30 by Claude]
|
||||
//
|
||||
// 배경:
|
||||
// 좌우대칭 hip(대각선) 라인의 라벨이 0.5mm 차이로 갈리는 케이스 발생
|
||||
// (예: 좌측 5048.5 / 우측 5048).
|
||||
//
|
||||
// 원인:
|
||||
// 1) polygon split / ridge intersection 단계 부동소수점 연산
|
||||
// → QLine.js:23 toFixed(1) 0.1mm 단위 절단 → 좌우 hip 좌표가 0.1mm 차
|
||||
// 2) useLine.js setActualSize 의 axisLength * tan + sqrt 계산에서
|
||||
// 좌표 0.1mm 차가 ×10 으로 1mm 차로 증폭
|
||||
// 3) 마지막 `Math.round(actualSize * 2) / 2` (0.5mm 단위) boundary 에서 갈림
|
||||
// → 한쪽은 5048.0, 한쪽은 5048.5 로 결정
|
||||
// 4) planeSize 도 usePolygon.js 의 _equalizeIfNearSymmetric([avg, avg])
|
||||
// 경로에서 0.5mm 단위 값이 만들어질 수 있음 (corridorDimension 모드에 영향)
|
||||
//
|
||||
// 수정 방침:
|
||||
// - **표시 레이어 (lengthText.text) 만 보정**.
|
||||
// - line.attributes.actualSize / line.attributes.planeSize 는 절대 안 건드림.
|
||||
// → 다운스트림 read 경로 (라인 분할 비율 / 확장라인 병합 / 모듈배치 shape
|
||||
// 상속 / JSON 저장 / DB / 복제 플랜) 영향 0.
|
||||
//
|
||||
// 처리:
|
||||
// - 대각선 hip 라벨끼리 묶어서, 표시값이 ±2mm 이내이면 정수 round 한
|
||||
// 공통값으로 스냅 (5048.5/5048 → 둘 다 5049 또는 5048).
|
||||
// - corridorDimension / realDimension 모드 모두 적용.
|
||||
// - noneDimension 모드는 빈 문자열이라 패스.
|
||||
//
|
||||
// 추후 근본 수정 (다른 개발자 검토 필요):
|
||||
// - QLine.js:23 toFixed(1) → toFixed(2) 정밀도 상향 (영향 범위 넓어 별건)
|
||||
// - skeleton-utils.js getLineIntersection 결과 좌표 round
|
||||
// - useLine.js setActualSize 마지막 round 단위 변경 (0.5 → 1.0)
|
||||
// 위 근본수정이 들어가면 이 보정 코드는 제거 가능.
|
||||
//
|
||||
// 메모리: feedback_display_vs_internal_values.md 참고.
|
||||
// ─────────────────────────────────────────────────────────────────────────
|
||||
if (column === 'corridorDimension' || column === 'realDimension') {
|
||||
// 대각선(hip) lengthText 추출.
|
||||
// lengthText 객체는 QPolygon.js:712-734 에서 minX/maxX/minY/maxY 로 부모 라인의
|
||||
// 바운딩박스를 저장. 둘 다 양수 차이 = 대각선.
|
||||
const hipTexts = lengthTexts.filter((obj) => {
|
||||
// [parent 좌표 기반 isDiagonal — QLine lengthText min/max 부정확 회피]
|
||||
// QLine.js:122-125 가 minY/maxY 를 this.top/this.top+this.length 로 설정해
|
||||
// 수평 ridge 도 maxY-minY>0 이 됨. 반드시 parent.x1/y1/x2/y2 로 판정.
|
||||
const parent = obj.parent ?? canvas.getObjects().find((p) => p.id === obj.parentId)
|
||||
if (!parent) return false
|
||||
const px1 = Number(parent.x1)
|
||||
const py1 = Number(parent.y1)
|
||||
const px2 = Number(parent.x2)
|
||||
const py2 = Number(parent.y2)
|
||||
if (!Number.isFinite(px1) || !Number.isFinite(py1) || !Number.isFinite(px2) || !Number.isFinite(py2)) {
|
||||
return false
|
||||
}
|
||||
const dx = Math.abs(px2 - px1)
|
||||
const dy = Math.abs(py2 - py1)
|
||||
return dx > 5.0 && dy > 5.0
|
||||
})
|
||||
|
||||
if (hipTexts.length >= 2) {
|
||||
// 현재 표시 텍스트를 숫자로 파싱.
|
||||
const items = hipTexts
|
||||
.map((obj) => ({ obj, value: parseFloat(obj.text) }))
|
||||
.filter((item) => !isNaN(item.value))
|
||||
|
||||
// 그룹화: 표시값이 ±TOLERANCE mm 이내면 동일 그룹.
|
||||
// TOLERANCE=2 는 0.5 단위 round drift + sqrt fp 노이즈 흡수.
|
||||
// 대칭 hip 들의 정상 길이차 (예: 다른 지붕면의 hip) 보다는 작아야 함.
|
||||
const TOLERANCE = 2
|
||||
const groups = []
|
||||
for (const item of items) {
|
||||
const matched = groups.find((g) => Math.abs(g.representative - item.value) <= TOLERANCE)
|
||||
if (matched) {
|
||||
matched.items.push(item)
|
||||
// 그룹 대표값은 첫 항목 값 유지 (안정적인 클러스터링).
|
||||
} else {
|
||||
groups.push({ representative: item.value, items: [item] })
|
||||
}
|
||||
}
|
||||
|
||||
// 각 그룹의 멤버가 2개 이상이면 정수 round 평균값으로 통일.
|
||||
// 1개 그룹은 단독 hip 이므로 보정 불필요.
|
||||
for (const group of groups) {
|
||||
if (group.items.length < 2) continue
|
||||
const avg = group.items.reduce((s, i) => s + i.value, 0) / group.items.length
|
||||
const unified = Math.round(avg).toString()
|
||||
for (const item of group.items) {
|
||||
// text 만 set. obj.actualSize / obj.planeSize 는 그대로.
|
||||
item.obj.set({ text: unified })
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
// ─────────────────────────────────────────────────────────────────────────
|
||||
|
||||
canvas?.renderAll()
|
||||
}
|
||||
|
||||
|
||||
@ -16,6 +16,24 @@ import wallLine from '@/components/floor-plan/modal/wallLineOffset/type/WallLine
|
||||
*/
|
||||
const EPSILON = 0.1
|
||||
|
||||
// [v1 helper 2026-04-29] B 경로: SK 입력은 그대로 두고, baseLine corner → roofLine corner 까지의
|
||||
// 시각 보조 라인만 추가. processInBoth/processInStartEnd 등 working code 무손상.
|
||||
// innerLines/skeletonLines 등 기존 collection 에 push 하지 않음 (canvas add 만).
|
||||
// 사용자 리셋 명령 없이 리셋되지 않도록 누적 baseLines/roofLines 좌표 그대로 사용.
|
||||
// 롤백: false 로 toggle.
|
||||
const DRAW_BASELINE_TO_ROOFLINE_HELPER = true
|
||||
const BASELINE_TO_ROOFLINE_HELPER_TYPE = 'sk_to_roofline_helper'
|
||||
|
||||
// [v1 architecture 2026-04-29] SK 입력 = wall.baseLines 직접 (offset 확장/movedPoints 사용 안 함).
|
||||
// 사용자 요청: "마루이동/벽이동/offset 으로 wall.baseLines 가 변하면 SK 도 변해야 한다.
|
||||
// SK 빌더에는 항상 wall.baseLines 를 넘겨야 함. 그 다음 확장선 그림".
|
||||
// changRoofLinePoints = orderedBaseLinePoints (= createOrderedBasePoints(roof.points, wall.baseLines))
|
||||
// 기존 45° 확장 / safeMovedPointsWithFallback 결과는 모두 무시.
|
||||
// 이렇게 하면 wall.baseLines 의 모든 누적 변경(마루이동/벽이동/offset)이 SK 에 자동 반영.
|
||||
// 확장선(B 경로 helper) 이 baseLine corner → roofLine 까지 시각화 담당.
|
||||
// 롤백: false → 기존 HEAD 동작 (45° 확장 + movedPoints).
|
||||
const SK_INPUT_USE_WALL_BASELINE_DIRECT = true
|
||||
|
||||
/**
|
||||
* 오목(concave) 폴리곤인지 판별합니다.
|
||||
* cross product의 부호가 혼재하면 오목 폴리곤입니다.
|
||||
@ -238,21 +256,31 @@ const movingLineFromSkeleton = (roofId, canvas) => {
|
||||
affected.add((k + 1) % nPts);
|
||||
});
|
||||
console.log('absMove::', moveUpDownLength);
|
||||
// [BR-TRACE local] movingLineFromSkeleton position/direction 분기
|
||||
const __isLocalMS = process.env.NEXT_PUBLIC_RUN_MODE === 'local'
|
||||
if (__isLocalMS) console.log(`[BR-TRACE] movingLineFromSkeleton position=${position} dir=${moveDirection} affected=${[...affected].join(',')}`)
|
||||
affected.forEach((i) => {
|
||||
const point = newPoints[i];
|
||||
if (!point) return;
|
||||
const __bx = point.x, __by = point.y;
|
||||
if (position === 'bottom') {
|
||||
if (moveDirection === 'in') point.y = Big(point.y).minus(moveUpDownLength).toNumber();
|
||||
else if (moveDirection === 'out') point.y = Big(point.y).plus(moveUpDownLength).toNumber();
|
||||
if (__isLocalMS) console.log(`[BR-TRACE] i=${i} bottom/${moveDirection} y ${__by}→${point.y}`)
|
||||
} else if (position === 'top') {
|
||||
if (moveDirection === 'in') point.y = Big(point.y).plus(moveUpDownLength).toNumber();
|
||||
else if (moveDirection === 'out') point.y = Big(point.y).minus(moveUpDownLength).toNumber();
|
||||
if (__isLocalMS) console.log(`[BR-TRACE] i=${i} top/${moveDirection} y ${__by}→${point.y}`)
|
||||
} else if (position === 'left') {
|
||||
if (moveDirection === 'in') point.x = Big(point.x).plus(moveUpDownLength).toNumber();
|
||||
else if (moveDirection === 'out') point.x = Big(point.x).minus(moveUpDownLength).toNumber();
|
||||
if (__isLocalMS) console.log(`[BR-TRACE] i=${i} left/${moveDirection} x ${__bx}→${point.x}`)
|
||||
} else if (position === 'right') {
|
||||
if (moveDirection === 'in') point.x = Big(point.x).minus(moveUpDownLength).toNumber();
|
||||
else if (moveDirection === 'out') point.x = Big(point.x).plus(moveUpDownLength).toNumber();
|
||||
if (__isLocalMS) console.log(`[BR-TRACE] i=${i} right/${moveDirection} x ${__bx}→${point.x}`)
|
||||
} else if (__isLocalMS) {
|
||||
console.warn(`[BR-TRACE] i=${i} position=${position} 매칭 분기 없음 (이동 안 됨)`)
|
||||
}
|
||||
});
|
||||
|
||||
@ -416,21 +444,31 @@ const buildRawMovedPoints = (roofId, canvas) => {
|
||||
affected.add(idx)
|
||||
affected.add((idx + 1) % nPts)
|
||||
})
|
||||
// [BR-TRACE local] buildRawMovedPoints position/direction 분기
|
||||
const __isLocalBR = process.env.NEXT_PUBLIC_RUN_MODE === 'local'
|
||||
if (__isLocalBR) console.log(`[BR-TRACE] buildRawMovedPoints position=${position} dir=${moveDirection} affected=${[...affected].join(',')}`)
|
||||
affected.forEach((i) => {
|
||||
const point = newPoints[i]
|
||||
if (!point) return
|
||||
const __bx = point.x, __by = point.y
|
||||
if (position === 'bottom') {
|
||||
if (moveDirection === 'out') point.y = Big(point.y).plus(moveUpDownLength).toNumber()
|
||||
else if (moveDirection === 'in') point.y = Big(point.y).minus(moveUpDownLength).toNumber()
|
||||
if (__isLocalBR) console.log(`[BR-TRACE] i=${i} bottom/${moveDirection} y ${__by}→${point.y}`)
|
||||
} else if (position === 'top') {
|
||||
if (moveDirection === 'out') point.y = Big(point.y).minus(moveUpDownLength).toNumber()
|
||||
else if (moveDirection === 'in') point.y = Big(point.y).plus(moveUpDownLength).toNumber()
|
||||
if (__isLocalBR) console.log(`[BR-TRACE] i=${i} top/${moveDirection} y ${__by}→${point.y}`)
|
||||
} else if (position === 'left') {
|
||||
if (moveDirection === 'out') point.x = Big(point.x).minus(moveUpDownLength).toNumber()
|
||||
else if (moveDirection === 'in') point.x = Big(point.x).plus(moveUpDownLength).toNumber()
|
||||
if (__isLocalBR) console.log(`[BR-TRACE] i=${i} left/${moveDirection} x ${__bx}→${point.x}`)
|
||||
} else if (position === 'right') {
|
||||
if (moveDirection === 'out') point.x = Big(point.x).plus(moveUpDownLength).toNumber()
|
||||
else if (moveDirection === 'in') point.x = Big(point.x).minus(moveUpDownLength).toNumber()
|
||||
if (__isLocalBR) console.log(`[BR-TRACE] i=${i} right/${moveDirection} x ${__bx}→${point.x}`)
|
||||
} else if (__isLocalBR) {
|
||||
console.warn(`[BR-TRACE] i=${i} position=${position} 매칭 분기 없음 (이동 안 됨)`)
|
||||
}
|
||||
})
|
||||
|
||||
@ -633,6 +671,248 @@ const safeMovedPointsWithFallback = (roofId, canvas) => {
|
||||
* @param {fabric.Object} roof - 지붕 객체
|
||||
* @param baseLines
|
||||
*/
|
||||
// [v1 helper 2026-04-29] 각 baseLine corner 에서 정확히 45° 대각 방향으로 ray cast →
|
||||
// 먼저 만나는 roofLine 세그먼트와의 교점까지 라인 그리기.
|
||||
// - 방향 = (signDx, signDy)/√2, 즉 4사분면 정확 대각.
|
||||
// signDx = sign(roofLinePoints[i].x - baseLinePoints[i].x) ← HEAD 의 contactData 와 동일 식
|
||||
// signDy = sign(roofLinePoints[i].y - baseLinePoints[i].y)
|
||||
// 0 폴백 = centroid 기준 (HEAD line 852~853 동일).
|
||||
// - outward bisector(변 normal 합) 식은 비대각 (= "90° 꺾임" 으로 보임) → 사용 안 함.
|
||||
// - canvas.add 만 수행 (innerLines/skeletonLines 영향 0).
|
||||
// - 누적 보존: 인자 baseLines/roofLines 그대로, 리셋 없음.
|
||||
// - 롤백: DRAW_BASELINE_TO_ROOFLINE_HELPER = false.
|
||||
const drawBaselineToRooflineHelpers = (roof, canvas, baseLinePoints, roofLinePoints) => {
|
||||
if (!DRAW_BASELINE_TO_ROOFLINE_HELPER) return
|
||||
if (!Array.isArray(baseLinePoints) || baseLinePoints.length < 3) return
|
||||
if (!Array.isArray(roofLinePoints) || roofLinePoints.length < 3) return
|
||||
if (!Array.isArray(roof.innerLines) || roof.innerLines.length === 0) return
|
||||
const m = roofLinePoints.length
|
||||
|
||||
// ray (P, dir) ∩ segment (A, B) → t (>0). 없으면 Infinity.
|
||||
const rayHitSegment = (P, dir, A, B) => {
|
||||
const sx = B.x - A.x, sy = B.y - A.y
|
||||
const denom = dir.x * sy - dir.y * sx
|
||||
if (Math.abs(denom) < 1e-9) return Infinity
|
||||
const ax = A.x - P.x, ay = A.y - P.y
|
||||
const t = (ax * sy - ay * sx) / denom
|
||||
const s = (ax * dir.y - ay * dir.x) / denom
|
||||
if (t > 1e-6 && s >= -1e-6 && s <= 1 + 1e-6) return t
|
||||
return Infinity
|
||||
}
|
||||
|
||||
// 점-선분 거리. P 가 polygon edge 위(또는 매우 가까이)인지 검사용.
|
||||
const pointSegDist = (P, A, B) => {
|
||||
const sx = B.x - A.x, sy = B.y - A.y
|
||||
const lenSq = sx * sx + sy * sy
|
||||
if (lenSq < 1e-9) return Math.hypot(P.x - A.x, P.y - A.y)
|
||||
let t = ((P.x - A.x) * sx + (P.y - A.y) * sy) / lenSq
|
||||
t = Math.max(0, Math.min(1, t))
|
||||
return Math.hypot(P.x - (A.x + t * sx), P.y - (A.y + t * sy))
|
||||
}
|
||||
const isPointOnRoofLine = (P, eps = 1.0) => {
|
||||
for (let i = 0; i < m; i++) {
|
||||
const d = pointSegDist(P, roofLinePoints[i], roofLinePoints[(i + 1) % m])
|
||||
if (d < eps) return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// baseLine corner 중 P 와 가장 가까운 거리.
|
||||
const minDistToBaseLine = (P) => {
|
||||
let min = Infinity
|
||||
for (const c of baseLinePoints) {
|
||||
const d = Math.hypot(P.x - c.x, P.y - c.y)
|
||||
if (d < min) min = d
|
||||
}
|
||||
return min
|
||||
}
|
||||
|
||||
// [v1 ext 2026-04-29] HIP 라인만 추출.
|
||||
// ext = SK(hip) 의 연장선이자 처마 라인이므로 항상 대각선.
|
||||
// corner 갯수 무관, hip 갯수 기반 → 평행오버 후 잔여 corner 가 만들어내던 헛 ext 제거.
|
||||
const hipLines = roof.innerLines.filter((il) => {
|
||||
if (!il || !isFinite(il.x1) || !isFinite(il.y1) || !isFinite(il.x2) || !isFinite(il.y2)) return false
|
||||
return il.lineName === 'hip' || il.name === 'hip'
|
||||
})
|
||||
console.log(`[v1 ext] HIP 추출 ${hipLines.length}개 / 전체 innerLines ${roof.innerLines.length}`)
|
||||
|
||||
// 절삭 대상: roofLine seg + canvas 의 다른 보조라인 (eaveHelpLine 등).
|
||||
// 기존 helper 자체(BASELINE_TO_ROOFLINE_HELPER_TYPE) / wall/roof 본체 / 텍스트 등은 제외.
|
||||
const segs = []
|
||||
for (let i = 0; i < m; i++) {
|
||||
segs.push({ A: roofLinePoints[i], B: roofLinePoints[(i + 1) % m] })
|
||||
}
|
||||
const excludedNames = new Set([
|
||||
'wall', 'roof', 'WALL', 'ROOF',
|
||||
'lengthText', 'outerLine', 'baseLine', 'outerLinePoint',
|
||||
BASELINE_TO_ROOFLINE_HELPER_TYPE,
|
||||
])
|
||||
const canvasObjs = (typeof canvas?.getObjects === 'function') ? canvas.getObjects() : []
|
||||
for (const obj of canvasObjs) {
|
||||
if (!obj) continue
|
||||
if (obj.parentId !== roof.id) continue
|
||||
if (excludedNames.has(obj.name) || excludedNames.has(obj.lineName)) continue
|
||||
if (!isFinite(obj.x1) || !isFinite(obj.y1) || !isFinite(obj.x2) || !isFinite(obj.y2)) continue
|
||||
if (Math.hypot(obj.x2 - obj.x1, obj.y2 - obj.y1) < 0.5) continue
|
||||
segs.push({ A: { x: obj.x1, y: obj.y1 }, B: { x: obj.x2, y: obj.y2 }, __name: obj.name || obj.lineName })
|
||||
}
|
||||
|
||||
// [BR-SEGS] local only — segs 에 들어간 aux 라인 목록 (eaveHelpLine 등 보조선 포함 여부 진단)
|
||||
if (process.env.NEXT_PUBLIC_RUN_MODE === 'local') {
|
||||
const auxList = []
|
||||
for (let k = m; k < segs.length; k++) {
|
||||
const s = segs[k]
|
||||
auxList.push(`${s.__name || '?'}=(${Math.round(s.A.x)},${Math.round(s.A.y)})→(${Math.round(s.B.x)},${Math.round(s.B.y)}) len=${Math.round(Math.hypot(s.B.x - s.A.x, s.B.y - s.A.y))}`)
|
||||
}
|
||||
console.log(`[BR-SEGS] roofLine=${m} aux=${segs.length - m}`, auxList)
|
||||
}
|
||||
|
||||
// [v1 ext 정정] ext 는 wallbaseLine 의 꼭지점(corner) 에 outer 끝점이 일치하는 hip 만 대상.
|
||||
// 내부에서 끝나는 hip(ridge 쪽으로만 향하는 hip) 은 ext 대상 아님.
|
||||
const CORNER_EPS = 1.0
|
||||
for (const hip of hipLines) {
|
||||
const p1 = { x: hip.x1, y: hip.y1 }
|
||||
const p2 = { x: hip.x2, y: hip.y2 }
|
||||
|
||||
// outerEnd = baseLine corner 와 가까운 끝. 반대는 inner(ridge 쪽).
|
||||
const d1 = minDistToBaseLine(p1)
|
||||
const d2 = minDistToBaseLine(p2)
|
||||
const outerEnd = d1 < d2 ? p1 : p2
|
||||
const innerEnd = d1 < d2 ? p2 : p1
|
||||
const outerCornerDist = Math.min(d1, d2)
|
||||
|
||||
// outer 끝이 wallbaseLine corner 에 일치하지 않으면 ext 대상 아님.
|
||||
if (outerCornerDist > CORNER_EPS) {
|
||||
console.log(
|
||||
`[v1 ext] hip outer=(${outerEnd.x.toFixed(1)},${outerEnd.y.toFixed(1)}) ` +
|
||||
`corner 거리=${outerCornerDist.toFixed(2)} > ${CORNER_EPS} → 내부 hip skip`
|
||||
)
|
||||
continue
|
||||
}
|
||||
|
||||
// 이미 처마(roofLine) 위에 있으면 ext 불필요.
|
||||
if (isPointOnRoofLine(outerEnd, 1.0)) {
|
||||
console.log(`[v1 ext] hip outer=(${outerEnd.x.toFixed(1)},${outerEnd.y.toFixed(1)}) 이미 roofLine 위 → skip`)
|
||||
continue
|
||||
}
|
||||
|
||||
// 방향 = innerEnd → outerEnd 의 unit vec (hip 자체 진행 방향 그대로).
|
||||
const dx = outerEnd.x - innerEnd.x
|
||||
const dy = outerEnd.y - innerEnd.y
|
||||
const len = Math.hypot(dx, dy)
|
||||
if (len < 1e-6) continue
|
||||
const ux = dx / len, uy = dy / len
|
||||
|
||||
// outerEnd 에서 unit 방향으로 ray cast → 가장 먼저 만나는 segment 까지.
|
||||
let bestT = Infinity
|
||||
let bestSrc = ''
|
||||
let bestSegName = ''
|
||||
const __isLocalRayLog = process.env.NEXT_PUBLIC_RUN_MODE === 'local'
|
||||
for (let k = 0; k < segs.length; k++) {
|
||||
const { A, B } = segs[k]
|
||||
const t = rayHitSegment(outerEnd, { x: ux, y: uy }, A, B)
|
||||
// [BR-RAY] local only — finite t 인 모든 후보 hit 찍기 (어떤 seg 가 가장 빠른지 비교용)
|
||||
if (__isLocalRayLog && isFinite(t)) {
|
||||
const __label = (k < m) ? `roofLine#${k}` : (segs[k].__name || `aux#${k - m}`)
|
||||
console.log(`[BR-RAY] hip outer=(${Math.round(outerEnd.x)},${Math.round(outerEnd.y)}) ${__label} t=${t.toFixed(2)} seg=(${Math.round(A.x)},${Math.round(A.y)})→(${Math.round(B.x)},${Math.round(B.y)})`)
|
||||
}
|
||||
if (t < bestT) {
|
||||
bestT = t
|
||||
bestSrc = (k < m) ? 'roofLine' : 'aux'
|
||||
bestSegName = (k < m) ? 'roofLine' : (segs[k].__name || 'aux')
|
||||
}
|
||||
}
|
||||
if (!isFinite(bestT) || bestT < 0.5) {
|
||||
console.log(`[v1 ext] hip outer=(${outerEnd.x.toFixed(1)},${outerEnd.y.toFixed(1)}) NO_HIT or 너무 가까움`)
|
||||
continue
|
||||
}
|
||||
|
||||
// [2026-04-30] 승자가 eaveHelpLine 이면 ext skip.
|
||||
// ext 의 목적은 hip 을 outer roofLine(처마) 까지 연장하는 것.
|
||||
// eaveHelpLine 이 ray 경로를 가로막으면 이미 eave/aux 영역에 도달한 것이므로 더 그릴 필요 없음.
|
||||
// roofLine 승자만 진짜 "처마 도달" 로 인정 → ext 그림.
|
||||
if (bestSegName === 'eaveHelpLine') {
|
||||
console.log(`[v1 ext] hip outer=(${outerEnd.x.toFixed(1)},${outerEnd.y.toFixed(1)}) 승자=eaveHelpLine bestT=${bestT.toFixed(2)} → skip`)
|
||||
continue
|
||||
}
|
||||
|
||||
const hitX = outerEnd.x + ux * bestT
|
||||
const hitY = outerEnd.y + uy * bestT
|
||||
|
||||
console.log(
|
||||
`[v1 ext] hip outer=(${outerEnd.x.toFixed(1)},${outerEnd.y.toFixed(1)}) ` +
|
||||
`dir=(${ux.toFixed(3)},${uy.toFixed(3)}) bestT=${bestT.toFixed(2)} src=${bestSrc} ` +
|
||||
`→ ext=(${outerEnd.x.toFixed(1)},${outerEnd.y.toFixed(1)})→(${hitX.toFixed(1)},${hitY.toFixed(1)})`
|
||||
)
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────
|
||||
// [hip 좌표 연장 2026-04-30]
|
||||
// ext 가 invisible 로 숨겨지면서 baseLine corner ↔ roof corner 시각 갭 발생.
|
||||
// sk(hip) 의 outer 끝점을 ext 끝점(hitX/hitY) 까지 연장해서 한 줄의 대각선 라인으로.
|
||||
// ext 객체는 그대로 만들어 canvas 에 남김 (invisible 데이터, 디버그/참조용).
|
||||
// integrate 의 ext+sk merge 가 sk 좌표를 되돌리지 않게 __extended 플래그 부착.
|
||||
// ─────────────────────────────────────────────────────────────────────────
|
||||
const oldLen = Math.hypot(hip.x2 - hip.x1, hip.y2 - hip.y1)
|
||||
const extLen = bestT // outerEnd → hitPoint 거리
|
||||
const ratio = oldLen > 0 ? (oldLen + extLen) / oldLen : 1
|
||||
|
||||
// outer 가 p1 (d1<d2) 인지 p2 (d2<=d1) 인지에 따라 해당 끝만 갱신.
|
||||
if (d1 < d2) {
|
||||
hip.set({ x1: hitX, y1: hitY })
|
||||
} else {
|
||||
hip.set({ x2: hitX, y2: hitY })
|
||||
}
|
||||
|
||||
// attributes (planeSize/actualSize) 0.01 정밀도 유지 (integrateExtensionLines 와 동일 규칙).
|
||||
if (!hip.attributes) hip.attributes = {}
|
||||
const oldPlane = hip.attributes.planeSize ?? 0
|
||||
const oldActual = hip.attributes.actualSize ?? 0
|
||||
hip.attributes.planeSize = Math.round(oldPlane * ratio * 100) / 100
|
||||
hip.attributes.actualSize = Math.round(oldActual * ratio * 100) / 100
|
||||
|
||||
// integrateExtensionLines 가 이 hip 의 좌표를 되돌리지 않게 가드.
|
||||
hip.__extended = true
|
||||
|
||||
// Fabric bounding/length/text 갱신.
|
||||
if (typeof hip.setCoords === 'function') hip.setCoords()
|
||||
if (typeof hip.setLength === 'function') hip.setLength()
|
||||
if (typeof hip.addLengthText === 'function') hip.addLengthText()
|
||||
|
||||
console.log(
|
||||
`[v1 ext] hip 연장 oldLen=${oldLen.toFixed(1)} +extLen=${extLen.toFixed(1)} ratio=${ratio.toFixed(3)} ` +
|
||||
`(plane ${oldPlane}→${hip.attributes.planeSize}, actual ${oldActual}→${hip.attributes.actualSize})`
|
||||
)
|
||||
// ─────────────────────────────────────────────────────────────────────────
|
||||
|
||||
// [확장선 스타일] local: 빨강 점선(디버그 가시화), dev/prd: innerLine 동일색·실선(연결된 것처럼).
|
||||
const __isLocalExt = process.env.NEXT_PUBLIC_RUN_MODE === 'local'
|
||||
const __extStroke = __isLocalExt ? '#FF0000' : '#1083E3'
|
||||
const __extDash = __isLocalExt ? [6, 4] : null
|
||||
const line = new QLine([outerEnd.x, outerEnd.y, hitX, hitY], {
|
||||
parentId: roof.id,
|
||||
stroke: __extStroke,
|
||||
strokeWidth: 2,
|
||||
strokeDashArray: __extDash,
|
||||
// lineName='extensionLine' → useRoofAllocationSetting.apply() 가 canvas 에서
|
||||
// 자동 수집 후 roofBase.lines 에 추가.
|
||||
name: BASELINE_TO_ROOFLINE_HELPER_TYPE,
|
||||
lineName: 'extensionLine',
|
||||
// [2026-04-30] 시각적으로 숨김. canvas 데이터(좌표/lineName/roofId)는 유지하여
|
||||
// integrate 픽업 및 디버그 조회(canvas.getObjects().filter)에 영향 없음.
|
||||
visible: false,
|
||||
roofId: roof.id,
|
||||
selectable: false,
|
||||
hoverCursor: 'default',
|
||||
attributes: {
|
||||
type: BASELINE_TO_ROOFLINE_HELPER_TYPE,
|
||||
},
|
||||
})
|
||||
canvas.add(line)
|
||||
line.bringToFront()
|
||||
}
|
||||
canvas.renderAll()
|
||||
}
|
||||
|
||||
export const skeletonBuilder = (roofId, canvas, textMode) => {
|
||||
//처마
|
||||
let roof = canvas?.getObjects().find((object) => object.id === roofId)
|
||||
@ -834,6 +1114,56 @@ export const skeletonBuilder = (roofId, canvas, textMode) => {
|
||||
changRoofLinePoints = movedPoints
|
||||
}
|
||||
|
||||
// [v1 architecture 2026-04-29] SK 입력 강제 = wall.baseLines 직접.
|
||||
// 위에서 계산된 changRoofLinePoints (45° 확장 또는 movedPoints) 를 모두 무시하고
|
||||
// orderedBaseLinePoints (= wall.baseLines corner) 그대로 사용.
|
||||
// wall.baseLines 는 마루이동/벽이동/offset 의 모든 누적 변경 반영 → SK 자동 동기화.
|
||||
// 확장선은 drawBaselineToRooflineHelpers (45° to first roofLine) 가 별도 그림.
|
||||
if (SK_INPUT_USE_WALL_BASELINE_DIRECT) {
|
||||
changRoofLinePoints = orderedBaseLinePoints.map((p) => ({ x: p.x, y: p.y }))
|
||||
roofLineContactPoints = orderedBaseLinePoints.map((p) => ({ x: p.x, y: p.y }))
|
||||
console.log('[v1 SK_INPUT] wall.baseLines 직접 사용 (45° 확장/movedPoints 무시):',
|
||||
changRoofLinePoints.map((p, i) => `[${i}](${Math.round(p.x)},${Math.round(p.y)})`).join(' '))
|
||||
|
||||
// [v1 평행오버 corner 흡수 2026-04-29]
|
||||
// 평행오버 시 OVER_GUARD 가 인접 baseLine 길이를 OVER_EPS(0.5) 만큼만 남김.
|
||||
// 그 corner 는 사실상 평행 — 모서리가 없어진 상태이므로 SK 빌더 입력에서 제외해야
|
||||
// 헛 hip / 헛 ext 가 만들어지지 않는다.
|
||||
// 조건: 인접점 거리 < DUP_EPS 또는 (prev,curr,next) cross < COLLINEAR_EPS
|
||||
// 여러 점 동시 흡수 가능 → 다중 패스.
|
||||
const DUP_EPS = 1.0
|
||||
const COLLINEAR_EPS = 50.0 // |cross| 단위는 면적의 2배. baseLine 한쪽이 1px 미만이면 cross 도 매우 작음.
|
||||
let kept = changRoofLinePoints.slice()
|
||||
const absorbedAll = []
|
||||
for (let pass = 0; pass < 5 && kept.length > 3; pass++) {
|
||||
const next = []
|
||||
const absorbedPass = []
|
||||
for (let i = 0; i < kept.length; i++) {
|
||||
const prev = kept[(i - 1 + kept.length) % kept.length]
|
||||
const curr = kept[i]
|
||||
const nxt = kept[(i + 1) % kept.length]
|
||||
const dPrev = Math.hypot(curr.x - prev.x, curr.y - prev.y)
|
||||
const dNext = Math.hypot(nxt.x - curr.x, nxt.y - curr.y)
|
||||
const cross = (curr.x - prev.x) * (nxt.y - prev.y) - (curr.y - prev.y) * (nxt.x - prev.x)
|
||||
if (dPrev < DUP_EPS || dNext < DUP_EPS || Math.abs(cross) < COLLINEAR_EPS) {
|
||||
absorbedPass.push(`[${i}](${Math.round(curr.x)},${Math.round(curr.y)}) dPrev=${dPrev.toFixed(2)} dNext=${dNext.toFixed(2)} cross=${cross.toFixed(2)}`)
|
||||
continue
|
||||
}
|
||||
next.push(curr)
|
||||
}
|
||||
if (absorbedPass.length === 0) break
|
||||
absorbedAll.push(...absorbedPass)
|
||||
kept = next
|
||||
}
|
||||
if (absorbedAll.length > 0 && kept.length >= 3) {
|
||||
console.log(`[v1 SK_INPUT 평행흡수] ${absorbedAll.length}개 corner 제거:\n ${absorbedAll.join('\n ')}`)
|
||||
console.log(`[v1 SK_INPUT 평행흡수] 최종 ${kept.length}개 corner:`,
|
||||
kept.map((p, i) => `[${i}](${Math.round(p.x)},${Math.round(p.y)})`).join(' '))
|
||||
changRoofLinePoints = kept
|
||||
roofLineContactPoints = kept.map((p) => ({ x: p.x, y: p.y }))
|
||||
}
|
||||
}
|
||||
|
||||
console.log('[DEBUG] changRoofLinePoints(최종 SK입력):', changRoofLinePoints.map((p, i) => `[${i}] (${Math.round(p.x)},${Math.round(p.y)})`))
|
||||
|
||||
// 좌표 유효성 검증
|
||||
@ -942,6 +1272,11 @@ export const skeletonBuilder = (roofId, canvas, textMode) => {
|
||||
roof.innerLines = roof.innerLines || []
|
||||
roof.innerLines = createInnerLinesFromSkeleton(roofId, canvas, skeleton, textMode, isOverDetected, changRoofLinePoints)
|
||||
//console.log("roofInnerLines:::", roof.innerLines);
|
||||
|
||||
// [v1 helper 2026-04-29 B 경로] SK 입력 (offset 확장 baseLine 또는 movedPoints) 과 roofLinePoints 간
|
||||
// gap 을 시각화. innerLines 와 별개 collection. 기존 working code (processInBoth 등) 무영향.
|
||||
drawBaselineToRooflineHelpers(roof, canvas, changRoofLinePoints, roofLinePoints)
|
||||
|
||||
// 캔버스에 스켈레톤 상태 저장
|
||||
if (!canvas.skeletonStates) {
|
||||
canvas.skeletonStates = {}
|
||||
@ -1227,17 +1562,33 @@ const createInnerLinesFromSkeleton = (roofId, canvas, skeleton, textMode, isOver
|
||||
|
||||
//처마라인
|
||||
const roofLines = roof.lines
|
||||
//벽라인
|
||||
const wallLines = wall.lines
|
||||
//벽라인 — wall.baseLines 의 회전 기준에 맞춰 wall.lines 정렬 보정 (createOrderedBasePoints 사상)
|
||||
let wallLines = wall.lines
|
||||
if (wall.baseLines && wall.baseLines.length > 0 && wallLines && wallLines.length === wall.baseLines.length) {
|
||||
const refStart = wall.baseLines[0].startPoint
|
||||
if (refStart) {
|
||||
let rotIdx = wallLines.findIndex((wl) => wl.startPoint && isSamePoint(wl.startPoint, refStart, 1.0))
|
||||
if (rotIdx > 0) {
|
||||
wallLines = [...wallLines.slice(rotIdx), ...wallLines.slice(0, rotIdx)]
|
||||
if (process.env.NEXT_PUBLIC_RUN_MODE === 'local') {
|
||||
console.log('[WL-ROT-FIX] wall.lines rotated by', -rotIdx, 'to align with wall.baseLines[0].startPoint')
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
skeletonLines.forEach((sktLine, skIndex) => {
|
||||
let { p1, p2, attributes, lineStyle } = sktLine;
|
||||
|
||||
// 중복방지 - 라인을 고유하게 식별할 수 있는 키 생성 (정규화된 좌표로 정렬하여 비교)
|
||||
const lineKey = [
|
||||
[p1.x, p1.y].sort().join(','),
|
||||
[p2.x, p2.y].sort().join(',')
|
||||
].sort().join('|');
|
||||
// [dedup fix 2026-04-30] 중복방지 키 — drift 흡수 + endpoint 순서 무관.
|
||||
// 기존: [p1.x,p1.y].sort() — 한 점의 x/y 를 정렬하던 의도불명 코드 +
|
||||
// exact string 매치 → SK 빌더 face A/B 가 같은 inner edge 를 1e-7
|
||||
// drift 로 emit 하면 dedup 실패 → 가짜 RG-N (dead-end) 생성.
|
||||
// 수정: 1696라인 _keyOfEdge 와 동일 패턴 — 0.1 단위 round 후 점 쌍 정렬.
|
||||
const _ptKey = (p) => `${Math.round(p.x * 10) / 10},${Math.round(p.y * 10) / 10}`
|
||||
const _a = _ptKey(p1)
|
||||
const _b = _ptKey(p2)
|
||||
const lineKey = _a < _b ? `${_a}|${_b}` : `${_b}|${_a}`
|
||||
|
||||
// 이미 추가된 라인인지 확인
|
||||
if (existingLines.has(lineKey)) {
|
||||
@ -1363,6 +1714,30 @@ const createInnerLinesFromSkeleton = (roofId, canvas, skeleton, textMode, isOver
|
||||
return origIdx != null && wall.baseLines[origIdx] ? wall.baseLines[origIdx] : sl
|
||||
})
|
||||
|
||||
// [PAIR-DIAG] local only — top in 시 페어 인덱스 어긋남 진단 (2026-04-30)
|
||||
// 1) 길이 미스매치 / 2) _keyOfEdge 충돌 / 3) raw 단계 1:1 무너짐 가려내기 위함
|
||||
if (process.env.NEXT_PUBLIC_RUN_MODE === 'local') {
|
||||
console.log('[PAIR-DIAG] lengths', {
|
||||
roofLines: roofLines.length,
|
||||
wallLines: wallLines.length,
|
||||
wallBaseLines: wall.baseLines.length,
|
||||
})
|
||||
const _seen = new Map()
|
||||
roofLines.forEach((l, i) => {
|
||||
const k = _keyOfEdge(l)
|
||||
if (_seen.has(k)) console.warn(`[PAIR-DIAG] key collision: idx ${_seen.get(k)} vs ${i} key=${k}`)
|
||||
else _seen.set(k, i)
|
||||
})
|
||||
roofLines.forEach((rl, i) => {
|
||||
const wl = wallLines[i]
|
||||
const wb = wall.baseLines[i]
|
||||
console.log(`[PAIR-DIAG] raw#${i}`,
|
||||
`roof=(${Math.round(rl.x1)},${Math.round(rl.y1)})→(${Math.round(rl.x2)},${Math.round(rl.y2)})`,
|
||||
wl ? `wall=(${Math.round(wl.x1)},${Math.round(wl.y1)})→(${Math.round(wl.x2)},${Math.round(wl.y2)})` : 'wall=∅',
|
||||
wb ? `base=(${Math.round(wb.x1)},${Math.round(wb.y1)})→(${Math.round(wb.x2)},${Math.round(wb.y2)})` : 'base=∅')
|
||||
})
|
||||
}
|
||||
|
||||
// [MOVE-TRACE] 진입 스냅샷 (필요 시 주석 해제)
|
||||
// console.log('[MOVE-TRACE] === getMoveUpDownLine entry ===',
|
||||
// 'moveUpDown=', roof.moveUpDown,
|
||||
@ -1669,6 +2044,46 @@ const createInnerLinesFromSkeleton = (roofId, canvas, skeleton, textMode, isOver
|
||||
}
|
||||
|
||||
if (isCollinear(wallBaseLine, wallLine)) {
|
||||
// [v1 corner-gap eaveHelpLine 2026-04-30] collinear 라도 한쪽 끝점이 어긋난 corner shift 라면
|
||||
// gap segment 를 eaveHelpLine 으로 추가. 미적용 시 SK ext 가 다른 짧은 보조선에 잘못 hit.
|
||||
// tolerance=1.0 (메모리: UI/Big.js drift 고려).
|
||||
const __cgStartSame = Math.abs(wallLine.x1 - wallBaseLine.x1) < 1.0 && Math.abs(wallLine.y1 - wallBaseLine.y1) < 1.0
|
||||
const __cgEndSame = Math.abs(wallLine.x2 - wallBaseLine.x2) < 1.0 && Math.abs(wallLine.y2 - wallBaseLine.y2) < 1.0
|
||||
const __cgPairs = []
|
||||
if (__cgStartSame && !__cgEndSame) {
|
||||
__cgPairs.push([{ x: wallLine.x2, y: wallLine.y2 }, { x: wallBaseLine.x2, y: wallBaseLine.y2 }])
|
||||
} else if (!__cgStartSame && __cgEndSame) {
|
||||
__cgPairs.push([{ x: wallLine.x1, y: wallLine.y1 }, { x: wallBaseLine.x1, y: wallBaseLine.y1 }])
|
||||
} else if (!__cgStartSame && !__cgEndSame) {
|
||||
__cgPairs.push([{ x: wallLine.x1, y: wallLine.y1 }, { x: wallBaseLine.x1, y: wallBaseLine.y1 }])
|
||||
__cgPairs.push([{ x: wallLine.x2, y: wallLine.y2 }, { x: wallBaseLine.x2, y: wallBaseLine.y2 }])
|
||||
}
|
||||
for (const [p1, p2] of __cgPairs) {
|
||||
if (Math.hypot(p2.x - p1.x, p2.y - p1.y) < 0.5) continue
|
||||
console.log(`🎯 [corner-gap eaveHelpLine] index=${index} p1=(${Math.round(p1.x)},${Math.round(p1.y)})→p2=(${Math.round(p2.x)},${Math.round(p2.y)}) wallLine=(${Math.round(wallLine.x1)},${Math.round(wallLine.y1)})→(${Math.round(wallLine.x2)},${Math.round(wallLine.y2)}) wallBaseLine=(${Math.round(wallBaseLine.x1)},${Math.round(wallBaseLine.y1)})→(${Math.round(wallBaseLine.x2)},${Math.round(wallBaseLine.y2)})`)
|
||||
const __cgLine = new QLine([p1.x, p1.y, p2.x, p2.y], {
|
||||
parentId: roof.id,
|
||||
fontSize: roof.fontSize,
|
||||
stroke: '',
|
||||
strokeWidth: 4,
|
||||
name: 'eaveHelpLine',
|
||||
lineName: 'eaveHelpLine',
|
||||
visible: true,
|
||||
roofId: roofId,
|
||||
selectable: true,
|
||||
hoverCursor: 'pointer',
|
||||
attributes: {
|
||||
type: 'eaveHelpLine',
|
||||
isStart: true,
|
||||
pitch: wallLine.attributes?.pitch,
|
||||
planeSize: calcLinePlaneSize({ x1: p1.x, y1: p1.y, x2: p2.x, y2: p2.y }),
|
||||
actualSize: calcLinePlaneSize({ x1: p1.x, y1: p1.y, x2: p2.x, y2: p2.y }),
|
||||
},
|
||||
})
|
||||
canvas.add(__cgLine)
|
||||
__cgLine.bringToFront()
|
||||
}
|
||||
if (__cgPairs.length > 0) canvas.renderAll()
|
||||
return
|
||||
}
|
||||
|
||||
@ -2363,6 +2778,90 @@ const createInnerLinesFromSkeleton = (roofId, canvas, skeleton, textMode, isOver
|
||||
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────
|
||||
// [B안 cull 2026-04-30] dead-end ridge 제거
|
||||
//
|
||||
// 배경:
|
||||
// 정확한 수치 입력 + 마루이동 누적시 SK builder (StraightSkeleton) 가
|
||||
// 1e-4~1e-7 좌표 drift 로 phantom skeleton edge 1개를 emit 하는 케이스.
|
||||
// dedup(line 1583) 으로는 단일 entry 라 못 잡음 → 화면에 RG-N 으로 등장.
|
||||
//
|
||||
// 정의:
|
||||
// - lineName === 'ridge' 인데 한쪽 endpoint 가 다른 어떤 innerLine 과도
|
||||
// 연결되지 않고(degree=1, 0.1mm round 기준) 지붕 경계 corner 도 아님.
|
||||
// - 이 조건을 만족하는 ridge 만 phantom 으로 간주하고 제거.
|
||||
//
|
||||
// 안전장치:
|
||||
// - hip / eaves / roofLine 은 절대 건드리지 않음 (lineName 가드).
|
||||
// - roof.points / roof.lines 의 corner 와 1mm 이내면 정상 끝점으로 인정.
|
||||
// - 정상 마루이동/gable 처리 결과는 양 끝이 다른 라인 또는 corner 와 연결됨.
|
||||
// ─────────────────────────────────────────────────────────────────────────
|
||||
;(() => {
|
||||
const TOL = 1.0
|
||||
const isNear = (a, b) => Math.abs(a.x - b.x) < TOL && Math.abs(a.y - b.y) < TOL
|
||||
const roofVertices = []
|
||||
;(roof.points || []).forEach((p) => roofVertices.push(p))
|
||||
;(roof.lines || []).forEach((l) => {
|
||||
roofVertices.push({ x: l.x1, y: l.y1 })
|
||||
roofVertices.push({ x: l.x2, y: l.y2 })
|
||||
})
|
||||
const isRoofVertex = (p) => roofVertices.some((v) => isNear(v, p))
|
||||
|
||||
const ptKey = (p) => `${Math.round(p.x * 10) / 10},${Math.round(p.y * 10) / 10}`
|
||||
|
||||
const toRemove = []
|
||||
|
||||
// 1단계: zero-length 퇴화 ridge 먼저 제거.
|
||||
// SK builder drift 로 한 점에서 시작/끝나는 ridge 가 emit 되는 케이스.
|
||||
// 이 라인이 살아있으면 그 점의 degree 가 +2 부풀려져 진짜 dead-end ridge 가
|
||||
// degree=3 으로 위장되어 2단계 가드를 우회함.
|
||||
const ZERO_LEN = 0.5
|
||||
innerLines.forEach((line) => {
|
||||
if (line.lineName !== 'ridge') return
|
||||
const dx = Math.abs(line.x2 - line.x1)
|
||||
const dy = Math.abs(line.y2 - line.y1)
|
||||
if (dx < ZERO_LEN && dy < ZERO_LEN) toRemove.push(line)
|
||||
})
|
||||
|
||||
// 2단계: zero-length 제외하고 degree 재계산 → dead-end ridge 식별.
|
||||
const degree = new Map()
|
||||
innerLines.forEach((l) => {
|
||||
if (toRemove.includes(l)) return
|
||||
const k1 = ptKey({ x: l.x1, y: l.y1 })
|
||||
const k2 = ptKey({ x: l.x2, y: l.y2 })
|
||||
degree.set(k1, (degree.get(k1) || 0) + 1)
|
||||
degree.set(k2, (degree.get(k2) || 0) + 1)
|
||||
})
|
||||
|
||||
innerLines.forEach((line) => {
|
||||
if (toRemove.includes(line)) return
|
||||
if (line.lineName !== 'ridge') return
|
||||
const p1 = { x: line.x1, y: line.y1 }
|
||||
const p2 = { x: line.x2, y: line.y2 }
|
||||
const p1Dead = degree.get(ptKey(p1)) === 1 && !isRoofVertex(p1)
|
||||
const p2Dead = degree.get(ptKey(p2)) === 1 && !isRoofVertex(p2)
|
||||
if (p1Dead || p2Dead) toRemove.push(line)
|
||||
})
|
||||
|
||||
if (toRemove.length > 0) {
|
||||
if (process.env.NEXT_PUBLIC_RUN_MODE === 'local') {
|
||||
console.log(
|
||||
`[B안 cull] dead-end ridge 제거 ${toRemove.length}개`,
|
||||
toRemove.map(
|
||||
(l) => `(${Math.round(l.x1)},${Math.round(l.y1)})→(${Math.round(l.x2)},${Math.round(l.y2)})`
|
||||
)
|
||||
)
|
||||
}
|
||||
toRemove.forEach((line) => {
|
||||
canvas.remove(line)
|
||||
const idx = innerLines.indexOf(line)
|
||||
if (idx >= 0) innerLines.splice(idx, 1)
|
||||
})
|
||||
canvas.renderAll()
|
||||
}
|
||||
})()
|
||||
|
||||
|
||||
if (findPoints.length > 0) {
|
||||
// 모든 점에 대해 라인 업데이트를 누적
|
||||
return findPoints.reduce((innerLines, point) => {
|
||||
@ -2413,7 +2912,12 @@ function processEavesEdge(roofId, canvas, skeleton, edgeResult, skeletonLines) {
|
||||
console.log('Has matching line:', outerLine);
|
||||
//if(outerLine === null) return
|
||||
}
|
||||
let pitch = outerLine?.attributes?.pitch??0
|
||||
// [hip pitch fallback 2026-04-30]
|
||||
// outerLine.attributes.pitch 가 없으면 0 → calcLineActualSize2(deg=0) 가
|
||||
// calcLinePlaneSize 와 동치(tan0=0) → 모든 hip 의 planeSize === actualSize.
|
||||
// 결과: 伏せ図(plane) ↔ 配置面(actual) 메뉴 토글해도 hip 라벨 변화 없음.
|
||||
// roof.pitch 폴백(usePolygon.js:1649 패턴) 으로 진짜 pitch 확보.
|
||||
let pitch = outerLine?.attributes?.pitch ?? roof?.pitch ?? 0
|
||||
|
||||
|
||||
const convertedPolygon = edgeResult.Polygon?.map(point => ({
|
||||
@ -2873,7 +3377,12 @@ function addRawLine(id, skeletonLines, p1, p2, lineType, color, width, pitch, is
|
||||
const currentDegree = getDegreeByChon(pitch)
|
||||
const dx = Math.abs(p2.x - p1.x);
|
||||
const dy = Math.abs(p2.y - p1.y);
|
||||
const isDiagonal = dx > 0.1 && dy > 0.1;
|
||||
// [HIP/RIDGE 임계 2026-04-30] 0.1 → 5.0 상향.
|
||||
// 배경: 벽라인 이동 누적 drift 로 수직/수평 ridge 의 한쪽 축에 0.1mm 초과
|
||||
// drift 가 생기면 즉시 HIP 로 재분류되던 케이스 수정.
|
||||
// 진짜 hip 은 dx, dy 모두 수십 mm 이상 → 5mm 임계로 영향 없음.
|
||||
// drift ridge 는 작은 축이 ~수 mm 이내 → ridge 유지.
|
||||
const isDiagonal = dx > 5.0 && dy > 5.0;
|
||||
const normalizedType = isDiagonal ? LINE_TYPE.SUBLINE.HIP : lineType;
|
||||
|
||||
// Count existing HIP lines
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user