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/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() } diff --git a/src/util/skeleton-utils.js b/src/util/skeleton-utils.js index a708f359..696de58f 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 }