From 3f5f490884d4652ef3bb6260f7932dbdc94877f1 Mon Sep 17 00:00:00 2001 From: "hyojun.choi" Date: Wed, 29 Apr 2026 14:47:17 +0900 Subject: [PATCH] =?UTF-8?q?1961,=201969,=202118=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/components/fabric/QPolygon.js | 49 +++++++++++- src/hooks/useLine.js | 3 +- src/hooks/useMode.js | 21 +++-- src/hooks/usePolygon.js | 124 +++++++++++++++++++++++++----- src/util/qpolygon-utils.js | 24 +++++- 5 files changed, 191 insertions(+), 30 deletions(-) diff --git a/src/components/fabric/QPolygon.js b/src/components/fabric/QPolygon.js index 276a7287..a947d511 100644 --- a/src/components/fabric/QPolygon.js +++ b/src/components/fabric/QPolygon.js @@ -18,7 +18,8 @@ import { drawSkeletonRidgeRoof, drawSkeletonRidgeRoofFromBaseLines, verifyMoveBo const DEBUG_LABEL_NAME = '__debugLabel' function __isDebugLabelsEnabled() { - return process.env.NEXT_PUBLIC_RUN_MODE === 'local' + // 디버그 라벨(H-1, B-4, RG-1 등) 표시 비활성화 + return false } function __classifyLineForLabel(obj) { @@ -596,6 +597,52 @@ 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 차이가 발생함 + 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) + } + }) + this.canvas?.renderAll() + } + // [DEBUG] 로컬(NEXT_PUBLIC_RUN_MODE==='local') 에서만 라벨 부착. 그 외 즉시 return. __attachDebugLabels(this.canvas, this.id) }, diff --git a/src/hooks/useLine.js b/src/hooks/useLine.js index df876fb1..351760d6 100644 --- a/src/hooks/useLine.js +++ b/src/hooks/useLine.js @@ -215,7 +215,8 @@ export const useLine = () => { line.attributes = { ...line.attributes, - actualSize: Number(line.attributes.actualSize.toFixed(0)), + // 0.5 단위 정밀도 유지 (대칭 분할 시 1637.5 같은 값 보존). 그 외에는 정수. + actualSize: Number((Math.round(line.attributes.actualSize * 2) / 2).toFixed(1)), isCalculated: true, } } diff --git a/src/hooks/useMode.js b/src/hooks/useMode.js index 46219a65..c1b5524c 100644 --- a/src/hooks/useMode.js +++ b/src/hooks/useMode.js @@ -1777,7 +1777,8 @@ export function useMode() { const offsetEdges = [] polygon.edges.forEach((edge, i) => { - const offset = lines[i % lines.length].attributes.offset === 0 ? 1 : lines[i % lines.length].attributes.offset + // offset 이 0(처마/케라바 출폭 0) 인 경우 그대로 0 유지 → wall == roof + const offset = lines[i % lines.length].attributes.offset const dx = edge.outwardNormal.x * offset const dy = edge.outwardNormal.y * offset offsetEdges.push(createOffsetEdge(edge, dx, dy)) @@ -1830,7 +1831,8 @@ export function useMode() { const offsetEdges = [] polygon.edges.forEach((edge, i) => { - const offset = lines[i % lines.length].attributes.offset === 0 ? 1 : lines[i % lines.length].attributes.offset + // offset 이 0(처마/케라바 출폭 0) 인 경우 그대로 0 유지 → wall == roof + const offset = lines[i % lines.length].attributes.offset const dx = edge.inwardNormal.x * offset const dy = edge.inwardNormal.y * offset offsetEdges.push(createOffsetEdge(edge, dx, dy)) @@ -1977,10 +1979,20 @@ export function useMode() { const y1 = Big(line.y1) const y2 = Big(line.y2) const lineLength = x1.minus(x2).abs().pow(2).plus(y1.minus(y2).abs().pow(2)).sqrt().times(10).round().toNumber() + const isDivLine = divWallLines.some((divLine) => divLine.index === index) + + // 옵션 3: 외벽선의 입력 치수(attributes.planeSize/actualSize)를 오프셋 폴리곤 라인에도 propagate + // → polygon-offset 알고리즘의 부동소수점 누적 오차로 인한 5~10mm drift 제거 + // divLine(외벽 분기로 인해 새로 삽입된 코너 라인)은 wall.lines 와 1:1 매칭이 아니므로 + // geometric lineLength 그대로 사용 + const sourceWallLine = !isDivLine ? wall.lines[roofWallIndex] : null + const propagatedPlaneSize = sourceWallLine?.attributes?.planeSize ?? lineLength + const propagatedActualSize = sourceWallLine?.attributes?.actualSize ?? lineLength + line.attributes = { roofId: roof.id, - planeSize: lineLength, - actualSize: lineLength, + planeSize: propagatedPlaneSize, + actualSize: propagatedActualSize, wallLine: wall.lines[roofWallIndex].id, type: wall.lines[roofWallIndex].attributes.type, offset: wall.lines[roofWallIndex].attributes.offset, @@ -1989,7 +2001,6 @@ export function useMode() { sleeve: wall.lines[roofWallIndex].attributes.sleeve || false, } - const isDivLine = divWallLines.some((divLine) => divLine.index === index) if (!isDivLine) { roofWallIndex++ } diff --git a/src/hooks/usePolygon.js b/src/hooks/usePolygon.js index 9b17419e..8b2f7c37 100644 --- a/src/hooks/usePolygon.js +++ b/src/hooks/usePolygon.js @@ -1053,26 +1053,53 @@ export const usePolygon = () => { const length1 = Math.round(Math.hypot(newLine1.x1 - newLine1.x2, newLine1.y1 - newLine1.y2)) * 10 const length2 = Math.round(Math.hypot(newLine2.x1 - newLine2.x2, newLine2.y1 - newLine2.y2)) * 10 - // 원본에 planeSize/actualSize가 있으면 비율로 분배, 없으면 기하학적 길이 사용 - let planeSize1, planeSize2, actualSize1, actualSize2 - if (line.attributes.planeSize && originalGeomLength > 0) { - const ratio1 = length1 / originalGeomLength - const ratio2 = length2 / originalGeomLength - planeSize1 = Math.round(line.attributes.planeSize * ratio1) - planeSize2 = Math.round(line.attributes.planeSize * ratio2) - } else { - planeSize1 = length1 - planeSize2 = length2 + // 분할 시 새 sub-segment 의 planeSize/actualSize 는 새 기하학으로 직접 계산. + // 부모 비율을 쓰면 부모 좌표 drift 가 그대로 전파되어 사용자 기대 round 값과 어긋남. + // sum 이 부모 입력 planeSize 와 ±20mm 차이일 때 잔차 보정: + // - sum < parent (drift +): geomLen 이 underestimated → 잔차를 SHORTER 에 더해 + // LONGER 의 round 값(예: 910)을 보존 + // - sum > parent (drift -): geomLen 이 overestimated → 잔차를 LONGER 에서 빼서 + // SHORTER 의 round 값(예: 460)을 보존 + let planeSize1 = length1 + let planeSize2 = length2 + let actualSize1 = length1 + let actualSize2 = length2 + + const _redistribute = (parentVal, p1, p2) => { + if (!parentVal) return [p1, p2] + const sum = p1 + p2 + const drift = parentVal - sum + if (Math.abs(drift) > 20) return [p1, p2] // 큰 차이는 보정하지 않음 + if (drift === 0) return [p1, p2] + // drift > 0: SHORTER 에 잔차 추가 → LONGER 보존 + // drift < 0: LONGER 에 잔차 제거 → SHORTER 보존 + if (drift > 0) { + if (length1 <= length2) return [parentVal - p2, p2] + return [p1, parentVal - p1] + } else { + if (length1 >= length2) return [parentVal - p2, p2] + return [p1, parentVal - p1] + } } - if (line.attributes.actualSize && originalGeomLength > 0) { - const ratio1 = length1 / originalGeomLength - const ratio2 = length2 / originalGeomLength - actualSize1 = Math.round(line.attributes.actualSize * ratio1) - actualSize2 = Math.round(line.attributes.actualSize * ratio2) - } else { - actualSize1 = length1 - actualSize2 = length2 + + const parentPlane = line.attributes?.planeSize + const parentActual = line.attributes?.actualSize + ;[planeSize1, planeSize2] = _redistribute(parentPlane, planeSize1, planeSize2) + ;[actualSize1, actualSize2] = _redistribute(parentActual, actualSize1, actualSize2) + + // 2분할에서 양 끝이 거의 같을 때(차이 ≤ 5mm) 대칭성 강제: 두 값을 평균으로 통일 + // 예: ridge 가 wall 을 거의 정확히 양분 → 1637 / 1638 → 둘 다 1637.5 + // 합이 홀수이면 0.5 소수점을 유지하여 양쪽이 정확히 같은 값으로 표시되도록 함 + const _equalizeIfNearSymmetric = (p1, p2) => { + if (!p1 || !p2) return [p1, p2] + if (Math.abs(p1 - p2) > 5) return [p1, p2] + const avg = (p1 + p2) / 2 // 0.5 소수점 허용 (예: 3275 / 2 = 1637.5) + // 0.5 단위로 정밀도 유지 (Big number/이상한 부동소수점 방지) + const halfPrecision = Math.round(avg * 2) / 2 + return [halfPrecision, halfPrecision] } + ;[planeSize1, planeSize2] = _equalizeIfNearSymmetric(planeSize1, planeSize2) + ;[actualSize1, actualSize2] = _equalizeIfNearSymmetric(actualSize1, actualSize2) newLine1.attributes = { ...line.attributes, @@ -1159,6 +1186,23 @@ export const usePolygon = () => { newLine.length = lastCalcLength newLines.push(newLine) + + // 홀수 분할(3, 5, ...) 시 양 끝(첫·마지막) sub-segment 의 길이를 평균값으로 동일하게 보정 + // 패턴 A/B 등 대칭 입력 wall 이 hip lines 로 분할될 때 좌우 대칭성을 강제로 유지 + const newSubsForThisLine = newLines.slice(-(intersections.length + 1)) // 이 부모 line 이 만든 sub-segment 들 + const segCount = newSubsForThisLine.length + if (segCount >= 3 && segCount % 2 === 1) { + const first = newSubsForThisLine[0] + const last = newSubsForThisLine[segCount - 1] + const avgPlane = Math.round((first.attributes.planeSize + last.attributes.planeSize) / 2) + const avgActual = Math.round((first.attributes.actualSize + last.attributes.actualSize) / 2) + // 두 끝값의 차이가 작을 때만 평균화 (대칭성 가정 케이스) + if (Math.abs(first.attributes.planeSize - last.attributes.planeSize) <= 20) { + first.attributes = { ...first.attributes, planeSize: avgPlane, actualSize: avgActual } + last.attributes = { ...last.attributes, planeSize: avgPlane, actualSize: avgActual } + } + } + divideLines.splice(i, 1) // 기존 line 제거 } } @@ -1638,18 +1682,60 @@ export const usePolygon = () => { return startFlag && endFlag }) + // 1차 매칭: 양 끝점이 정확히 일치하는 부모 line 의 attributes 복사 + const matchedRoofLines = new Set() roofLines.forEach((line) => { - //console.log("::::::::::",line); roof.lines.forEach((roofLine) => { if ( (isSamePoint(line.startPoint, roofLine.startPoint) && isSamePoint(line.endPoint, roofLine.endPoint)) || (isSamePoint(line.startPoint, roofLine.endPoint) && isSamePoint(line.endPoint, roofLine.startPoint)) ) { roofLine.attributes = { ...line.attributes } + matchedRoofLines.add(roofLine) } }) }) + // 2차 매칭 (옵션 3 + α): 대각선이 외벽 중간을 분할한 sub-segment 처리 + const _isCollinearWithin = (parent, p) => { + const ax = parent.startPoint.x, ay = parent.startPoint.y + const bx = parent.endPoint.x, by = parent.endPoint.y + const dx = bx - ax, dy = by - ay + const segLenSq = dx * dx + dy * dy + if (segLenSq < 1) return false + const cross = (p.x - ax) * dy - (p.y - ay) * dx + if (Math.abs(cross) / Math.sqrt(segLenSq) > 2) return false + const t = ((p.x - ax) * dx + (p.y - ay) * dy) / segLenSq + return t >= -0.001 && t <= 1.001 + } + const _segLen = (s, e) => Math.sqrt((s.x - e.x) ** 2 + (s.y - e.y) ** 2) + + const parentCandidates = [...polygonLines, ...polygon.innerLines] + roof.lines.forEach((roofLine) => { + if (matchedRoofLines.has(roofLine)) return + const parent = parentCandidates.find( + (p) => p?.startPoint && p?.endPoint && _isCollinearWithin(p, roofLine.startPoint) && _isCollinearWithin(p, roofLine.endPoint), + ) + if (!parent) return + const parentLen = _segLen(parent.startPoint, parent.endPoint) + if (parentLen < 1) return + const subLen = _segLen(roofLine.startPoint, roofLine.endPoint) + const ratio = subLen / parentLen + + const parentAttrs = parent.attributes || {} + const parentPlane = Number(parentAttrs.planeSize) + const parentActual = Number(parentAttrs.actualSize) + const propagatedPlane = Number.isFinite(parentPlane) ? Math.round(parentPlane * ratio) : null + const propagatedActual = Number.isFinite(parentActual) ? Math.round(parentActual * ratio) : null + + roofLine.attributes = { + ...parentAttrs, + ...(propagatedPlane != null ? { planeSize: propagatedPlane } : {}), + ...(propagatedActual != null ? { actualSize: propagatedActual } : {}), + } + matchedRoofLines.add(roofLine) + }) + // canvas.add(roof) createdRoofs.push(roof) canvas.remove(polygon) diff --git a/src/util/qpolygon-utils.js b/src/util/qpolygon-utils.js index c01feb2e..dccd3414 100644 --- a/src/util/qpolygon-utils.js +++ b/src/util/qpolygon-utils.js @@ -726,8 +726,11 @@ export const drawGableRoof = (roofId, canvas, textMode) => { let currentRoof if (line.attributes.offset === 0) { checkRoofLines.forEach((roofLine) => { - const isVerticalSame = line.y1 === roofLine.y1 && line.y2 === roofLine.y2 - const isHorizontalSame = line.x1 === roofLine.x1 && line.x2 === roofLine.x2 + // 정방향/역방향 양쪽 모두 매칭 허용 (좌표 정렬에 따라 끝점이 swap 될 수 있음) + const isVerticalSame = + (line.y1 === roofLine.y1 && line.y2 === roofLine.y2) || (line.y1 === roofLine.y2 && line.y2 === roofLine.y1) + const isHorizontalSame = + (line.x1 === roofLine.x1 && line.x2 === roofLine.x2) || (line.x1 === roofLine.x2 && line.x2 === roofLine.x1) if ( (isVerticalSame || isHorizontalSame) && (isPointOnLineNew(roofLine, { x: line.x1, y: line.y1 }) || isPointOnLineNew(roofLine, { x: line.x2, y: line.y2 })) @@ -735,6 +738,11 @@ export const drawGableRoof = (roofId, canvas, textMode) => { currentRoof = { roofLine } } }) + // offset=0(처마/케라바 출폭 0) 케이스에서 매칭 실패하면 wall.line 자체를 roofLine 으로 사용 + // (wall == roof 이므로 자기 자신이 맞음). currentRoof 가 undefined 일 때 NPE 방지. + if (!currentRoof) { + currentRoof = { roofLine: line } + } } else { const findEdge = { vertex1: { x: midX, y: midY }, vertex2: { x: midX + roofVector.x * offset, y: midY + roofVector.y * offset } } const edgeDx = @@ -2041,8 +2049,11 @@ export const drawRoofByAttribute = (roofId, canvas, textMode) => { let currentRoof if (line.attributes.offset === 0) { checkRoofLines.forEach((roofLine) => { - const isVerticalSame = line.y1 === roofLine.y1 && line.y2 === roofLine.y2 - const isHorizontalSame = line.x1 === roofLine.x1 && line.x2 === roofLine.x2 + // 정방향/역방향 양쪽 모두 매칭 허용 (좌표 정렬에 따라 끝점이 swap 될 수 있음) + const isVerticalSame = + (line.y1 === roofLine.y1 && line.y2 === roofLine.y2) || (line.y1 === roofLine.y2 && line.y2 === roofLine.y1) + const isHorizontalSame = + (line.x1 === roofLine.x1 && line.x2 === roofLine.x2) || (line.x1 === roofLine.x2 && line.x2 === roofLine.x1) if ( (isVerticalSame || isHorizontalSame) && (isPointOnLineNew(roofLine, { x: line.x1, y: line.y1 }) || isPointOnLineNew(roofLine, { x: line.x2, y: line.y2 })) @@ -2050,6 +2061,11 @@ export const drawRoofByAttribute = (roofId, canvas, textMode) => { currentRoof = { roofLine } } }) + // offset=0(처마/케라바 출폭 0) 케이스에서 매칭 실패하면 wall.line 자체를 roofLine 으로 사용 + // (wall == roof 이므로 자기 자신이 맞음). currentRoof 가 undefined 일 때 NPE 방지. + if (!currentRoof) { + currentRoof = { roofLine: line } + } } else { const findEdge = { vertex1: { x: midX, y: midY }, vertex2: { x: midX + roofVector.x * offset, y: midY + roofVector.y * offset } } const edgeDx = -- 2.47.2