diff --git a/src/components/fabric/QLine.js b/src/components/fabric/QLine.js index 03dd14db..be26d281 100644 --- a/src/components/fabric/QLine.js +++ b/src/components/fabric/QLine.js @@ -93,7 +93,12 @@ export const QLine = fabric.util.createClass(fabric.Line, { thisText.set({ actualSize: this.attributes.actualSize }) } if (this.attributes?.planeSize) { - thisText.set({ planeSize: this.attributes.planeSize, text: this.attributes.planeSize.toString() }) + // [2026-05-18 라벨 저장값 통일] planeSize 저장값 그대로 표시. + // 정수(calcLinePlaneSize) 또는 0.5 step(hip equalize 후) 모두 노출. + thisText.set({ + planeSize: this.attributes.planeSize, + text: Number(this.attributes.planeSize).toFixed(1).replace(/\.0$/, ''), + }) } // 라인이 이동했을 수 있으므로 위치도 재계산해서 업데이트한다. const sX = this.scaleX @@ -147,8 +152,9 @@ export const QLine = fabric.util.createClass(fabric.Line, { const maxY = this.top + this.length const degree = (Math.atan2(y2 - y1, x2 - x1) * 180) / Math.PI + // [2026-05-18 라벨 저장값 통일] planeSize 저장값(정수 또는 0.5 step) 그대로. const displayValue = this.attributes?.planeSize ?? this.getLength() - const text = new fabric.Textbox(displayValue.toString(), { + const text = new fabric.Textbox(Number(displayValue).toFixed(1).replace(/\.0$/, ''), { actualSize: this.attributes?.actualSize, planeSize: this.attributes?.planeSize, left: left, diff --git a/src/components/fabric/QPolygon.js b/src/components/fabric/QPolygon.js index a0fcb57f..d264a2fe 100644 --- a/src/components/fabric/QPolygon.js +++ b/src/components/fabric/QPolygon.js @@ -2,7 +2,7 @@ import { fabric } from 'fabric' import { v4 as uuidv4 } from 'uuid' import { QLine } from '@/components/fabric/QLine' import { distanceBetweenPoints, findTopTwoIndexesByDistance, getDirectionByPoint, sortedPointLessEightPoint, sortedPoints } from '@/util/canvas-util' -import { calculateAngle, drawGableRoof, drawRoofByAttribute, drawShedRoof, snapNearAxisEdges, toGeoJSON } from '@/util/qpolygon-utils' +import { calculateAngle, drawGableRoof, drawRoofByAttribute, drawShedRoof, equalizeSymmetricHips, snapNearAxisEdges, toGeoJSON } from '@/util/qpolygon-utils' import * as turf from '@turf/turf' import { LINE_TYPE, POLYGON_TYPE } from '@/common/common' import Big from 'big.js' @@ -610,49 +610,11 @@ export const QPolygon = fabric.util.createClass(fabric.Polygon, { drawRoofByAttribute(this.id, this.canvas, textMode) } - // 대칭 hip line 쌍의 planeSize/actualSize 를 평균값으로 통일 (예: 1637 / 1638 → 1637.5) - // hip 라인은 좌우 대칭이지만 부동소수점 round-off 로 1mm 차이가 발생함 + // 대칭 hip line 쌍의 planeSize/actualSize 통일 (±5mm 그룹 평균, 0.5 step) + // 부동소수점 round-off 로 좌우 hip 이 1mm 차이 나는 케이스 보정. if (this.innerLines && this.innerLines.length > 0) { const hips = this.innerLines.filter((l) => l && l.name === LINE_TYPE.SUBLINE.HIP) - const _segLen = (l) => Math.round(Math.sqrt((l.x2 - l.x1) ** 2 + (l.y2 - l.y1) ** 2) * 10) - const _equalizeText = (line, plane, actual) => { - if (line.attributes) { - line.attributes.planeSize = plane - line.attributes.actualSize = actual - } - // canvas 위 lengthText 도 즉시 갱신 - const text = this.canvas.getObjects().find((o) => o.name === 'lengthText' && o.parentId === line.id) - if (text) { - text.set({ planeSize: plane, actualSize: actual }) - // 현재 표시 모드에 맞춰 text 갱신 - const isPlane = !text.actualSize || text.text === String(text.planeSize) - text.set({ text: isPlane ? String(plane) : String(actual) }) - } - } - // 길이 그룹화: ±5mm 이내 길이끼리 묶고, 그 그룹 안에서 평균값으로 통일 - const used = new Set() - hips.forEach((h, i) => { - if (used.has(i)) return - const group = [{ idx: i, line: h }] - const baseLen = _segLen(h) - hips.forEach((h2, j) => { - if (j <= i || used.has(j)) return - if (Math.abs(_segLen(h2) - baseLen) <= 5) group.push({ idx: j, line: h2 }) - }) - if (group.length >= 2) { - const avgPlane = group.reduce((s, g) => s + (g.line.attributes?.planeSize || 0), 0) / group.length - const avgActual = group.reduce((s, g) => s + (g.line.attributes?.actualSize || 0), 0) / group.length - // 0.5 단위 정밀도 유지 - const plane = Math.round(avgPlane * 2) / 2 - const actual = Math.round(avgActual * 2) / 2 - group.forEach(({ idx, line }) => { - used.add(idx) - _equalizeText(line, plane, actual) - }) - } else { - used.add(i) - } - }) + equalizeSymmetricHips(hips, this.canvas) this.canvas?.renderAll() } @@ -697,6 +659,9 @@ export const QPolygon = fabric.util.createClass(fabric.Polygon, { const dx = Big(end.x).minus(Big(start.x)) const dy = Big(end.y).minus(Big(start.y)) const length = dx.pow(2).plus(dy.pow(2)).sqrt().times(10).round().toNumber() + // [2026-05-18 라벨 저장값 통일] planeSize(정수 또는 0.5 step) 그대로. + const __displayRaw = this.lines[i]?.attributes?.planeSize ?? length + const __display = Number(__displayRaw).toFixed(1).replace(/\.0$/, '') const direction = getDirectionByPoint(start, end) @@ -723,7 +688,7 @@ export const QPolygon = fabric.util.createClass(fabric.Polygon, { const degree = Big(Math.atan2(dy.toNumber(), dx.toNumber())).times(180).div(Math.PI).toNumber() // Create a new text object if it doesn't exist - const text = new fabric.Text(length.toString(), { + const text = new fabric.Text(__display, { left: midPoint.x, top: midPoint.y, fontSize: this.fontSize, diff --git a/src/hooks/roofcover/useMovementSetting.js b/src/hooks/roofcover/useMovementSetting.js index 9c2910d1..c84e1cf4 100644 --- a/src/hooks/roofcover/useMovementSetting.js +++ b/src/hooks/roofcover/useMovementSetting.js @@ -17,7 +17,8 @@ import { useMouse } from '@/hooks/useMouse' // 'passthrough' : 가드 전면 스킵 — 사용자가 입력한 raw 값을 그대로 적용. (과거 crossed 허용 동작; polygon 왜곡 위험) // 교체 방법: 이 상수만 변경. OVER_GUARD 분기가 자동 반응. const OVER_MOVE_POLICY = 'clamp' -const OVER_EPS = 0.5 +// [2026-05-18 라벨 5mm 단축 fix 1차] 0.5 → 0.1. 인접 edge 잔여 1mm → SHOULDER_ABSORBED(<10) 자연 흡수, 라벨 1mm 차는 round-trip 인정 범위. +const OVER_EPS = 0.1 //동선이동 형 올림 내림 export function useMovementSetting(id) { diff --git a/src/hooks/surface/useSurfaceShapeBatch.js b/src/hooks/surface/useSurfaceShapeBatch.js index 602dd209..d6cb2df7 100644 --- a/src/hooks/surface/useSurfaceShapeBatch.js +++ b/src/hooks/surface/useSurfaceShapeBatch.js @@ -147,7 +147,9 @@ export function useSurfaceShapeBatch({ isHidden, setIsHidden }) { } } - const displayValue = (+roofSizeSet === 1 ? actualSize : planeSize).toString() + // [2026-05-18 라벨 저장값 통일] planeSize/actualSize 저장값 그대로. + const __raw = +roofSizeSet === 1 ? actualSize : planeSize + const displayValue = Number(__raw).toFixed(1).replace(/\.0$/, '') let tx = (start.x + end.x) / 2 let ty = (start.y + end.y) / 2 if (lineDir === 'right') ty += 15 diff --git a/src/hooks/usePolygon.js b/src/hooks/usePolygon.js index 595d63cd..91596aab 100644 --- a/src/hooks/usePolygon.js +++ b/src/hooks/usePolygon.js @@ -3,7 +3,7 @@ import { useRecoilValue } from 'recoil' import { fabric } from 'fabric' import { calculateIntersection, findAndRemoveClosestPoint, getDegreeByChon, isPointOnLine } from '@/util/canvas-util' import { QPolygon } from '@/components/fabric/QPolygon' -import { calcLinePlaneSize, isSamePoint, removeDuplicatePolygons } from '@/util/qpolygon-utils' +import { calcLinePlaneSize, equalizeSymmetricHips, isSamePoint, removeDuplicatePolygons } from '@/util/qpolygon-utils' import { basicSettingState, flowDisplaySelector } from '@/store/settingAtom' import { fontSelector } from '@/store/fontAtom' import { QLine } from '@/components/fabric/QLine' @@ -89,8 +89,10 @@ export const usePolygon = () => { const maxY = line.top + line.length const degree = (Math.atan2(y2 - y1, x2 - x1) * 180) / Math.PI + // [2026-05-18 라벨 저장값 통일] planeSize(정수 또는 0.5 step) 그대로. + const __display = planeSize ?? length const text = new fabric.Textbox( - +roofSizeSet === 1 ? (actualSize ? actualSize.toString() : length.toString()) : planeSize ? planeSize.toString() : length.toString(), + Number(__display).toFixed(1).replace(/\.0$/, ''), { left: left, top: top, @@ -2139,6 +2141,26 @@ export const usePolygon = () => { setActualSize(line, polygon.direction, +polygon.roofMaterial?.pitch, forceUpdate) }) + // [hip 대칭 통일 2026-05-18] 할당 단계도 SK 빌드와 동일하게 hip 좌우대칭 라인을 + // 평균 0.5 step 으로 통일. 좌표 float drift 로 hip 4개가 3832.5/3831.5/3833.5 + // 처럼 갈리던 문제 차단. + // canvas 전체 ROOF 폴리곤의 hip 라인을 모아 cross-polygon 그룹화 (한 hip 이 두 face 에 + // 공유되는 경우도 같은 그룹). + const allHips = [] + const seenIds = new Set() + canvas?.getObjects()?.forEach((obj) => { + if (obj?.name !== POLYGON_TYPE.ROOF || !obj.lines) return + obj.lines.forEach((l) => { + if (l?.attributes?.type === LINE_TYPE.SUBLINE.HIP && !seenIds.has(l.id)) { + seenIds.add(l.id) + allHips.push(l) + } + }) + }) + if (allHips.length >= 2) { + equalizeSymmetricHips(allHips, canvas) + } + addLengthText(polygon) } diff --git a/src/hooks/useText.js b/src/hooks/useText.js index 01b8d51a..63cdd04c 100644 --- a/src/hooks/useText.js +++ b/src/hooks/useText.js @@ -96,12 +96,15 @@ export function useText() { 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: v.toString() }) + obj.set({ text: Number(v).toFixed(1).replace(/\.0$/, '') }) } }) break @@ -111,7 +114,7 @@ export function useText() { recomputeHipActualIfFlat(obj) const v = resolveSize(obj, 'actualSize') if (v) { - obj.set({ text: v.toString() }) + obj.set({ text: Number(v).toFixed(1).replace(/\.0$/, '') }) } }) break @@ -207,7 +210,9 @@ export function useText() { 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() + // [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 }) diff --git a/src/util/qpolygon-utils.js b/src/util/qpolygon-utils.js index 5fc1a2e7..1471186d 100644 --- a/src/util/qpolygon-utils.js +++ b/src/util/qpolygon-utils.js @@ -6276,14 +6276,74 @@ export const calcLineActualSize = (points, degree = 0) => { export const calcLineActualSize2 = (points, degree = 0) => { const planeSize = calcLinePlaneSize(points) + // [공식 통일 2026-05-18] setActualSize(useLine.js) 와 동일하게 axis 평균 사용. + // 기존: hx = xLength * tan(deg) → X 축만 → south/north hip 과 west/east hip 에서 + // symmetric 케이스에도 다른 actualSize 가 산출되어 SK 빌드와 할당 단계 라벨 불일치. + // 변경: axisLength = (xLength + yLength) / 2 → 양 face 대칭값 산출. const xLength = Math.abs(points.x2 - points.x1) * 10 const yLength = Math.abs(points.y2 - points.y1) * 10 - const hx = xLength * Math.tan(degree * (Math.PI / 180)) + const axisLength = (xLength + yLength) / 2 + const h = axisLength * Math.tan(degree * (Math.PI / 180)) + const actualSize = Math.sqrt(h ** 2 + planeSize ** 2) + // setActualSize 와 동일하게 0.5 step 으로 정합. + return Number((Math.round(actualSize * 2) / 2).toFixed(1)) +} - const hy = yLength * Math.tan(degree * (Math.PI / 180)) - const actualSize = Number(Math.sqrt(hx ** 2 + planeSize ** 2)).toFixed(0) - const actualSize2 = Number(Math.sqrt(hy ** 2 + planeSize ** 2)).toFixed(0) - return actualSize +/** + * 대칭 hip 라인들의 planeSize/actualSize 를 ±5mm 그룹 평균(0.5 step)으로 통일. + * SK 빌드(QPolygon.drawHelpLine innerLines) 와 지붕재 할당(setPolygonLinesActualSize 결과) 양쪽에서 재사용. + * + * @param {Array} hipLines - hip 라인 객체 배열 (x1,y1,x2,y2 + attributes 필수) + * @param {object} canvas - fabric canvas (lengthText 동기화용; null 이면 attribute 만 갱신) + */ +export const equalizeSymmetricHips = (hipLines, canvas) => { + if (!hipLines || hipLines.length < 2) return + // [그룹 키 안정화 2026-05-18] + // 기존 rawLen(Math.hypot * 10) 기준은 같은 hip 의 face별 좌표 미세 drift + // (예: dx=290,dy=289 vs 290,290 → 7mm 차) 로 ±5mm 그룹을 벗어남. + // planeSize 는 SK 빌드시 이미 ±5mm 그룹으로 통일된 안정 정수 키. + // planeSize 없을 때만 rawLen 폴백. + const _segLen = (l) => + l?.attributes?.planeSize != null + ? Math.round(l.attributes.planeSize) + : Math.round(Math.sqrt((l.x2 - l.x1) ** 2 + (l.y2 - l.y1) ** 2) * 10) + const _apply = (line, plane, actual) => { + if (line.attributes) { + line.attributes.planeSize = plane + line.attributes.actualSize = actual + } + if (!canvas) return + const text = canvas.getObjects().find((o) => o.name === 'lengthText' && o.parentId === line.id) + if (text) { + text.set({ planeSize: plane, actualSize: actual }) + const isPlane = !text.actualSize || text.text === String(text.planeSize) + // planeSize/actualSize 모두 저장값(정수 또는 0.5 step) 그대로 표시. + const __raw = isPlane ? plane : actual + text.set({ text: Number(__raw).toFixed(1).replace(/\.0$/, '') }) + } + } + const used = new Set() + hipLines.forEach((h, i) => { + if (used.has(i)) return + const group = [{ idx: i, line: h }] + const baseLen = _segLen(h) + hipLines.forEach((h2, j) => { + if (j <= i || used.has(j)) return + if (Math.abs(_segLen(h2) - baseLen) <= 5) group.push({ idx: j, line: h2 }) + }) + if (group.length >= 2) { + const avgPlane = group.reduce((s, g) => s + (g.line.attributes?.planeSize || 0), 0) / group.length + const avgActual = group.reduce((s, g) => s + (g.line.attributes?.actualSize || 0), 0) / group.length + const plane = Math.round(avgPlane * 2) / 2 + const actual = Math.round(avgActual * 2) / 2 + group.forEach(({ idx, line }) => { + used.add(idx) + _apply(line, plane, actual) + }) + } else { + used.add(i) + } + }) } /** diff --git a/src/util/skeleton-utils.js b/src/util/skeleton-utils.js index 2329cc92..fd63b2af 100644 --- a/src/util/skeleton-utils.js +++ b/src/util/skeleton-utils.js @@ -2918,12 +2918,28 @@ function processEavesEdge(roofId, canvas, skeleton, edgeResult, skeletonLines) { // logger.log('Has matching line:', outerLine); //if(outerLine === null) return } - // [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 + // [hip pitch fallback 2026-04-30 / 강화 2026-05-18] + // 1) outerLine.attributes.pitch — 매칭된 eaves 라인의 pitch + // 2) roof.pitch — setRoofPitch 에서 stamp (allocation 후) + // 3) roof.lines 중 pitch 가 있는 eaves 라인 — 초기 SK 빌드 시 매칭 실패 시 + // 4) canvas 의 wall outerLine 중 pitch 가 있는 라인 — 최후 폴백 + // 5) 0 + // 기존엔 1~2 폴백만 있어 SK 빌드시 pitch=0 → hip actualSize=planeSize 로 잘못 계산되고, + // 이후 display recompute / 할당 단계에서 다른 pitch 로 재계산되어 라벨이 변하는 문제. + let pitch = outerLine?.attributes?.pitch ?? roof?.pitch + if (pitch === undefined || pitch === null) { + const fallbackFromRoofLines = roof?.lines?.find((l) => l?.attributes?.pitch != null)?.attributes?.pitch + pitch = fallbackFromRoofLines + } + if (pitch === undefined || pitch === null) { + const fallbackOuter = canvas + ?.getObjects() + ?.find((o) => o?.name === 'outerLine' && o?.attributes?.pitch != null) + pitch = fallbackOuter?.attributes?.pitch + } + if (pitch === undefined || pitch === null) { + pitch = 0 + } const convertedPolygon = edgeResult.Polygon?.map(point => ({