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() }