From abee58fcae6700bddee1a283755ca140f2955946 Mon Sep 17 00:00:00 2001 From: ysCha Date: Wed, 29 Apr 2026 17:47:46 +0900 Subject: [PATCH 1/8] =?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 = {} From 2a7c78a9270b740c4ab58419de306eca64e61d9d Mon Sep 17 00:00:00 2001 From: ysCha Date: Wed, 29 Apr 2026 18:02:14 +0900 Subject: [PATCH 2/8] =?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, From beeeeb32e58da513241a976004818bf42d87a03a Mon Sep 17 00:00:00 2001 From: ysCha Date: Wed, 29 Apr 2026 18:31:14 +0900 Subject: [PATCH 3/8] =?UTF-8?q?=EB=A7=88=EB=A3=A8=EC=9D=B4=EB=8F=99?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/hooks/roofcover/useMovementSetting.js | 19 +++++++++++-------- 1 file changed, 11 insertions(+), 8 deletions(-) diff --git a/src/hooks/roofcover/useMovementSetting.js b/src/hooks/roofcover/useMovementSetting.js index fccf759b..25ba502f 100644 --- a/src/hooks/roofcover/useMovementSetting.js +++ b/src/hooks/roofcover/useMovementSetting.js @@ -769,10 +769,14 @@ export function useMovementSetting(id) { `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)`) + // [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) @@ -787,10 +791,9 @@ export function useMovementSetting(id) { const currentLine = targetItem.line const __horizCurr = currentLine.y1 === currentLine.y2 - // [FIX-2] mixed-orientation 가드: linePosition 가족과 currentLine 방향 불일치 → 이 라인은 스킵. - // gable 보강 push (zeroLengthLines.forEach) 가 좌표 매칭만으로 인접 라인을 추가하면서 - // 가로/세로 혼재된 targetBaseLines 가 만들어지는 경우 방어. - if (__horizCurr !== __targetIsHoriz) { + // [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 ? '수평' : '수직'} ` + From 645e8b344dfc5f6e7e9001c2bb90d79523b56769 Mon Sep 17 00:00:00 2001 From: ysCha Date: Thu, 30 Apr 2026 11:16:19 +0900 Subject: [PATCH 4/8] =?UTF-8?q?=EB=B3=B4=EC=A1=B0=EB=9D=BC=EC=9D=B81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/components/fabric/QPolygon.js | 3 +- src/hooks/roofcover/useMovementSetting.js | 11 +- .../roofcover/useRoofShapePassivitySetting.js | 1 + src/util/skeleton-utils.js | 111 +++++++++++++++++- 4 files changed, 115 insertions(+), 11 deletions(-) diff --git a/src/components/fabric/QPolygon.js b/src/components/fabric/QPolygon.js index a947d511..29868836 100644 --- a/src/components/fabric/QPolygon.js +++ b/src/components/fabric/QPolygon.js @@ -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) { diff --git a/src/hooks/roofcover/useMovementSetting.js b/src/hooks/roofcover/useMovementSetting.js index 25ba502f..be2608cb 100644 --- a/src/hooks/roofcover/useMovementSetting.js +++ b/src/hooks/roofcover/useMovementSetting.js @@ -443,11 +443,11 @@ export function useMovementSetting(id) { // 실패 시 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'}` - ) + // console.log( + // `[MV-TRACE] type=${typeRef.current} value=${value?.toString?.()} dir=${direction} ` + + // `inputUpdated=${__inputUpdated} ` + + // `selectedObject=${selectedObject.current ? 'set' : 'null'}` + // ) } } @@ -529,6 +529,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 diff --git a/src/hooks/roofcover/useRoofShapePassivitySetting.js b/src/hooks/roofcover/useRoofShapePassivitySetting.js index 6149ca39..e0223139 100644 --- a/src/hooks/roofcover/useRoofShapePassivitySetting.js +++ b/src/hooks/roofcover/useRoofShapePassivitySetting.js @@ -240,6 +240,7 @@ export function useRoofShapePassivitySetting(id) { } wall.lines = [...lines] + // 기존 그려진 지붕이 없다면 if (isFix.current) { diff --git a/src/util/skeleton-utils.js b/src/util/skeleton-utils.js index a708f359..37e31da6 100644 --- a/src/util/skeleton-utils.js +++ b/src/util/skeleton-utils.js @@ -754,7 +754,17 @@ const drawBaselineToRooflineHelpers = (roof, canvas, baseLinePoints, roofLinePoi 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 } }) + 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 만 대상. @@ -796,12 +806,20 @@ const drawBaselineToRooflineHelpers = (roof, canvas, baseLinePoints, roofLinePoi // 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) { @@ -809,6 +827,15 @@ const drawBaselineToRooflineHelpers = (roof, canvas, baseLinePoints, roofLinePoi 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 @@ -1494,8 +1521,20 @@ 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; @@ -1630,6 +1669,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, @@ -1936,6 +1999,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 } @@ -1976,7 +2079,7 @@ const createInnerLinesFromSkeleton = (roofId, canvas, skeleton, textMode, isOver const line = new QLine([p1.x, p1.y, p2.x, p2.y], { parentId: roof.id, fontSize: roof.fontSize, - stroke: 'black', + stroke: stroke, strokeWidth: 4, name: lineType, lineName: lineType, From c221515744fca7ec257a806c46242b50e6ee4363 Mon Sep 17 00:00:00 2001 From: ysCha Date: Thu, 30 Apr 2026 12:21:59 +0900 Subject: [PATCH 5/8] =?UTF-8?q?=EB=8F=99=EC=9D=BC=EB=9D=BC=EB=B2=A8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/hooks/useLine.js | 12 +++++-- src/hooks/useText.js | 84 ++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 94 insertions(+), 2 deletions(-) diff --git a/src/hooks/useLine.js b/src/hooks/useLine.js index 351760d6..fcca1bbc 100644 --- a/src/hooks/useLine.js +++ b/src/hooks/useLine.js @@ -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 } diff --git a/src/hooks/useText.js b/src/hooks/useText.js index 134ade4a..4bd4865c 100644 --- a/src/hooks/useText.js +++ b/src/hooks/useText.js @@ -42,6 +42,90 @@ 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) => { + const dx = (obj.maxX ?? 0) - (obj.minX ?? 0) + const dy = (obj.maxY ?? 0) - (obj.minY ?? 0) + return dx > 0.5 && dy > 0.5 + }) + + 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() } From 02c8422e870709fb5088aef4fe303dad868817d6 Mon Sep 17 00:00:00 2001 From: ysCha Date: Thu, 30 Apr 2026 13:07:50 +0900 Subject: [PATCH 6/8] =?UTF-8?q?=EB=9D=BC=EC=9D=B8=EC=83=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/util/skeleton-utils.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/util/skeleton-utils.js b/src/util/skeleton-utils.js index 37e31da6..696de58f 100644 --- a/src/util/skeleton-utils.js +++ b/src/util/skeleton-utils.js @@ -2079,7 +2079,7 @@ const createInnerLinesFromSkeleton = (roofId, canvas, skeleton, textMode, isOver const line = new QLine([p1.x, p1.y, p2.x, p2.y], { parentId: roof.id, fontSize: roof.fontSize, - stroke: stroke, + stroke: 'black', strokeWidth: 4, name: lineType, lineName: lineType, From c2eba138a6d1e8847be04a2fc75cda46895f1b35 Mon Sep 17 00:00:00 2001 From: ysCha Date: Thu, 30 Apr 2026 17:02:16 +0900 Subject: [PATCH 7/8] innerLine --- src/hooks/roofcover/useMovementSetting.js | 57 +++++++ .../roofcover/useRoofAllocationSetting.js | 25 ++- src/util/skeleton-utils.js | 148 +++++++++++++++++- 3 files changed, 218 insertions(+), 12 deletions(-) diff --git a/src/hooks/roofcover/useMovementSetting.js b/src/hooks/roofcover/useMovementSetting.js index be2608cb..22b48f52 100644 --- a/src/hooks/roofcover/useMovementSetting.js +++ b/src/hooks/roofcover/useMovementSetting.js @@ -94,6 +94,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) => { @@ -672,6 +694,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) diff --git a/src/hooks/roofcover/useRoofAllocationSetting.js b/src/hooks/roofcover/useRoofAllocationSetting.js index 09c6efa9..c5ac8d61 100644 --- a/src/hooks/roofcover/useRoofAllocationSetting.js +++ b/src/hooks/roofcover/useRoofAllocationSetting.js @@ -468,6 +468,10 @@ export function useRoofAllocationSetting(id) { 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 } @@ -491,6 +495,15 @@ export function useRoofAllocationSetting(id) { 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 @@ -541,25 +554,27 @@ export function useRoofAllocationSetting(id) { merged.push(mergedLine) }) - if (merged.length === 0) { + if (merged.length === 0 && extLinesOnly.length === 0) { console.log(`[INTEGRATE] 통합 대상 없음 ext=${extLines.length}`) return } - roofBase.lines = roofBase.lines.filter((l) => !removedExt.includes(l)) + // 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 정리: - // - 원본 ext 제거 - // - 원본 sk 도 canvas 에서 제거 (innerLines 배열에서 빠졌으므로 apply() 끝의 일괄 제거가 닿지 않음) - // - 둘 다 남기면 라벨/라인이 중복 표시됨 + // - 기존 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}` ) } diff --git a/src/util/skeleton-utils.js b/src/util/skeleton-utils.js index 696de58f..b78a5618 100644 --- a/src/util/skeleton-utils.js +++ b/src/util/skeleton-utils.js @@ -845,6 +845,45 @@ const drawBaselineToRooflineHelpers = (roof, canvas, baseLinePoints, roofLinePoi `→ 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 { 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)) { @@ -2733,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) => { @@ -3243,7 +3372,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 From 376ccab8e74de2b7a8a60aef6e8f63e8fbda73f6 Mon Sep 17 00:00:00 2001 From: ysCha Date: Thu, 30 Apr 2026 18:26:34 +0900 Subject: [PATCH 8/8] =?UTF-8?q?=EB=9D=BC=EB=B2=A8=EB=B3=80=EA=B2=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../roofcover/useRoofAllocationSetting.js | 43 ++++++- src/hooks/useText.js | 107 ++++++++++++++++-- src/util/skeleton-utils.js | 7 +- 3 files changed, 147 insertions(+), 10 deletions(-) diff --git a/src/hooks/roofcover/useRoofAllocationSetting.js b/src/hooks/roofcover/useRoofAllocationSetting.js index c5ac8d61..cd0fc1a6 100644 --- a/src/hooks/roofcover/useRoofAllocationSetting.js +++ b/src/hooks/roofcover/useRoofAllocationSetting.js @@ -31,6 +31,7 @@ import { usePlan } from '@/hooks/usePlan' import { roofsState } from '@/store/roofAtom' import { useText } from '@/hooks/useText' import { QLine } from '@/components/fabric/QLine' +import { calcLineActualSize2 } from '@/util/qpolygon-utils' export function useRoofAllocationSetting(id) { const canvas = useRecoilValue(canvasState) @@ -405,7 +406,43 @@ 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 }) + } } }) } @@ -479,6 +516,10 @@ export function useRoofAllocationSetting(id) { 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) diff --git a/src/hooks/useText.js b/src/hooks/useText.js index 4bd4865c..01b8d51a 100644 --- a/src/hooks/useText.js +++ b/src/hooks/useText.js @@ -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 @@ -85,9 +164,21 @@ export function useText() { // lengthText 객체는 QPolygon.js:712-734 에서 minX/maxX/minY/maxY 로 부모 라인의 // 바운딩박스를 저장. 둘 다 양수 차이 = 대각선. const hipTexts = lengthTexts.filter((obj) => { - const dx = (obj.maxX ?? 0) - (obj.minX ?? 0) - const dy = (obj.maxY ?? 0) - (obj.minY ?? 0) - return dx > 0.5 && dy > 0.5 + // [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) { diff --git a/src/util/skeleton-utils.js b/src/util/skeleton-utils.js index b78a5618..7ade81e8 100644 --- a/src/util/skeleton-utils.js +++ b/src/util/skeleton-utils.js @@ -2912,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 => ({