import { useRecoilValue } from 'recoil' import { corridorDimensionSelector } from '@/store/settingAtom' 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 if (columnText) { column = columnText } const lengthTexts = canvas.getObjects().filter((obj) => obj.name === 'lengthText') const group = canvas.getObjects().filter((obj) => obj.type === 'group') group.forEach((obj) => { obj._objects .filter((obj2) => obj2.name === 'lengthText') .forEach((obj3) => { lengthTexts.push(obj3) }) }) // ───────────────────────────────────────────────────────────────────────── // [라벨 토글 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 } // [2026-05-18 라벨 통일] planeSize/actualSize 모두 저장값 그대로 표시. // planeSize 는 정수(calcLinePlaneSize) 가 기본, hip equalize 후 0.5 step 가능. // actualSize 는 0.5 step. 둘 다 .toFixed(1) 후 trailing .0 제거. switch (column) { case 'corridorDimension': lengthTexts.forEach((obj) => { const v = resolveSize(obj, 'planeSize') if (v) { obj.set({ text: Number(v).toFixed(1).replace(/\.0$/, '') }) } }) break case 'realDimension': lengthTexts.forEach((obj) => { // hip 이고 actualSize===planeSize 면 pitch 로 즉석 재계산. recomputeHipActualIfFlat(obj) const v = resolveSize(obj, 'actualSize') if (v) { obj.set({ text: Number(v).toFixed(1).replace(/\.0$/, '') }) } }) break case 'noneDimension': lengthTexts.forEach((obj) => { obj.set({ text: '' }) }) break } // ───────────────────────────────────────────────────────────────────────── // [hip 라벨 cross-display 통일 2026-04-30 by Claude] // // 배경: // 좌우대칭 hip(대각선) 라인의 라벨이 0.5mm 차이로 갈리는 케이스 발생 // (예: 좌측 5048.5 / 우측 5048). // // 원인: // 1) polygon split / ridge intersection 단계 부동소수점 연산 // → QLine.js:23 toFixed(1) 0.1mm 단위 절단 → 좌우 hip 좌표가 0.1mm 차 // 2) useLine.js setActualSize 의 axisLength * tan + sqrt 계산에서 // 좌표 0.1mm 차가 ×10 으로 1mm 차로 증폭 // 3) 마지막 `Math.round(actualSize * 2) / 2` (0.5mm 단위) boundary 에서 갈림 // → 한쪽은 5048.0, 한쪽은 5048.5 로 결정 // 4) planeSize 도 usePolygon.js 의 _equalizeIfNearSymmetric([avg, avg]) // 경로에서 0.5mm 단위 값이 만들어질 수 있음 (corridorDimension 모드에 영향) // // 수정 방침: // - **표시 레이어 (lengthText.text) 만 보정**. // - line.attributes.actualSize / line.attributes.planeSize 는 절대 안 건드림. // → 다운스트림 read 경로 (라인 분할 비율 / 확장라인 병합 / 모듈배치 shape // 상속 / JSON 저장 / DB / 복제 플랜) 영향 0. // // 처리: // - 대각선 hip 라벨끼리 묶어서, 표시값이 ±2mm 이내이면 정수 round 한 // 공통값으로 스냅 (5048.5/5048 → 둘 다 5049 또는 5048). // - corridorDimension / realDimension 모드 모두 적용. // - noneDimension 모드는 빈 문자열이라 패스. // // 추후 근본 수정 (다른 개발자 검토 필요): // - QLine.js:23 toFixed(1) → toFixed(2) 정밀도 상향 (영향 범위 넓어 별건) // - skeleton-utils.js getLineIntersection 결과 좌표 round // - useLine.js setActualSize 마지막 round 단위 변경 (0.5 → 1.0) // 위 근본수정이 들어가면 이 보정 코드는 제거 가능. // // 메모리: feedback_display_vs_internal_values.md 참고. // ───────────────────────────────────────────────────────────────────────── if (column === 'corridorDimension' || column === 'realDimension') { // 대각선(hip) lengthText 추출. // lengthText 객체는 QPolygon.js:712-734 에서 minX/maxX/minY/maxY 로 부모 라인의 // 바운딩박스를 저장. 둘 다 양수 차이 = 대각선. const hipTexts = lengthTexts.filter((obj) => { // [parent 좌표 기반 isDiagonal — QLine lengthText min/max 부정확 회피] // QLine.js:122-125 가 minY/maxY 를 this.top/this.top+this.length 로 설정해 // 수평 ridge 도 maxY-minY>0 이 됨. 반드시 parent.x1/y1/x2/y2 로 판정. const parent = obj.parent ?? canvas.getObjects().find((p) => p.id === obj.parentId) if (!parent) return false const px1 = Number(parent.x1) const py1 = Number(parent.y1) const px2 = Number(parent.x2) const py2 = Number(parent.y2) if (!Number.isFinite(px1) || !Number.isFinite(py1) || !Number.isFinite(px2) || !Number.isFinite(py2)) { return false } const dx = Math.abs(px2 - px1) const dy = Math.abs(py2 - py1) return dx > 5.0 && dy > 5.0 }) if (hipTexts.length >= 2) { // 현재 표시 텍스트를 숫자로 파싱. const items = hipTexts .map((obj) => ({ obj, value: parseFloat(obj.text) })) .filter((item) => !isNaN(item.value)) // 그룹화: 표시값이 ±TOLERANCE mm 이내면 동일 그룹. // TOLERANCE=2 는 0.5 단위 round drift + sqrt fp 노이즈 흡수. // 대칭 hip 들의 정상 길이차 (예: 다른 지붕면의 hip) 보다는 작아야 함. const TOLERANCE = 2 const groups = [] for (const item of items) { const matched = groups.find((g) => Math.abs(g.representative - item.value) <= TOLERANCE) if (matched) { matched.items.push(item) // 그룹 대표값은 첫 항목 값 유지 (안정적인 클러스터링). } else { groups.push({ representative: item.value, items: [item] }) } } // 각 그룹의 멤버가 2개 이상이면 정수 round 평균값으로 통일. // 1개 그룹은 단독 hip 이므로 보정 불필요. for (const group of groups) { if (group.items.length < 2) continue const avg = group.items.reduce((s, i) => s + i.value, 0) / group.items.length // [2026-05-18 라벨 0.5 step 통일] corridor/real 모두 0.5 step. // planeSize 도 hip equalize 후 0.5 step 가능하므로 정수 round 하지 않음. const unified = (Math.round(avg * 2) / 2).toFixed(1).replace(/\.0$/, '') for (const item of group.items) { // text 만 set. obj.actualSize / obj.planeSize 는 그대로. item.obj.set({ text: unified }) } } } } // ───────────────────────────────────────────────────────────────────────── canvas?.renderAll() } return { changeCorridorDimensionText, } }