From abee58fcae6700bddee1a283755ca140f2955946 Mon Sep 17 00:00:00 2001 From: ysCha Date: Wed, 29 Apr 2026 17:47:46 +0900 Subject: [PATCH 1/2] =?UTF-8?q?=ED=99=95=EC=9E=A5=EC=9E=90=201=EB=8B=A8?= =?UTF-8?q?=EA=B3=84=EB=A7=88=EB=AC=B4=EB=A6=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/hooks/roofcover/useMovementSetting.js | 131 +++++++-- .../roofcover/useRoofAllocationSetting.js | 179 +++++++++++- src/hooks/useLine.js | 16 +- src/hooks/usePolygon.js | 5 +- src/util/skeleton-utils.js | 267 ++++++++++++++++++ 5 files changed, 566 insertions(+), 32 deletions(-) diff --git a/src/hooks/roofcover/useMovementSetting.js b/src/hooks/roofcover/useMovementSetting.js index d4f9fc74..fccf759b 100644 --- a/src/hooks/roofcover/useMovementSetting.js +++ b/src/hooks/roofcover/useMovementSetting.js @@ -56,6 +56,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(() => { @@ -253,23 +255,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 = '' @@ -315,15 +327,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) @@ -422,6 +439,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) => { @@ -431,10 +458,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') @@ -723,35 +757,78 @@ 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] linePosition='unknown' (또는 없음) 이면 부호 결정 불가 → 이동 자체 스킵. + // 기존: unknown 일 때 negation 안 거치고 부호 그대로 들어가 in/out 의도와 반대로 적용되는 case 발생. + if (linePosition !== 'top' && linePosition !== 'bottom' && linePosition !== 'left' && linePosition !== 'right') { + if (__isLocal) console.warn(`[BR-TRACE] linePosition=${linePosition} → 이동 스킵 (early return)`) + roof.drawHelpLine() + initEvent() + closePopup(id) + return + } + // 클로저 격리용: value 는 단일 사실(원본 부호) 로 두고, iter 마다 localValue 로만 부호 결정. + const __valueOriginal = value + const __targetIsHoriz = (linePosition === 'top' || linePosition === 'bottom') targetBaseLines .filter((line) => Math.sqrt(Math.pow(line.line.x2 - line.line.x1, 2) + Math.pow(line.line.y2 - line.line.y1, 2)) >= 1) - .forEach((target) => { - const currentLine = target.line + .forEach((targetItem, __iter) => { + const currentLine = targetItem.line + const __horizCurr = currentLine.y1 === currentLine.y2 - //console.log("linePosition::::::::::::::", linePosition) + // [FIX-2] mixed-orientation 가드: linePosition 가족과 currentLine 방향 불일치 → 이 라인은 스킵. + // gable 보강 push (zeroLengthLines.forEach) 가 좌표 매칭만으로 인접 라인을 추가하면서 + // 가로/세로 혼재된 targetBaseLines 가 만들어지는 경우 방어. + if (__horizCurr !== __targetIsHoriz) { + if (__isLocal) { + console.warn( + `[BR-TRACE] iter=${__iter} ⚠️ 방향 불일치 skip: currentLine ${__horizCurr ? '수평' : '수직'} ` + + `↔ linePosition=${linePosition} (예상 ${__targetIsHoriz ? '수평' : '수직'})` + ) + } + return + } + + // [FIX-3] 클로저 격리: 외부 value 변이 금지. iter 마다 localValue 로만 부호 결정. + let localValue = __valueOriginal if (UP_DOWN_REF?.DOWN_RADIO_REF?.current?.checked) { - //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 로 전환. diff --git a/src/hooks/roofcover/useRoofAllocationSetting.js b/src/hooks/roofcover/useRoofAllocationSetting.js index 568e2dc8..09c6efa9 100644 --- a/src/hooks/roofcover/useRoofAllocationSetting.js +++ b/src/hooks/roofcover/useRoofAllocationSetting.js @@ -436,12 +436,141 @@ 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 = [] + + 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 + 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 + } + + 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) { + console.log(`[INTEGRATE] 통합 대상 없음 ext=${extLines.length}`) + return + } + + roofBase.lines = roofBase.lines.filter((l) => !removedExt.includes(l)) + roofBase.innerLines = roofBase.innerLines.filter((l) => !removedSk.includes(l)) + roofBase.innerLines = [...roofBase.innerLines, ...merged] + + // canvas 정리: + // - 원본 ext 제거 + // - 원본 sk 도 canvas 에서 제거 (innerLines 배열에서 빠졌으므로 apply() 끝의 일괄 제거가 닿지 않음) + // - 둘 다 남기면 라벨/라인이 중복 표시됨 + 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} ` + + `→ lines=${roofBase.lines.length} innerLines=${roofBase.innerLines.length}` + ) + } + /** * 지붕면 할당 */ const apply = () => { 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 추가 @@ -449,6 +578,28 @@ export function useRoofAllocationSetting(id) { (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')) @@ -517,8 +668,20 @@ export function useRoofAllocationSetting(id) { }); // 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) + // [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] } @@ -555,6 +718,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 { diff --git a/src/hooks/useLine.js b/src/hooks/useLine.js index df876fb1..158b0fb7 100644 --- a/src/hooks/useLine.js +++ b/src/hooks/useLine.js @@ -189,7 +189,14 @@ export const useLine = () => { actualSize: calcLineActualSizeByLineLength(lineLength, getDegreeByChon(pitch)), } } else if (isDiagonal) { - const yLength = Math.abs(y2 - y1) * 10 + // [좌표정수화 비대칭 보정] face direction 무관하게 dx/dy 평균 비율 사용. + // (587,-58)→(931,285) 처럼 좌표 round 로 dx=344, dy=343 비대칭이 생겨도 + // 같은 라인은 face direction 과 무관하게 같은 actualSize 가 산출된다. + const dxRaw = Math.abs(x2 - x1) + const dyRaw = Math.abs(y2 - y1) + const hypotPx = Math.hypot(dxRaw, dyRaw) + const axisAvg = (dxRaw + dyRaw) / 2 + const yLength = hypotPx > 0 ? lineLength * (axisAvg / hypotPx) : 0 const h = yLength * Math.tan(getDegreeByChon(pitch) * (Math.PI / 180)) @@ -203,7 +210,12 @@ export const useLine = () => { actualSize: calcLineActualSizeByLineLength(lineLength, getDegreeByChon(pitch)), } } else if (isDiagonal) { - const xLength = Math.abs(x2 - x1) * 10 + // [좌표정수화 비대칭 보정] 위와 동일한 평균 axis 사용 → 두 face 일관성 보장 + const dxRaw = Math.abs(x2 - x1) + const dyRaw = Math.abs(y2 - y1) + const hypotPx = Math.hypot(dxRaw, dyRaw) + const axisAvg = (dxRaw + dyRaw) / 2 + const xLength = hypotPx > 0 ? lineLength * (axisAvg / hypotPx) : 0 const h = xLength * Math.tan(getDegreeByChon(pitch) * (Math.PI / 180)) diff --git a/src/hooks/usePolygon.js b/src/hooks/usePolygon.js index 9b17419e..b7b94347 100644 --- a/src/hooks/usePolygon.js +++ b/src/hooks/usePolygon.js @@ -89,8 +89,11 @@ export const usePolygon = () => { const maxY = line.top + line.length const degree = (Math.atan2(y2 - y1, x2 - x1) * 180) / Math.PI + // [표시 round] 저장값은 0.01 정밀도, 라벨은 정수 표시 const text = new fabric.Textbox( - +roofSizeSet === 1 ? (actualSize ? actualSize.toString() : length.toString()) : planeSize ? planeSize.toString() : length.toString(), + +roofSizeSet === 1 + ? (actualSize ? Math.round(actualSize).toString() : Math.round(length).toString()) + : planeSize ? Math.round(planeSize).toString() : Math.round(length).toString(), { left: left, top: top, diff --git a/src/util/skeleton-utils.js b/src/util/skeleton-utils.js index b0064ea0..a708f359 100644 --- a/src/util/skeleton-utils.js +++ b/src/util/skeleton-utils.js @@ -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,180 @@ 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 } }) + } + + // [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 = '' + for (let k = 0; k < segs.length; k++) { + const { A, B } = segs[k] + const t = rayHitSegment(outerEnd, { x: ux, y: uy }, A, B) + if (t < bestT) { + bestT = t + bestSrc = (k < m) ? 'roofLine' : '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 + } + + 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)})` + ) + + // [확장선 스타일] 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', + visible: true, + 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 +1046,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 +1204,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 = {} -- 2.47.2 From 2a7c78a9270b740c4ab58419de306eca64e61d9d Mon Sep 17 00:00:00 2001 From: ysCha Date: Wed, 29 Apr 2026 18:02:14 +0900 Subject: [PATCH 2/2] =?UTF-8?q?=ED=99=95=EC=9E=A5=EC=9E=90=201=EB=8B=A8?= =?UTF-8?q?=EA=B3=84=EB=A7=88=EB=AC=B4=EB=A6=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/hooks/useLine.js | 16 ++-------------- src/hooks/usePolygon.js | 5 +---- 2 files changed, 3 insertions(+), 18 deletions(-) diff --git a/src/hooks/useLine.js b/src/hooks/useLine.js index 7575e143..351760d6 100644 --- a/src/hooks/useLine.js +++ b/src/hooks/useLine.js @@ -189,14 +189,7 @@ export const useLine = () => { actualSize: calcLineActualSizeByLineLength(lineLength, getDegreeByChon(pitch)), } } else if (isDiagonal) { - // [좌표정수화 비대칭 보정] face direction 무관하게 dx/dy 평균 비율 사용. - // (587,-58)→(931,285) 처럼 좌표 round 로 dx=344, dy=343 비대칭이 생겨도 - // 같은 라인은 face direction 과 무관하게 같은 actualSize 가 산출된다. - const dxRaw = Math.abs(x2 - x1) - const dyRaw = Math.abs(y2 - y1) - const hypotPx = Math.hypot(dxRaw, dyRaw) - const axisAvg = (dxRaw + dyRaw) / 2 - const yLength = hypotPx > 0 ? lineLength * (axisAvg / hypotPx) : 0 + const yLength = Math.abs(y2 - y1) * 10 const h = yLength * Math.tan(getDegreeByChon(pitch) * (Math.PI / 180)) @@ -210,12 +203,7 @@ export const useLine = () => { actualSize: calcLineActualSizeByLineLength(lineLength, getDegreeByChon(pitch)), } } else if (isDiagonal) { - // [좌표정수화 비대칭 보정] 위와 동일한 평균 axis 사용 → 두 face 일관성 보장 - const dxRaw = Math.abs(x2 - x1) - const dyRaw = Math.abs(y2 - y1) - const hypotPx = Math.hypot(dxRaw, dyRaw) - const axisAvg = (dxRaw + dyRaw) / 2 - const xLength = hypotPx > 0 ? lineLength * (axisAvg / hypotPx) : 0 + const xLength = Math.abs(x2 - x1) * 10 const h = xLength * Math.tan(getDegreeByChon(pitch) * (Math.PI / 180)) diff --git a/src/hooks/usePolygon.js b/src/hooks/usePolygon.js index e7427b31..8b2f7c37 100644 --- a/src/hooks/usePolygon.js +++ b/src/hooks/usePolygon.js @@ -89,11 +89,8 @@ export const usePolygon = () => { const maxY = line.top + line.length const degree = (Math.atan2(y2 - y1, x2 - x1) * 180) / Math.PI - // [표시 round] 저장값은 0.01 정밀도, 라벨은 정수 표시 const text = new fabric.Textbox( - +roofSizeSet === 1 - ? (actualSize ? Math.round(actualSize).toString() : Math.round(length).toString()) - : planeSize ? Math.round(planeSize).toString() : Math.round(length).toString(), + +roofSizeSet === 1 ? (actualSize ? actualSize.toString() : length.toString()) : planeSize ? planeSize.toString() : length.toString(), { left: left, top: top, -- 2.47.2