From dfed51a7586ab300b23c403b86f526b205fa1d3a Mon Sep 17 00:00:00 2001 From: "hyojun.choi" Date: Tue, 30 Sep 2025 09:55:01 +0900 Subject: [PATCH 01/16] =?UTF-8?q?=EA=B0=81=EB=8F=84=20=EA=B3=84=EC=82=B0?= =?UTF-8?q?=EC=8B=9D=20=EC=86=8C=EC=88=98=EC=A0=90=20=EB=91=98=EC=A7=B8?= =?UTF-8?q?=EC=9E=90=EB=A6=AC=EA=B9=8C=EC=A7=80=20=ED=91=9C=EC=8B=9C?= =?UTF-8?q?=ED=95=98=EB=8F=84=EB=A1=9D=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/util/canvas-util.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/util/canvas-util.js b/src/util/canvas-util.js index 0af492a1..243936da 100644 --- a/src/util/canvas-util.js +++ b/src/util/canvas-util.js @@ -260,7 +260,7 @@ export const getDegreeByChon = (chon) => { // tan(theta) = height / base const radians = Math.atan(chon / 10) // 라디안을 도 단위로 변환 - return Number((radians * (180 / Math.PI)).toFixed(1)) + return Number((radians * (180 / Math.PI)).toFixed(2)) } /** From 384c68c1ef31e65fc47e472d28b96b70626189f5 Mon Sep 17 00:00:00 2001 From: ysCha Date: Tue, 30 Sep 2025 13:29:09 +0900 Subject: [PATCH 02/16] =?UTF-8?q?[big.js]=20Division=20by=20zero=20-=20act?= =?UTF-8?q?ualSize=20=EA=B0=80=200=EC=9D=B4=20=EB=90=98=EC=96=B4=20?= =?UTF-8?q?=EB=82=98=EB=88=84=EA=B8=B0=EC=97=90=20=EB=AC=B8=EC=A0=9C?= =?UTF-8?q?=EA=B0=80=20=EB=90=A8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/components/fabric/QPolygon.js | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/components/fabric/QPolygon.js b/src/components/fabric/QPolygon.js index d712b3f1..6021afc2 100644 --- a/src/components/fabric/QPolygon.js +++ b/src/components/fabric/QPolygon.js @@ -6,6 +6,7 @@ import { calculateAngle, drawGableRoof, drawRidgeRoof, drawShedRoof, toGeoJSON } import * as turf from '@turf/turf' import { LINE_TYPE, POLYGON_TYPE } from '@/common/common' import Big from 'big.js' +import { drawSkeletonRidgeRoof } from '@/util/skeleton-utils' export const QPolygon = fabric.util.createClass(fabric.Polygon, { type: 'QPolygon', @@ -335,6 +336,7 @@ export const QPolygon = fabric.util.createClass(fabric.Polygon, { // 용마루 -- straight-skeleton console.log('용마루 지붕') drawRidgeRoof(this.id, this.canvas, textMode) + //drawSkeletonRidgeRoof(this.id, this.canvas, textMode); } else if (isGableRoof(types)) { // A형, B형 박공 지붕 console.log('패턴 지붕') From 75312f5ccff14f2be36a4ca214716a4b23c7a49f Mon Sep 17 00:00:00 2001 From: ysCha Date: Tue, 30 Sep 2025 13:41:34 +0900 Subject: [PATCH 03/16] =?UTF-8?q?=EC=8A=A4=EC=BC=88=EB=A0=88=ED=86=A4.v0.1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/util/skeleton-utils.js | 2593 ++++++++++-------------------------- 1 file changed, 682 insertions(+), 1911 deletions(-) diff --git a/src/util/skeleton-utils.js b/src/util/skeleton-utils.js index ba5b4e87..54cb5ba2 100644 --- a/src/util/skeleton-utils.js +++ b/src/util/skeleton-utils.js @@ -1,32 +1,38 @@ import { LINE_TYPE, POLYGON_TYPE } from '@/common/common' -import Big from 'big.js' import { SkeletonBuilder } from '@/lib/skeletons' -import { arePointsEqual, calcLineActualSize, calcLinePlaneSize, calculateAngle, toGeoJSON } from '@/util/qpolygon-utils' +import { calcLineActualSize, calcLinePlaneSize, toGeoJSON } from '@/util/qpolygon-utils' import { QLine } from '@/components/fabric/QLine' import { getDegreeByChon } from '@/util/canvas-util' - +import Big from 'big.js' /** * 지붕 폴리곤의 스켈레톤(중심선)을 생성하고 캔버스에 그립니다. * @param {string} roofId - 대상 지붕 객체의 ID * @param {fabric.Canvas} canvas - Fabric.js 캔버스 객체 * @param {string} textMode - 텍스트 표시 모드 - * @param existingSkeletonLines + * @param pitch */ -export const drawSkeletonRidgeRoof = (roofId, canvas, textMode, existingSkeletonLines = []) => { - const roof = canvas?.getObjects().find((object) => object.id === roofId) - if (!roof) { - console.error(`Roof with id "${roofId}" not found.`); - return; - } - const skeletonLines = [...existingSkeletonLines]; - // 1. 기존 스켈레톤 라인 제거 - // const existingSkeletonLines = canvas.getObjects().filter(obj => - // obj.parentId === roofId && obj.attributes?.type === 'skeleton' - // ); - // existingSkeletonLines.forEach(line => canvas.remove(line)); - // 2. 지붕 폴리곤 좌표 전처리 + +export const drawSkeletonRidgeRoof = (roofId, canvas, textMode) => { + let roof = canvas?.getObjects().find((object) => object.id === roofId) + const wall = canvas.getObjects().find((object) => object.name === POLYGON_TYPE.WALL && object.attributes.roofId === roofId) + + const hasNonParallelLines = roof.lines.filter((line) => Big(line.x1).minus(Big(line.x2)).gt(1) && Big(line.y1).minus(Big(line.y2)).gt(1)) + if (hasNonParallelLines.length > 0) { + return + } + + const eavesType = [LINE_TYPE.WALLLINE.EAVES, LINE_TYPE.WALLLINE.HIPANDGABLE] + const gableType = [LINE_TYPE.WALLLINE.GABLE, LINE_TYPE.WALLLINE.JERKINHEAD] + + /** 외벽선 */ + const baseLines = wall.baseLines.filter((line) => line.attributes.planeSize > 0) + + + + //const skeletonLines = []; + // 1. 지붕 폴리곤 좌표 전처리 const coordinates = preprocessPolygonCoordinates(roof.points); if (coordinates.length < 3) { console.warn("Polygon has less than 3 unique points. Cannot generate skeleton."); @@ -34,185 +40,41 @@ export const drawSkeletonRidgeRoof = (roofId, canvas, textMode, existingSkeleton } - const wall = canvas.getObjects().find((object) => object.name === POLYGON_TYPE.WALL && object.attributes.roofId === roofId) - - //평행선 여부 - const hasNonParallelLines = roof.lines.filter((line) => Big(line.x1).minus(Big(line.x2)).gt(1) && Big(line.y1).minus(Big(line.y2)).gt(1)) - if (hasNonParallelLines.length > 0) { - return - } - - - /** 외벽선 */ - const baseLines = wall.baseLines.filter((line) => line.attributes.planeSize > 0) - const baseLinePoints = baseLines.map((line) => ({ x: line.x1, y: line.y1 })) - - skeletonBuilder(roofId, canvas, textMode, roof, skeletonLines) - + // 2. 스켈레톤 생성 및 그리기 + skeletonBuilder(roofId, canvas, textMode, roof, baseLines) } /** - * 스켈레톤의 edge를 각도가 있는 구간으로 변형합니다. - * @param {Object} skeleton - 스켈레톤 객체 - * @param {number} edgeIndex - 변형할 edge의 인덱스 - * @param {number} angleOffset - 추가할 각도 (도 단위) - * @param {number} splitRatio - 분할 비율 (0-1 사이, 0.5면 중간점) - * @returns {Object} 변형된 스켈레톤 객체 + * SkeletonBuilder를 사용하여 스켈레톤을 생성하고 내부선을 그립니다. + * @param {string} roofId - 지붕 ID + * @param {fabric.Canvas} canvas - 캔버스 객체 + * @param {string} textMode - 텍스트 모드 + * @param {fabric.Object} roof - 지붕 객체 + * @param baseLines */ -export const transformEdgeWithAngle = (skeleton, edgeIndex, angleOffset = 45, splitRatio = 0.5) => { - if (!skeleton || !skeleton.Edges || edgeIndex >= skeleton.Edges.length || edgeIndex < 0) { - console.warn('유효하지 않은 스켈레톤 또는 edge 인덱스입니다.') - return skeleton - } - - const edgeResult = skeleton.Edges[edgeIndex] - if (!edgeResult || !edgeResult.Polygon || !Array.isArray(edgeResult.Polygon)) { - console.warn('유효하지 않은 edge 또는 Polygon 데이터입니다.') - return skeleton - } - - const polygonPoints = edgeResult.Polygon.map((p) => ({ x: p.X, y: p.Y })) - - // 변형할 edge 찾기 (가장 긴 내부 선분을 대상으로 함) - let longestEdge = null - let longestLength = 0 - let longestEdgeIndex = -1 - - for (let i = 0; i < polygonPoints.length; i++) { - const p1 = polygonPoints[i] - const p2 = polygonPoints[(i + 1) % polygonPoints.length] - - const length = Math.sqrt(Math.pow(p2.x - p1.x, 2) + Math.pow(p2.y - p1.y, 2)) - - if (length > longestLength) { - longestLength = length - longestEdge = { p1, p2, index: i } - longestEdgeIndex = i - } - } - - if (!longestEdge) return skeleton - - // 중간점 계산 - const midPoint = { - x: longestEdge.p1.x + (longestEdge.p2.x - longestEdge.p1.x) * splitRatio, - y: longestEdge.p1.y + (longestEdge.p2.y - longestEdge.p1.y) * splitRatio, - } - - // 원래 선분의 방향 벡터 - const originalVector = { - x: longestEdge.p2.x - longestEdge.p1.x, - y: longestEdge.p2.y - longestEdge.p1.y, - } - - // 각도 변형을 위한 새로운 점 계산 - const angleRad = (angleOffset * Math.PI) / 180 - const perpVector = { - x: -originalVector.y, - y: originalVector.x, - } - - // 정규화 - const perpLength = Math.sqrt(perpVector.x * perpVector.x + perpVector.y * perpVector.y) - const normalizedPerp = { - x: perpVector.x / perpLength, - y: perpVector.y / perpLength, - } - - // 각도 변형을 위한 오프셋 거리 (선분 길이의 10%) - const offsetDistance = longestLength * 0.1 - - // 새로운 각도 점 - const anglePoint = { - x: midPoint.x + normalizedPerp.x * offsetDistance * Math.sin(angleRad), - y: midPoint.y + normalizedPerp.y * offsetDistance * Math.sin(angleRad), - } - - // 새로운 폴리곤 점들 생성 - const newPolygonPoints = [...polygonPoints] - - // 기존 점을 제거하고 새로운 세 점으로 교체 - newPolygonPoints.splice(longestEdgeIndex + 1, 0, anglePoint) - - // 스켈레톤 객체 업데이트 - 순환 참조 문제를 방지하기 위해 안전한 복사 방식 사용 - const newSkeleton = { - ...skeleton, - Edges: skeleton.Edges.map((edge, idx) => { - if (idx === edgeIndex) { - return { - ...edge, - Polygon: newPolygonPoints.map((p) => ({ X: p.x, Y: p.y })), - } - } - return edge - }), - } - - return newSkeleton -} - -/** - * 여러 edge를 한 번에 변형합니다. - * @param {Object} skeleton - 스켈레톤 객체 - * @param {Array} edgeConfigs - 변형 설정 배열 [{edgeIndex, angleOffset, splitRatio}] - * @returns {Object} 변형된 스켈레톤 객체 - */ -export const transformMultipleEdges = (skeleton, edgeConfigs) => { - let transformedSkeleton = skeleton - - // 인덱스 역순으로 정렬하여 변형 시 인덱스 변화를 방지 - edgeConfigs.sort((a, b) => b.edgeIndex - a.edgeIndex) - - edgeConfigs.forEach((config) => { - transformedSkeleton = transformEdgeWithAngle(transformedSkeleton, config.edgeIndex, config.angleOffset || 45, config.splitRatio || 0.5) - }) - - return transformedSkeleton -} - -/** - * 마루가 있는 지붕을 그린다. - * @param roofId - * @param canvas - * @param textMode - * @param roof - * @param edgeProperties - */ -export const skeletonBuilder = (roofId, canvas, textMode, roof, skeletonLines) => { - // 1. 다각형 좌표를 GeoJSON 형식으로 변환합니다. +export const skeletonBuilder = (roofId, canvas, textMode, roof, baseLines) => { const geoJSONPolygon = toGeoJSON(roof.points) try { - // 2. SkeletonBuilder를 사용하여 스켈레톤을 생성합니다. + // SkeletonBuilder는 닫히지 않은 폴리곤을 기대하므로 마지막 점 제거 geoJSONPolygon.pop() - let skeleton = SkeletonBuilder.BuildFromGeoJSON([[geoJSONPolygon]]) + const skeleton = SkeletonBuilder.BuildFromGeoJSON([[geoJSONPolygon]]) - console.log(`지붕 형태: ${skeleton.roof_type}`) // "complex" - console.log('Edge 분석:', skeleton.edge_analysis) + console.log(`지붕 형태: ${skeleton.roof_type}`, skeleton.edge_analysis) - // 3. 라인을 그림 - const innerLines = createInnerLinesFromSkeleton(skeleton, roof.lines, roof, canvas, textMode, skeletonLines) + // 스켈레톤 데이터를 기반으로 내부선 생성 + roof.innerLines = createInnerLinesFromSkeleton(skeleton, canvas, textMode, roof, baseLines) - console.log("innerLines::", innerLines) - // 4. 생성된 선들을 캔버스에 추가하고 지붕 객체에 저장 - // innerLines.forEach((line) => { - // canvas.add(line) - // line.bringToFront() - // canvas.renderAll() - // }) - - roof.innerLines = innerLines - - // canvas에 skeleton 상태 저장 + // 캔버스에 스켈레톤 상태 저장 if (!canvas.skeletonStates) { canvas.skeletonStates = {} + canvas.skeletonLines = [] } canvas.skeletonStates[roofId] = true canvas.renderAll() } catch (e) { - console.error('지붕 생성 중 오류 발생:', e) - // 오류 발생 시 기존 로직으로 대체하거나 사용자에게 알림 + console.error('스켈레톤 생성 중 오류 발생:', e) if (canvas.skeletonStates) { canvas.skeletonStates[roofId] = false } @@ -220,1831 +82,740 @@ export const skeletonBuilder = (roofId, canvas, textMode, roof, skeletonLines) = } /** - * 스켈레톤 결과와 원본 외벽선 정보를 바탕으로 내부선(마루, 추녀)들을 생성합니다. + * 스켈레톤 결과와 외벽선 정보를 바탕으로 내부선(용마루, 추녀)을 생성합니다. * @param {object} skeleton - SkeletonBuilder로부터 반환된 스켈레톤 객체 - * @param {Array} baseLines - 원본 외벽선 QLine 객체 배열 - * @param {QPolygon} roof - 대상 지붕 QPolygon 객체 + + * @param {fabric.Object} roof - 대상 지붕 객체 * @param {fabric.Canvas} canvas - Fabric.js 캔버스 객체 * @param {string} textMode - 텍스트 표시 모드 ('plane', 'actual', 'none') - * @returns {Array} 생성된 내부선(QLine) 배열 + * @param {Array} baseLines - 원본 외벽선 QLine 객체 배열 */ -// 두 선분이 같은 직선상에 있고 겹치는지 확인하는 함수 -const areLinesCollinearAndOverlapping = (line1, line2) => { - // 두 선분이 같은 직선상에 있는지 확인 - const areCollinear = (p1, p2, p3, p4) => { - const area1 = (p2.x - p1.x) * (p3.y - p1.y) - (p2.y - p1.y) * (p3.x - p1.x) - const area2 = (p2.x - p1.x) * (p4.y - p1.y) - (p2.y - p1.y) * (p4.x - p1.x) - return Math.abs(area1) < 1 && Math.abs(area2) < 1 - } - - // 두 선분이 겹치는지 확인 - const isOverlapping = (a1, a2, b1, b2) => { - // x축에 평행한 경우 - if (Math.abs(a1.y - a2.y) < 1 && Math.abs(b1.y - b2.y) < 1) { - if (Math.abs(a1.y - b1.y) > 1) return false - return !(Math.max(a1.x, a2.x) < Math.min(b1.x, b2.x) || Math.min(a1.x, a2.x) > Math.max(b1.x, b2.x)) - } - // y축에 평행한 경우 - if (Math.abs(a1.x - a2.x) < 1 && Math.abs(b1.x - b2.x) < 1) { - if (Math.abs(a1.x - b1.x) > 1) return false - return !(Math.max(a1.y, a2.y) < Math.min(b1.y, b2.y) || Math.min(a1.y, a2.y) > Math.max(b1.y, b2.y)) - } - return false - } - - return areCollinear(line1.p1, line1.p2, line2.p1, line2.p2) && isOverlapping(line1.p1, line1.p2, line2.p1, line2.p2) -} - -// 겹치는 선분을 하나로 합치는 함수 -const mergeCollinearLines = (lines) => { - if (lines.length <= 1) return lines - - const merged = [] - const processed = new Set() - - for (let i = 0; i < lines.length; i++) { - if (processed.has(i)) continue - - let currentLine = lines[i] - let mergedLine = { ...currentLine } - let wasMerged = false - - for (let j = i + 1; j < lines.length; j++) { - if (processed.has(j)) continue - - const otherLine = lines[j] - - if (areLinesCollinearAndOverlapping(mergedLine, otherLine)) { - // 겹치는 선분을 하나로 합침 - const allPoints = [ - { x: mergedLine.p1.x, y: mergedLine.p1.y }, - { x: mergedLine.p2.x, y: mergedLine.p2.y }, - { x: otherLine.p1.x, y: otherLine.p1.y }, - { x: otherLine.p2.x, y: otherLine.p2.y }, - ] - - // x축에 평행한 경우 x 좌표로 정렬 - if (Math.abs(mergedLine.p1.y - mergedLine.p2.y) < 1) { - allPoints.sort((a, b) => a.x - b.x) - mergedLine = { - p1: allPoints[0], - p2: allPoints[allPoints.length - 1], - attributes: mergedLine.attributes, - } - } - // y축에 평행한 경우 y 좌표로 정렬 - else { - allPoints.sort((a, b) => a.y - b.y) - mergedLine = { - p1: allPoints[0], - p2: allPoints[allPoints.length - 1], - attributes: mergedLine.attributes, - } - } - - wasMerged = true - processed.add(j) - } - } - - merged.push(mergedLine) - processed.add(i) - } - - return merged -} - -//조건에 따른 스켈레톤을 그린다. -const createInnerLinesFromSkeleton = (skeleton, baseLines, roof, canvas, textMode, skeletonLines) => { - console.log('=== Edge Properties 기반 후처리 시작 ===') - - if (!skeleton || !skeleton.Edges) return [] - - const innerLines = [] +const createInnerLinesFromSkeleton = (skeleton,canvas, textMode, roof, baseLines) => { + if (!skeleton?.Edges) return [] + const skeletonLines = [] const processedInnerEdges = new Set() - //const skeletonLines = [] - // 1. 기본 skeleton에서 모든 내부 선분 수집 - //edge 순서와 baseLines 순서가 같을수가 없다. - for (let edgeIndex = 0; edgeIndex < skeleton.Edges.length; edgeIndex++) { - console.log('edgeIndex:::', edgeIndex) - let changeEdgeIndex = edgeIndex - //입력 폴리곤이 왼쪽 상단에서 시작하면 시계 방향으로 진행합니다. - //오른쪽 하단에서 시작하면 그 지점에서부터 시계 방향으로 진행합니다. - //edgeIndex 대신에 실제 baseLines 선택라인을 찾아야 한다. - const edgeResult = skeleton.Edges[edgeIndex] - console.log(edgeResult) - // 방향을 고려하지 않고 같은 라인인지 확인하는 함수 + // 1. 모든 Edge를 순회하며 기본 스켈레톤 선(용마루)을 수집합니다. + skeleton.Edges.forEach((edgeResult, index) => { + processEavesEdge(edgeResult, processedInnerEdges, roof, skeletonLines, baseLines[index].attributes.pitch); + }); - let edgeType = 'eaves' - let baseLineIndex = 0 +/* + // 2. 케라바(Gable) 속성을 가진 외벽선에 해당하는 스켈레톤을 후처리합니다. - processEavesEdge(edgeResult, baseLines, skeletonLines, processedInnerEdges) + skeleton.Edges.forEach(edgeResult => { - } + const { Begin, End } = edgeResult.Edge; + const gableBaseLine = roof.lines.find(line => + line.attributes.type === 'gable' && isSameLine(Begin.X, Begin.Y, End.X, End.Y, line) + ); + if (gableBaseLine) { + // Store current state before processing + const beforeGableProcessing = JSON.parse(JSON.stringify(skeletonLines)); - for (let edgeIndex = 0; edgeIndex < skeleton.Edges.length; edgeIndex++) { + // if(canvas.skeletonLines.length > 0){ + // skeletonLines = canvas.skeletonLines; + // } - const edgeResult = skeleton.Edges[edgeIndex] - const startX = edgeResult.Edge.Begin.X - const startY = edgeResult.Edge.Begin.Y - const endX = edgeResult.Edge.End.X - const endY = edgeResult.Edge.End.Y + // Process gable edge with both current and previous states + const processedLines = processGableEdge( + edgeResult, + baseLines, + [...skeletonLines], // Current state + gableBaseLine, + beforeGableProcessing // Previous state + ); - - //외벽선 라인과 같은 edgeResult를 찾는다 - for (let baseLineIndex = 0; baseLineIndex < baseLines.length; baseLineIndex++) { - - if (baseLines[baseLineIndex].attributes.type === 'gable') { - // 일다 그려서 skeletonLines를 만들어 -//외벽선 동일 라인이면 - if (isSameLine(startX, startY, endX, endY, baseLines[baseLineIndex])) { - processGableEdge(edgeResult, baseLines, skeletonLines, processedInnerEdges, edgeIndex, baseLineIndex) // - break // 매칭되는 라인을 찾았으므로 루프 종료 - } - } + // Update canvas with processed lines + canvas.skeletonLines = processedLines; + skeletonLines = processedLines; } + }); +* + //2. 연결이 끊어진 스켈레톤 선을 찾아 연장합니다. + const { disconnectedLines } = findDisconnectedSkeletonLines(skeletonLines, baseLines); + if(disconnectedLines.length > 0) { + disconnectedLines.forEach(dLine => { + const { index, extendedLine, p1Connected, p2Connected } = dLine; + const newPoint = extendedLine?.point; + if (!newPoint) return; + // p1이 끊어졌으면 p1을, p2가 끊어졌으면 p2를 연장된 지점으로 업데이트 + if (p1Connected) { //p2 연장 + skeletonLines[index].p2 = { ...skeletonLines[index].p2, x: newPoint.x, y: newPoint.y }; + } else if (p2Connected) {//p1 연장 + skeletonLines[index].p1 = { ...skeletonLines[index].p1, x: newPoint.x, y: newPoint.y }; + } + }); + + //2-1 확장된 스켈레톤 선이 연장되다가 서로 만나면 만난점(접점)에서 멈추어야 된다. + trimIntersectingExtendedLines(skeletonLines, disconnectedLines); } - console.log(`처리된 skeletonLines: ${skeletonLines.length}개`) +*/ - // 2. 겹치는 선분 병합 - // const mergedLines = mergeCollinearLines(skeletonLines) - // console.log('mergedLines', mergedLines) - // 3. QLine 객체로 변환 - for (const line of skeletonLines) { - const { p1, p2, attributes, lineStyle } = line + // 3. 최종적으로 정리된 스켈레톤 선들을 QLine 객체로 변환하여 캔버스에 추가합니다. + const innerLines = []; + skeletonLines.forEach(line => { + const { p1, p2, attributes, lineStyle } = line; const innerLine = new QLine([p1.x, p1.y, p2.x, p2.y], { parentId: roof.id, fontSize: roof.fontSize, stroke: lineStyle.color, - strokeWidth: lineStyle?.width, + strokeWidth: lineStyle.width, name: attributes.type, textMode: textMode, attributes: attributes, - }) + }); - canvas.add(innerLine) - innerLine.bringToFront() - canvas.renderAll() + canvas.add(innerLine); + innerLine.bringToFront(); + innerLines.push(innerLine); + }); - innerLines.push(innerLine) - } - - return innerLines + canvas.renderAll(); + return innerLines; } -// ✅ EAVES (처마) 처리 - 기본 skeleton 모두 사용 -function processEavesEdge(edgeResult, baseLines, skeletonLines, processedInnerEdges) { - console.log(`processEavesEdge::`, skeletonLines) - const begin = edgeResult.Edge.Begin - const end = edgeResult.Edge.End - - - //내부 선분 수집 (스케레톤은 다각형) - const polygonPoints = edgeResult.Polygon.map((p) => ({ x: p.X, y: p.Y })) +/** + * EAVES(처마) Edge를 처리하여 내부 스켈레톤 선을 추가합니다. + * @param {object} edgeResult - 스켈레톤 Edge 데이터 + * @param {Array} skeletonLines - 스켈레톤 라인 배열 + * @param {Set} processedInnerEdges - 중복 처리를 방지하기 위한 Set + * @param roof + * @param pitch + */ +function processEavesEdge(edgeResult, processedInnerEdges, roof, skeletonLines, pitch) { + const polygonPoints = edgeResult.Polygon.map(p => ({ x: p.X, y: p.Y })); + const currentDegree = getDegreeByChon(pitch) + let eavesLines = [] for (let i = 0; i < polygonPoints.length; i++) { - //시계방향 - const p1 = polygonPoints[i] - const p2 = polygonPoints[(i + 1) % polygonPoints.length] + const p1 = polygonPoints[i]; + const p2 = polygonPoints[(i + 1) % polygonPoints.length]; - // 외벽선 제외 후 추가 - if(begin !== edgeResult.Polygon[i] && end !== edgeResult.Polygon[i] ) { - addRawLine(skeletonLines, processedInnerEdges, p1, p2, 'RIDGE', '#FF0000', 3) + // 외벽선에 해당하는 스켈레톤 선은 제외하고 내부선만 추가 + if (!isOuterEdge(p1, p2, [edgeResult.Edge])) { + addRawLine(roof.id, skeletonLines, processedInnerEdges, p1, p2, 'RIDGE', '#FF0000', 3, currentDegree); } } } -// ✅ WALL (벽) 처리 - 선분 개수 최소화 -function processWallEdge(edgeResult, baseLines, skeletonLines, processedInnerEdges, edgeIndex) { - console.log(`WALL Edge ${edgeIndex}: 내부 선분 최소화`) - - const polygonPoints = edgeResult.Polygon.map((p) => ({ x: p.X, y: p.Y })) - - // 벽면은 내부 구조를 단순화 - 주요 선분만 선택 - for (let i = 0; i < polygonPoints.length; i++) { - const p1 = polygonPoints[i] - const p2 = polygonPoints[(i + 1) % polygonPoints.length] - - if (!isOuterEdge(p1, p2, baseLines)) { - // 선분 길이 확인 - 긴 선분만 사용 (짧은 선분 제거) - const lineLength = Math.sqrt((p2.x - p1.x) ** 2 + (p2.y - p1.y) ** 2) - - if (lineLength > 10) { - // 최소 길이 조건 - addRawLine(skeletonLines, processedInnerEdges, p1, p2, 'HIP', '#000000', 2) - } else { - console.log(`WALL: 짧은 선분 제거 (길이: ${lineLength.toFixed(1)})`) - } - } - } -} - -// ✅ GABLE (케라바) 처리 - 직선 생성, 다른 선분 제거 -function processGableEdge(edgeResult, baseLines, skeletonLines, processedInnerEdges, edgeIndex, baseLineIndex) { - console.log(`GABLE Edge ${edgeResult}: 직선 skeleton 생성`) - const diagonalLine = []; //대각선 라인 - - const polygonPoints = edgeResult.Polygon.map((p) => ({ x: p.X, y: p.Y })) - console.log('polygonPoints::', polygonPoints) - // ✅ 케라바는 직선 패턴으로 변경 - - // 1. 기존 복잡한 skeleton 선분들 무시 - // 2. GABLE edge에 수직인 직선 생성 - const sourceEdge = edgeResult.Edge - const gableStart = { x: sourceEdge.Begin.X, y: sourceEdge.Begin.Y } - const gableEnd = { x: sourceEdge.End.X, y: sourceEdge.End.Y } - - // GABLE edge 중점 - const gableMidpoint = { - x: (gableStart.x + gableEnd.x) / 2, - y: (gableStart.y + gableEnd.y) / 2, - } - - // 폴리곤 중심점 (대략적) - const centerX = polygonPoints.reduce((sum, p) => sum + p.x, 0) / polygonPoints.length - const centerY = polygonPoints.reduce((sum, p) => sum + p.y, 0) / polygonPoints.length - const polygonCenter = { x: centerX, y: centerY } - - - - const selectBaseLine = baseLines[baseLineIndex]; - console.log('selectBaseLine:', selectBaseLine); - console.log('skeletonLines:', skeletonLines) - - // selectBaseLine의 중간 좌표 계산 - const midPoint = { - x: (selectBaseLine.x1 + selectBaseLine.x2) / 2, - y: (selectBaseLine.y1 + selectBaseLine.y2) / 2 - }; - console.log('midPoint of selectBaseLine:', midPoint); - - // 대각선 보정(fallback) 제거: 항상 수평/수직 내부 용마루만 생성 +/** + * GABLE(케라바) Edge를 처리하여 스켈레톤 선을 정리하고 연장합니다. + * @param {object} edgeResult - 스켈레톤 Edge 데이터 + * @param {Array} baseLines - 전체 외벽선 배열 + * @param {Array} skeletonLines - 전체 스켈레톤 라인 배열 + * @param selectBaseLine + * @param lastSkeletonLines + */ +function processGableEdge(edgeResult, baseLines, skeletonLines, selectBaseLine, lastSkeletonLines) { const edgePoints = edgeResult.Polygon.map(p => ({ x: p.X, y: p.Y })); - - //제거 + //const polygons = createPolygonsFromSkeletonLines(skeletonLines, selectBaseLine); + console.log("edgePoints::::::", edgePoints) +// 1. Initialize processedLines with a deep copy of lastSkeletonLines + let processedLines = [] + // 1. 케라바 면과 관련된 불필요한 스켈레톤 선을 제거합니다. for (let i = skeletonLines.length - 1; i >= 0; i--) { - const line = skeletonLines[i]; - console.log('line:', line) - console.log('line.attributes.type:', line.attributes.type) + const line = skeletonLines[i]; + const isEdgeLine = line.p1 && line.p2 && + edgePoints.some(ep => Math.abs(ep.x - line.p1.x) < 0.001 && Math.abs(ep.y - line.p1.y) < 0.001) && + edgePoints.some(ep => Math.abs(ep.x - line.p2.x) < 0.001 && Math.abs(ep.y - line.p2.y) < 0.001); - const linePoints = [line.p1, line.p2]; - - // Check if both points of the line are in the edgePoints - const isEdgeLine = linePoints.every(point => - edgePoints.some(ep => - Math.abs(ep.x - point.x) < 0.001 && - Math.abs(ep.y - point.y) < 0.001 - ) - ); - - if (isEdgeLine) { - skeletonLines.splice(i, 1); - } + if (isEdgeLine) { + skeletonLines.splice(i, 1); + } } - //확장 - const breakLinePont = findDisconnectedSkeletonLines(skeletonLines, baseLines) - console.log('breakLinePont:', breakLinePont) + console.log("skeletonLines::::::", skeletonLines) + console.log("lastSkeletonLines", lastSkeletonLines) -if(breakLinePont.disconnectedLines.length > 0) { + // 2. Find common lines between skeletonLines and lastSkeletonLines + skeletonLines.forEach(line => { + const matchingLine = lastSkeletonLines?.find(pl => + pl.p1 && pl.p2 && line.p1 && line.p2 && + ((Math.abs(pl.p1.x - line.p1.x) < 0.001 && Math.abs(pl.p1.y - line.p1.y) < 0.001 && + Math.abs(pl.p2.x - line.p2.x) < 0.001 && Math.abs(pl.p2.y - line.p2.y) < 0.001) || + (Math.abs(pl.p1.x - line.p2.x) < 0.001 && Math.abs(pl.p1.y - line.p2.y) < 0.001 && + Math.abs(pl.p2.x - line.p1.x) < 0.001 && Math.abs(pl.p2.y - line.p1.y) < 0.001)) + ); + if (matchingLine) { + processedLines.push({...matchingLine}); + } + }); - for (const dLine of breakLinePont.disconnectedLines) { - const inx = dLine.index; - const exLine = dLine.extendedLine; + // // 3. Remove lines that are part of the gable edge + // processedLines = processedLines.filter(line => { + // const isEdgeLine = line.p1 && line.p2 && + // edgePoints.some(ep => Math.abs(ep.x - line.p1.x) < 0.001 && Math.abs(ep.y - line.p1.y) < 0.001) && + // edgePoints.some(ep => Math.abs(ep.x - line.p2.x) < 0.001 && Math.abs(ep.y - line.p2.y) < 0.001); + // + // return !isEdgeLine; + // }); - //확장 - if (dLine.p1Connected) { - skeletonLines[inx].p2 = { ...skeletonLines[inx].p2, x: exLine.p2.x, y: exLine.p2.y }; - - } else if (dLine.p2Connected) { - skeletonLines[inx].p1 = { ...skeletonLines[inx].p1, x: exLine.p1.x, y: exLine.p1.y }; - } - - } -} - //확장(연장) -// for (let i = 0; i < skeletonLines.length; i++) { -// const line = skeletonLines[i]; -// const p1 = line.p1; -// const p2 = line.p2; -// const lineP1 = { x: line.p1.x, y: line.p1.y }; -// const lineP2 = { x: line.p2.x, y: line.p2.y }; -// -// let hasP1 = false; -// let hasP2 = false; -// console.log('edgeResult.Edge::',edgeResult.Edge) -// //선택한 라인과 다각형을 생성하는 라인 여부 -// const matchingLinePoint = findMatchingLinePoints(line, edgeResult.Polygon); -// console.log(matchingLinePoint); -// -// -// if(matchingLinePoint.hasMatch) { -// -// if (matchingLinePoint.matches[0].type === 'diagonal') { -// console.log("lineP1:", lineP1) -// console.log("lineP2:", lineP2) -// const intersectionPoint = getLineIntersectionParametric(lineP1, lineP2, gableStart, gableEnd); -// console.log('intersectionPoint:', intersectionPoint); -// console.log('gableStart:', gableStart); -// console.log('gableEnd:', gableEnd); -// // 교차점이 생겼다면 절삭(교차점 이하(이상) 삭제) -// if (!intersectionPoint) { -// console.warn('No valid intersection point found between line and gable edge'); -// return; // or handle the null case appropriately -// } -// -// if (matchingLinePoint.matches[0].linePoint === 'p1') { -// skeletonLines[i].p1 = { ...skeletonLines[i].p1, x: intersectionPoint.x, y: intersectionPoint.y }; -// } else { -// skeletonLines[i].p2 = { ...skeletonLines[i].p2, x: intersectionPoint.x, y: intersectionPoint.y }; -// } -// -// } else if (matchingLinePoint.matches[0].type === 'horizontal') { -// if (matchingLinePoint.matches[0].linePoint === 'p1') { -// skeletonLines[i].p1 = { ...skeletonLines[i].p1, x: edgeResult.Edge.Begin.X }; -// } else { -// skeletonLines[i].p2 = { ...skeletonLines[i].p2, x: edgeResult.Edge.Begin.X }; -// } -// -// } else if (matchingLinePoint.matches[0].type === 'vertical') { -// if (matchingLinePoint.matches[0].linePoint === 'p1') { -// skeletonLines[i].p1 = { ...skeletonLines[i].p1, y: edgeResult.Edge.Begin.Y }; -// } else { -// skeletonLines[i].p2 = { ...skeletonLines[i].p2, y: edgeResult.Edge.Begin.Y }; -// } -// } -// -// } -// -// } + console.log("skeletonLines::::::", skeletonLines); + console.log("lastSkeletonLines", lastSkeletonLines); + console.log("processedLines after filtering", processedLines); + return processedLines; } -// ✅ 헬퍼 함수들 -function isOuterEdge(p1, p2, baseLines) { - const tolerance = 0.1 - return baseLines.some((line) => { - const lineStart = line.startPoint || { x: line.x1, y: line.y1 } - const lineEnd = line.endPoint || { x: line.x2, y: line.y2 } - return ( - (Math.abs(lineStart.x - p1.x) < tolerance && - Math.abs(lineStart.y - p1.y) < tolerance && - Math.abs(lineEnd.x - p2.x) < tolerance && - Math.abs(lineEnd.y - p2.y) < tolerance) || - (Math.abs(lineStart.x - p2.x) < tolerance && - Math.abs(lineStart.y - p2.y) < tolerance && - Math.abs(lineEnd.x - p1.x) < tolerance && - Math.abs(lineEnd.y - p1.y) < tolerance) - ) - }) +// --- Helper Functions --- + +/** + * 두 점으로 이루어진 선분이 외벽선인지 확인합니다. + * @param {object} p1 - 점1 {x, y} + * @param {object} p2 - 점2 {x, y} + * @param {Array} edges - 확인할 외벽선 Edge 배열 + * @returns {boolean} 외벽선 여부 + */ +function isOuterEdge(p1, p2, edges) { + const tolerance = 0.1; + return edges.some(edge => { + const lineStart = { x: edge.Begin.X, y: edge.Begin.Y }; + const lineEnd = { x: edge.End.X, y: edge.End.Y }; + const forwardMatch = Math.abs(lineStart.x - p1.x) < tolerance && Math.abs(lineStart.y - p1.y) < tolerance && Math.abs(lineEnd.x - p2.x) < tolerance && Math.abs(lineEnd.y - p2.y) < tolerance; + const backwardMatch = Math.abs(lineStart.x - p2.x) < tolerance && Math.abs(lineStart.y - p2.y) < tolerance && Math.abs(lineEnd.x - p1.x) < tolerance && Math.abs(lineEnd.y - p1.y) < tolerance; + return forwardMatch || backwardMatch; + }); } -function addRawLine(skeletonLines, processedInnerEdges, p1, p2, lineType, color, width) { - const edgeKey = [`${p1.x.toFixed(1)},${p1.y.toFixed(1)}`, `${p2.x.toFixed(1)},${p2.y.toFixed(1)}`].sort().join('|') +/** + * 스켈레톤 라인 배열에 새로운 라인을 추가합니다. (중복 방지) + * @param id + * @param {Array} skeletonLines - 스켈레톤 라인 배열 + * @param {Set} processedInnerEdges - 처리된 Edge 키 Set + * @param {object} p1 - 시작점 + * @param {object} p2 - 끝점 + * @param {string} lineType - 라인 타입 + * @param {string} color - 색상 + * @param {number} width - 두께 + * @param currentDegree + */ +function addRawLine(id, skeletonLines, processedInnerEdges, p1, p2, lineType, color, width, currentDegree) { + const edgeKey = [`${p1.x.toFixed(1)},${p1.y.toFixed(1)}`, `${p2.x.toFixed(1)},${p2.y.toFixed(1)}`].sort().join('|'); + if (processedInnerEdges.has(edgeKey)) return; + processedInnerEdges.add(edgeKey); - if (processedInnerEdges.has(edgeKey)) return - processedInnerEdges.add(edgeKey) - - // 라인 타입을 상수로 정규화 - const inputNormalizedType = - lineType === LINE_TYPE.SUBLINE.RIDGE || lineType === 'RIDGE' - ? LINE_TYPE.SUBLINE.RIDGE - : lineType === LINE_TYPE.SUBLINE.HIP || lineType === 'HIP' - ? LINE_TYPE.SUBLINE.HIP - : lineType - - // 대각선 여부 판단 (수평/수직이 아닌 경우) - const dx = Math.abs(p2.x - p1.x) - const dy = Math.abs(p2.y - p1.y) - const tolerance = 0.1 - const isHorizontal = dy < tolerance - const isVertical = dx < tolerance - const isDiagonal = !isHorizontal && !isVertical - - // 대각선일 때 lineType을 HIP로 지정 - const normalizedType = isDiagonal ? LINE_TYPE.SUBLINE.HIP : inputNormalizedType + const dx = Math.abs(p2.x - p1.x); + const dy = Math.abs(p2.y - p1.y); + const isDiagonal = dx > 0.1 && dy > 0.1; + const normalizedType = isDiagonal ? LINE_TYPE.SUBLINE.HIP : lineType; + const rawLines = [] skeletonLines.push({ - p1: p1, - p2: p2, + p1, + p2, attributes: { + roofId:id, + + actualSize: (isDiagonal) ? calcLineActualSize( + { + x1: p1.x, + y1: p1.y, + x2: p2.x, + y2: p2.y + }, + currentDegree + ) : calcLinePlaneSize({ x1: p1.x, y1: p1.y, x2: p2.x, y2: p2.y }), + type: normalizedType, planeSize: calcLinePlaneSize({ x1: p1.x, y1: p1.y, x2: p2.x, y2: p2.y }), isRidge: normalizedType === LINE_TYPE.SUBLINE.RIDGE, }, - lineStyle: { - color: color, - width: width, - }, - }) + lineStyle: { color, width }, + }); + + console.log('skeletonLines', skeletonLines); } /** - * 특정 roof의 edge를 캐라바로 설정하여 다시 그립니다. - * @param {string} roofId - 지붕 ID - * @param {fabric.Canvas} canvas - 캔버스 객체 - * @param {string} textMode - 텍스트 모드 - * @param {number} selectedEdgeIndex - 선택된 외곽선의 인덱스 - */ - -export const drawSkeletonWithTransformedEdges = (roofId, canvas, textMode, selectedEdgeIndex) => { - let roof = canvas?.getObjects().find((object) => object.id === roofId) - if (!roof) { - console.warn('Roof object not found') - return - } - - // Clear existing inner lines if any - if (roof.innerLines) { - roof.innerLines.forEach((line) => canvas.remove(line)) - roof.innerLines = [] - } - - // Transform the selected wall into a roof - transformWallToRoof(roof, canvas, selectedEdgeIndex) - - canvas.renderAll() -} - -/** - * 삼각형에 대한 캐라바 처리 - * @param {QPolygon} roof - 지붕 객체 - * @param {fabric.Canvas} canvas - 캔버스 객체 - * @param {string} textMode - 텍스트 모드 - * @param {number} selectedEdgeIndex - 선택된 외곽선의 인덱스 - */ -const drawCarabaForTriangle = (roof, canvas, textMode, edgeIndex) => { - const points = roof.getCurrentPoints() - - if (!points || points.length !== 3) { - console.warn('삼각형이 아니거나 유효하지 않은 점 데이터입니다.') - return - } - - if (edgeIndex < 0 || edgeIndex >= 3) { - console.warn('유효하지 않은 edge 인덱스입니다.') - return - } - - // 선택된 edge의 두 꼭짓점을 제외한 나머지 점 찾기 - const oppositeVertexIndex = (edgeIndex + 2) % 3 - const oppositeVertex = points[oppositeVertexIndex] - - // 선택된 edge의 시작점과 끝점 - const edgeStartIndex = edgeIndex - const edgeEndIndex = (edgeIndex + 1) % 3 - const edgeStart = points[edgeStartIndex] - const edgeEnd = points[edgeEndIndex] - - // 선택된 edge의 중점 계산 - const edgeMidPoint = { - x: (edgeStart.x + edgeEnd.x) / 2, - y: (edgeStart.y + edgeEnd.y) / 2, - } - - // 맞은편 꼭짓점에서 선택된 edge의 중점으로 가는 직선 생성 - const carabaLine = new QLine([oppositeVertex.x, oppositeVertex.y, edgeMidPoint.x, edgeMidPoint.y], { - parentId: roof.id, - fontSize: roof.fontSize, - stroke: '#FF0000', - strokeWidth: 2, - name: LINE_TYPE.SUBLINE.RIDGE, - textMode: textMode, - attributes: { - type: LINE_TYPE.SUBLINE.RIDGE, - planeSize: calcLinePlaneSize({ - x1: oppositeVertex.x, - y1: oppositeVertex.y, - x2: edgeMidPoint.x, - y2: edgeMidPoint.y, - }), - actualSize: calcLineActualSize( - { - x1: oppositeVertex.x, - y1: oppositeVertex.y, - x2: edgeMidPoint.x, - y2: edgeMidPoint.y, - }, - getDegreeByChon(roof.lines[edgeIndex]?.attributes?.pitch || 30), - ), - roofId: roof.id, - isRidge: true, - }, - }) - - // 캔버스에 추가 - canvas.add(carabaLine) - carabaLine.bringToFront() - - // 지붕 객체에 저장 - roof.innerLines = [carabaLine] - - // canvas에 skeleton 상태 저장 - if (!canvas.skeletonStates) { - canvas.skeletonStates = {} - } - canvas.skeletonStates[roof.id] = true - - canvas.renderAll() -} - -/** - * 다각형에 대한 캐라바 처리 - * @param {QPolygon} roof - 지붕 객체 - * @param {fabric.Canvas} canvas - 캔버스 객체 - * @param {string} textMode - 텍스트 모드 - * @param {number} selectedEdgeIndex - 선택된 외곽선의 인덱스 - */ -const drawCarabaForPolygon = (roof, canvas, textMode, selectedEdgeIndex) => { - const points = roof.getCurrentPoints() - - if (!points || points.length < 3) { - console.warn('유효하지 않은 다각형 점 데이터입니다.') - return - } - - if (selectedEdgeIndex < 0 || selectedEdgeIndex >= points.length) { - console.warn('유효하지 않은 edge 인덱스입니다.') - return - } - - // 삼각형인 경우 기존 로직 사용 - if (points.length === 3) { - drawCarabaForTriangle(roof, canvas, textMode, selectedEdgeIndex) - return - } - - // 먼저 스켈레톤을 생성하여 내부 구조를 파악 - const geoJSONPolygon = toGeoJSON(points) - geoJSONPolygon.pop() // 마지막 좌표 제거 - - let skeleton - try { - skeleton = SkeletonBuilder.BuildFromGeoJSON([[geoJSONPolygon]]) - } catch (e) { - console.error('스켈레톤 생성 중 오류:', e) - return - } - - if (!skeleton || !skeleton.Edges) { - console.warn('스켈레톤 생성에 실패했습니다.') - return - } - - // 선택된 외곽선의 시작점과 끝점 - const selectedStart = points[selectedEdgeIndex] - const selectedEnd = points[(selectedEdgeIndex + 1) % points.length] - - const innerLines = [] - const processedInnerEdges = new Set() - - // 스켈레톤의 모든 내부선을 수집 - for (const edgeResult of skeleton.Edges) { - const polygonPoints = edgeResult.Polygon.map((p) => ({ x: p.X, y: p.Y })) - - for (let i = 0; i < polygonPoints.length; i++) { - const p1 = polygonPoints[i] - const p2 = polygonPoints[(i + 1) % polygonPoints.length] - - // 선택된 외곽선은 제외 - const isSelectedEdge = - (arePointsEqual(selectedStart, p1) && arePointsEqual(selectedEnd, p2)) || - (arePointsEqual(selectedStart, p2) && arePointsEqual(selectedEnd, p1)) - - if (isSelectedEdge) continue - - // 다른 외곽선들은 제외 - const isOtherOuterEdge = roof.lines.some((line, idx) => { - if (idx === selectedEdgeIndex) return false - return ( - (arePointsEqual(line.startPoint, p1) && arePointsEqual(line.endPoint, p2)) || - (arePointsEqual(line.startPoint, p2) && arePointsEqual(line.endPoint, p1)) - ) - }) - - if (isOtherOuterEdge) continue - - const edgeKey = [`${p1.x.toFixed(1)},${p1.y.toFixed(1)}`, `${p2.x.toFixed(1)},${p2.y.toFixed(1)}`].sort().join('|') - - if (processedInnerEdges.has(edgeKey)) continue - processedInnerEdges.add(edgeKey) - - // 선택된 외곽선에 수직으로 연장되는 선들만 처리 - const selectedLineAngle = calculateAngle(selectedStart, selectedEnd) - const innerLineAngle = calculateAngle(p1, p2) - const angleDiff = Math.abs(selectedLineAngle - innerLineAngle) - const isPerpendicular = Math.abs(angleDiff - 90) < 5 || Math.abs(angleDiff - 270) < 5 - - if (isPerpendicular) { - // 선택된 외곽선 방향으로 연장 - const extendedLine = extendLineToOppositeEdge(p1, p2, points, selectedEdgeIndex) - - if (extendedLine) { - const carabaLine = new QLine([extendedLine.start.x, extendedLine.start.y, extendedLine.end.x, extendedLine.end.y], { - parentId: roof.id, - fontSize: roof.fontSize, - stroke: '#FF0000', - strokeWidth: 2, - name: LINE_TYPE.SUBLINE.RIDGE, - textMode: textMode, - attributes: { - type: LINE_TYPE.SUBLINE.RIDGE, - planeSize: calcLinePlaneSize({ - x1: extendedLine.start.x, - y1: extendedLine.start.y, - x2: extendedLine.end.x, - y2: extendedLine.end.y, - }), - actualSize: calcLineActualSize( - { - x1: extendedLine.start.x, - y1: extendedLine.start.y, - x2: extendedLine.end.x, - y2: extendedLine.end.y, - }, - getDegreeByChon(roof.lines[selectedEdgeIndex]?.attributes?.pitch || 30), - ), - roofId: roof.id, - isRidge: true, - }, - }) - - innerLines.push(carabaLine) - } - } - } - } - - // 캔버스에 추가 - innerLines.forEach((line) => { - canvas.add(line) - line.bringToFront() - }) - - // 지붕 객체에 저장 - roof.innerLines = innerLines - - // canvas에 skeleton 상태 저장 - if (!canvas.skeletonStates) { - canvas.skeletonStates = {} - } - canvas.skeletonStates[roof.id] = true - - canvas.renderAll() -} - -/** - * 선분을 맞은편 외곽선까지 연장하는 함수 - */ -const extendLineToOppositeEdge = (p1, p2, polygonPoints, selectedEdgeIndex) => { - // 선분의 방향 벡터 계산 - const direction = { - x: p2.x - p1.x, - y: p2.y - p1.y, - } - - // 방향 벡터 정규화 - const length = Math.sqrt(direction.x * direction.x + direction.y * direction.y) - - if (length === 0) return null - - const normalizedDir = { - x: direction.x / length, - y: direction.y / length, - } - - // 선택된 외곽선의 반대편 찾기 - const oppositeEdgeIndex = (selectedEdgeIndex + Math.floor(polygonPoints.length / 2)) % polygonPoints.length - const oppositeStart = polygonPoints[oppositeEdgeIndex] - const oppositeEnd = polygonPoints[(oppositeEdgeIndex + 1) % polygonPoints.length] - - // p1에서 시작해서 반대편까지 연장 - const extendedStart = { x: p1.x, y: p1.y } - const extendedEnd = findIntersectionWithEdge(p1, normalizedDir, oppositeStart, oppositeEnd) || { x: p2.x, y: p2.y } - - return { - start: extendedStart, - end: extendedEnd, - } -} - -/** - * 선분과 외곽선의 교점을 찾는 함수 - */ -const findIntersectionWithEdge = (lineStart, lineDir, edgeStart, edgeEnd) => { - const edgeDir = { - x: edgeEnd.x - edgeStart.x, - y: edgeEnd.y - edgeStart.y, - } - - const denominator = lineDir.x * edgeDir.y - lineDir.y * edgeDir.x - if (Math.abs(denominator) < 1e-10) return null // 평행선 - - const t = ((edgeStart.x - lineStart.x) * edgeDir.y - (edgeStart.y - lineStart.y) * edgeDir.x) / denominator - const u = ((edgeStart.x - lineStart.x) * lineDir.y - (edgeStart.y - lineStart.y) * lineDir.x) / denominator - - if (t >= 0 && u >= 0 && u <= 1) { - return { - x: lineStart.x + t * lineDir.x, - y: lineStart.y + t * lineDir.y, - } - } - - return null -} - -/** - * Transforms the selected wall line into a roof structure - * @param {QPolygon} roof - The roof object - * @param {fabric.Canvas} canvas - The canvas object - * @param {number} edgeIndex - Index of the selected edge - */ -const transformWallToRoof = (roof, canvas, edgeIndex) => { - // Get the current points - const points = roof.getCurrentPoints() - - if (!points || points.length < 3) { - console.warn('Invalid polygon points') - return - } - - // Get the selected edge points - const p1 = points[edgeIndex] - const p2 = points[(edgeIndex + 1) % points.length] - - // Calculate mid point of the selected edge - const midX = (p1.x + p2.x) / 2 - const midY = (p1.y + p2.y) / 2 - - // Calculate the perpendicular vector (for the roof ridge) - const dx = p2.x - p1.x - const dy = p2.y - p1.y - const length = Math.sqrt(dx * dx + dy * dy) - - // Normal vector (perpendicular to the edge) - const nx = -dy / length - const ny = dx / length - - // Calculate the ridge point (extending inward from the middle of the edge) - const ridgeLength = length * 0.4 // Adjust this factor as needed - const ridgeX = midX + nx * ridgeLength - const ridgeY = midY + ny * ridgeLength - - // Create the new points for the roof - const newPoints = [...points] - newPoints.splice(edgeIndex + 1, 0, { x: ridgeX, y: ridgeY }) - - // Update the roof with new points - roof.set({ - points: newPoints, - // Ensure the polygon is re-rendered - dirty: true, - }) - - // Update the polygon's path - roof.setCoords() - - // Force a re-render of the canvas - canvas.renderAll() - - return roof -} - -/** - * 사용 예제: 첫 번째 edge를 45도 각도로 변형 - * drawSkeletonWithTransformedEdges(roofId, canvas, textMode, [ - * { edgeIndex: 0, angleOffset: 45, splitRatio: 0.5 } - * ]); - */ - -class Advanced2DRoofBuilder extends SkeletonBuilder { - static Build2DRoofFromAdvancedProperties(geoJsonPolygon, edgeProperties) { - // 입력 데이터 검증 - if (!geoJsonPolygon || !Array.isArray(geoJsonPolygon) || geoJsonPolygon.length === 0) { - throw new Error('geoJsonPolygon이 유효하지 않습니다') - } - - if (!edgeProperties || !Array.isArray(edgeProperties)) { - throw new Error('edgeProperties가 유효하지 않습니다') - } - - console.log('입력 검증 통과') - console.log('geoJsonPolygon:', geoJsonPolygon) - console.log('edgeProperties:', edgeProperties) - - // 1. 입력 폴리곤을 edgeProperties에 따라 수정 - const modifiedPolygon = this.preprocessPolygonByEdgeTypes(geoJsonPolygon, edgeProperties) - - // 2. 수정된 폴리곤으로 skeleton 생성 - const skeleton = SkeletonBuilder.BuildFromGeoJSON([[modifiedPolygon]]) - - if (!skeleton || !skeleton.Edges) { - throw new Error('Skeleton 생성 실패') - } - - // 3. Edge 분석 - const edgeAnalysis = this.analyzeAdvancedEdgeTypes(edgeProperties) - - return { - skeleton: skeleton, - original_polygon: geoJsonPolygon, - modified_polygon: modifiedPolygon, - roof_type: edgeAnalysis.roof_type, - edge_analysis: edgeAnalysis, - } - } - - /** - * ✅ 안전한 폴리곤 전처리 - */ - static preprocessPolygonByEdgeTypes(geoJsonPolygon, edgeProperties) { - try { - const originalRing = geoJsonPolygon - - if (!Array.isArray(originalRing) || originalRing.length < 4) { - throw new Error('외곽선이 유효하지 않습니다') - } - - const modifiedRing = originalRing.map((point) => { - if (!Array.isArray(point) || point.length < 2) { - throw new Error('좌표점 형식이 잘못되었습니다') - } - return [point[0], point[1]] - }) - - const isClosedPolygon = this.isPolygonClosed(modifiedRing) - if (isClosedPolygon) { - modifiedRing.pop() - } - - const actualEdgeCount = modifiedRing.length - const edgeCountToProcess = Math.min(edgeProperties.length, actualEdgeCount) - - for (let i = 0; i < edgeCountToProcess; i++) { - const edgeProp = edgeProperties[i] - const edgeType = edgeProp?.edge_type - - console.log(`Processing edge ${i}: ${edgeType}`) - - try { - switch (edgeType) { - case 'EAVES': - // ✅ 수정: 처마는 기본 상태이므로 수정하지 않음 - console.log(`Edge ${i}: EAVES - 기본 처마 상태 유지`) - break - - case 'WALL': - // ✅ 수정: 처마를 벽으로 변경 - this.transformEavesToWall(modifiedRing, i, edgeProp) - break - - case 'GABLE': - // ✅ 수정: 처마를 케라바로 변경 - this.transformEavesToGable(modifiedRing, i, edgeProp) - break - - default: - console.warn(`알 수 없는 edge 타입: ${edgeType}, 기본 EAVES로 처리`) - } - } catch (edgeError) { - console.error(`Edge ${i} 처리 중 오류:`, edgeError) - } - } - - const finalPolygon = this.prepareFinalPolygon(modifiedRing) - return finalPolygon - } catch (error) { - console.error('폴리곤 전처리 오류:', error) - throw error - } - } - - /** - * ✅ 처마를 벽으로 변경 (내부로 수축) - */ - static transformEavesToWall(ring, edgeIndex, edgeProp) { - console.log(`transformEavesToWall: edgeIndex=${edgeIndex}`) - - if (!ring || !Array.isArray(ring)) { - console.error('ring이 배열이 아니거나 undefined입니다') - return - } - - const totalPoints = ring.length - - if (edgeIndex < 0 || edgeIndex >= totalPoints) { - console.error(`edgeIndex ${edgeIndex}가 범위를 벗어났습니다`) - return - } - - const p1 = ring[edgeIndex] - const nextIndex = (edgeIndex + 1) % totalPoints - const p2 = ring[nextIndex] - - if (!Array.isArray(p1) || p1.length < 2 || !Array.isArray(p2) || p2.length < 2) { - console.error('점 형식이 잘못되었습니다') - return - } - - try { - // 폴리곤 중심 계산 - const centerX = ring.reduce((sum, p) => sum + p[0], 0) / totalPoints - const centerY = ring.reduce((sum, p) => sum + p[1], 0) / totalPoints - - // edge 중점 - const midX = (p1[0] + p2[0]) / 2 - const midY = (p1[1] + p2[1]) / 2 - - // 내향 방향 (처마 → 벽: 안쪽으로 수축) - const dirX = centerX - midX - const dirY = centerY - midY - const length = Math.sqrt(dirX * dirX + dirY * dirY) - - if (length < 0.001) { - console.warn('내향 방향 벡터 길이가 거의 0입니다') - return - } - - const unitX = dirX / length - const unitY = dirY / length - const shrinkDistance = edgeProp.shrink_distance || 0.8 // 벽 수축 거리 - - // 점들을 내부로 이동 - ring[edgeIndex] = [p1[0] + unitX * shrinkDistance, p1[1] + unitY * shrinkDistance] - - ring[nextIndex] = [p2[0] + unitX * shrinkDistance, p2[1] + unitY * shrinkDistance] - - console.log(`✅ WALL: Edge ${edgeIndex} 내부로 수축 완료 (${shrinkDistance})`) - } catch (calcError) { - console.error('벽 변환 계산 중 오류:', calcError) - } - } - - /** - * ✅ 처마를 케라바로 변경 (특별한 형태로 변형) - */ - // static transformEavesToGable(ring, edgeIndex, edgeProp) { - // console.log(`transformEavesToGable: edgeIndex=${edgeIndex}`); - // - // // 안전성 검증 - // if (!ring || !Array.isArray(ring)) { - // console.error('ring이 배열이 아니거나 undefined입니다'); - // return; - // } - // - // const totalPoints = ring.length; - // - // if (edgeIndex < 0 || edgeIndex >= totalPoints) { - // console.error(`edgeIndex ${edgeIndex}가 범위를 벗어났습니다`); - // return; - // } - // - // const p1 = ring[edgeIndex]; - // const nextIndex = (edgeIndex + 1) % totalPoints; - // const p2 = ring[nextIndex]; - // - // if (!Array.isArray(p1) || p1.length < 2 || - // !Array.isArray(p2) || p2.length < 2) { - // console.error('점 형식이 잘못되었습니다'); - // return; - // } - // - // try { - // // 케라바 변형: edge를 직선화하고 약간 내부로 이동 - // const centerX = ring.reduce((sum, p) => sum + p[0], 0) / totalPoints; - // const centerY = ring.reduce((sum, p) => sum + p[1], 0) / totalPoints; - // - // const midX = (p1[0] + p2[0]) / 2; - // const midY = (p1[1] + p2[1]) / 2; - // - // // 내향 방향으로 약간 이동 - // const dirX = centerX - midX; - // const dirY = centerY - midY; - // const length = Math.sqrt(dirX * dirX + dirY * dirY); - // - // if (length < 0.001) { - // console.warn('내향 방향 벡터 길이가 거의 0입니다'); - // return; - // } - // - // const unitX = dirX / length; - // const unitY = dirY / length; - // const gableInset = edgeProp.gable_inset || 0.3; // 케라바 안쪽 이동 거리 - // - // // edge의 방향 벡터 - // const edgeVecX = p2[0] - p1[0]; - // const edgeVecY = p2[1] - p1[1]; - // - // // 새로운 중점 (안쪽으로 이동) - // const newMidX = midX + unitX * gableInset; - // const newMidY = midY + unitY * gableInset; - // - // // 케라바를 위한 직선화된 점들 - // ring[edgeIndex] = [ - // newMidX - edgeVecX * 0.5, - // newMidY - edgeVecY * 0.5 - // ]; - // - // ring[nextIndex] = [ - // newMidX + edgeVecX * 0.5, - // newMidY + edgeVecY * 0.5 - // ]; - // - // console.log(`✅ GABLE: Edge ${edgeIndex} 케라바 변형 완료`); - // - // } catch (calcError) { - // console.error('케라바 변환 계산 중 오류:', calcError); - // } - // } - - static transformEavesToGable(ring, edgeIndex, edgeProp) { - // ✅ 캐라바면을 위한 특별 처리 - // 해당 edge를 "직선 제약 조건"으로 만들어야 함 - - const p1 = ring[edgeIndex] - const nextIndex = (edgeIndex + 1) % ring.length - const p2 = ring[nextIndex] - - // 캐라바면: edge를 완전히 직선으로 고정 - // 이렇게 하면 skeleton이 이 edge에 수직으로만 생성됨 - - // 중간점들을 제거하여 직선화 - const midX = (p1[0] + p2[0]) / 2 - const midY = (p1[1] + p2[1]) / 2 - - // 캐라바 edge를 단순 직선으로 만들어 - // SkeletonBuilder가 여기서 직선 skeleton을 생성하도록 유도 - console.log(`✅ GABLE: Edge ${edgeIndex}를 직선 캐라바로 설정`) - } - - // analyzeAdvancedEdgeTypes도 수정 - static analyzeAdvancedEdgeTypes(edgeProperties) { - const eavesEdges = [] // 기본 처마 (수정 안함) - const wallEdges = [] // 처마→벽 변경 - const gableEdges = [] // 처마→케라바 변경 - - edgeProperties.forEach((prop, i) => { - switch (prop?.edge_type) { - case 'EAVES': - eavesEdges.push(i) - break - case 'WALL': - wallEdges.push(i) - break - case 'GABLE': - gableEdges.push(i) - break - default: - console.warn(`Edge ${i}: 알 수 없는 타입 ${prop?.edge_type}, 기본 EAVES로 처리`) - eavesEdges.push(i) - } - }) - - let roofType - if (wallEdges.length === 0 && gableEdges.length === 0) { - roofType = 'pavilion' // 모든 면이 처마 - } else if (wallEdges.length === 4 && gableEdges.length === 0) { - roofType = 'hipped' // 모든 면이 벽 - } else if (gableEdges.length === 2 && wallEdges.length === 2) { - roofType = 'gabled' // 박공지붕 - } else { - roofType = 'complex' // 복합지붕 - } - - return { - roof_type: roofType, - eaves_edges: eavesEdges, // 기본 처마 - wall_edges: wallEdges, // 처마→벽 - gable_edges: gableEdges, // 처마→케라바 - } - } - - /** - * ✅ 폴리곤이 닫혀있는지 확인 - */ - static isPolygonClosed(ring) { - if (!ring || ring.length < 2) return false - - const firstPoint = ring[0] - const lastPoint = ring[ring.length - 1] - - const tolerance = 0.0001 // 부동소수점 허용 오차 - - return Math.abs(firstPoint[0] - lastPoint[0]) < tolerance && Math.abs(firstPoint[1] - lastPoint[1]) < tolerance - } - - /** - * ✅ BuildFromGeoJSON용 최종 polygon 준비 - */ - static prepareFinalPolygon(ring) { - // 1. 최소 점 개수 확인 - if (ring.length < 3) { - throw new Error(`폴리곤 점이 부족합니다: ${ring.length}개 (최소 3개 필요)`) - } - - // 2. 닫힌 폴리곤인지 다시 확인 - const isClosed = this.isPolygonClosed(ring) - - if (isClosed) { - console.log('여전히 닫힌 폴리곤입니다. 마지막 점 제거') - return ring.slice(0, -1) // 마지막 점 제거 - } - - // 3. 열린 폴리곤이면 그대로 반환 - console.log('열린 폴리곤 상태로 BuildFromGeoJSON에 전달') - return [...ring] // 복사본 반환 - } - - static expandEdgeForEaves(ring, edgeIndex, overhang) { - console.log(`expandEdgeForEaves 시작: edgeIndex=${edgeIndex}, overhang=${overhang}`) - - // 안전성 검증 - if (!ring || !Array.isArray(ring)) { - console.error('ring이 배열이 아니거나 undefined입니다') - return - } - - const totalPoints = ring.length - 1 // 마지막 중복점 제외 - console.log(`ring 길이: ${ring.length}, totalPoints: ${totalPoints}`) - - if (totalPoints <= 2) { - console.error('ring 점 개수가 부족합니다') - return - } - - if (edgeIndex < 0 || edgeIndex >= totalPoints) { - console.error(`edgeIndex ${edgeIndex}가 범위를 벗어났습니다`) - return - } - - // 안전한 점 접근 - const p1 = ring[edgeIndex] - const nextIndex = (edgeIndex + 1) % totalPoints - const p2 = ring[nextIndex] - - console.log(`p1 (index ${edgeIndex}):`, p1) - console.log(`p2 (index ${nextIndex}):`, p2) - - if (!Array.isArray(p1) || p1.length < 2 || !Array.isArray(p2) || p2.length < 2) { - console.error('점 형식이 잘못되었습니다') - console.error('p1:', p1, 'p2:', p2) - return - } - - if (typeof p1[0] !== 'number' || typeof p1[1] !== 'number' || typeof p2[0] !== 'number' || typeof p2[1] !== 'number') { - console.error('좌표값이 숫자가 아닙니다') - return - } - - try { - // 폴리곤 중심 계산 (마지막 중복점 제외) - const validPoints = ring.slice(0, totalPoints) - const centerX = validPoints.reduce((sum, p) => sum + p[0], 0) / totalPoints - const centerY = validPoints.reduce((sum, p) => sum + p[1], 0) / totalPoints - - console.log(`중심점: (${centerX}, ${centerY})`) - - // edge 중점 - const midX = (p1[0] + p2[0]) / 2 - const midY = (p1[1] + p2[1]) / 2 - - // 외향 방향 - const dirX = midX - centerX - const dirY = midY - centerY - const length = Math.sqrt(dirX * dirX + dirY * dirY) - - if (length < 0.001) { - // 거의 0인 경우 - console.warn('외향 방향 벡터 길이가 거의 0입니다, 확장하지 않습니다') - return - } - - const unitX = dirX / length - const unitY = dirY / length - - // 안전하게 점 수정 - ring[edgeIndex] = [p1[0] + unitX * overhang, p1[1] + unitY * overhang] - - ring[nextIndex] = [p2[0] + unitX * overhang, p2[1] + unitY * overhang] - - console.log(`✅ EAVES: Edge ${edgeIndex} 확장 완료 (${overhang})`) - console.log('수정된 p1:', ring[edgeIndex]) - console.log('수정된 p2:', ring[nextIndex]) - } catch (calcError) { - console.error('계산 중 오류:', calcError) - } - } - - /** - * ✅ 안전한 박공 조정 - */ - static adjustEdgeForGable(ring, edgeIndex, gableHeight) { - console.log(`adjustEdgeForGable 시작: edgeIndex=${edgeIndex}`) - - // 안전성 검증 (동일한 패턴) - if (!ring || !Array.isArray(ring)) { - console.error('ring이 배열이 아니거나 undefined입니다') - return - } - - const totalPoints = ring.length - 1 - - if (totalPoints <= 2) { - console.error('ring 점 개수가 부족합니다') - return - } - - if (edgeIndex < 0 || edgeIndex >= totalPoints) { - console.error(`edgeIndex ${edgeIndex}가 범위를 벗어났습니다`) - return - } - - const p1 = ring[edgeIndex] - const nextIndex = (edgeIndex + 1) % totalPoints - const p2 = ring[nextIndex] - - if (!Array.isArray(p1) || p1.length < 2 || !Array.isArray(p2) || p2.length < 2) { - console.error('점 형식이 잘못되었습니다') - return - } - - try { - const validPoints = ring.slice(0, totalPoints) - const centerX = validPoints.reduce((sum, p) => sum + p[0], 0) / totalPoints - const centerY = validPoints.reduce((sum, p) => sum + p[1], 0) / totalPoints - - console.log(`중심점: (${centerX}, ${centerY})`) - - // edge 중점 - const midX = (p1[0] + p2[0]) / 2 - const midY = (p1[1] + p2[1]) / 2 - - // 외향 방향 - const dirX = centerX - midX - const dirY = centerY - midY - const length = Math.sqrt(dirX * dirX + dirY * dirY) - - if (length < 0.001) { - console.warn('중심 방향 벡터 길이가 거의 0입니다') - return - } - - const unitX = dirX / length - const unitY = dirY / length - const insetDistance = 0.5 - - const newMidX = midX + unitX * insetDistance - const newMidY = midY + unitY * insetDistance - - const edgeVecX = p2[0] - p1[0] - const edgeVecY = p2[1] - p1[1] - - ring[edgeIndex] = [newMidX - edgeVecX * 0.5, newMidY - edgeVecY * 0.5] - - ring[nextIndex] = [newMidX + edgeVecX * 0.5, newMidY + edgeVecY * 0.5] - - console.log(`✅ GABLE: Edge ${edgeIndex} 조정 완료`) - } catch (calcError) { - console.error('박공 조정 계산 중 오류:', calcError) - } - } - - static processGableSkeleton(skeleton, gableEdgeIndex, originalPolygon) { - // ✅ Gable edge에 해당하는 skeleton 정점들을 찾아서 - // 해당 edge의 중점으로 강제 이동 - - const gableEdge = originalPolygon[gableEdgeIndex] - const edgeMidpoint = calculateMidpoint(gableEdge) - - // skeleton 정점들을 edge 중점으로 "압축" - skeleton.Edges.forEach((edge) => { - if (isRelatedToGableEdge(edge, gableEdgeIndex)) { - // 해당 edge 관련 skeleton 정점들을 직선으로 정렬 - straightenSkeletonToEdge(edge, edgeMidpoint) - } - }) - } - - // ✅ Gable edge에 제약 조건을 추가하여 skeleton 생성 - static buildConstrainedSkeleton(polygon, edgeConstraints) { - const constraints = edgeConstraints - .map((constraint) => { - if (constraint.type === 'GABLE') { - return { - edgeIndex: constraint.edgeIndex, - forceLinear: true, // 직선 강제 - fixToMidpoint: true, // 중점 고정 - } - } - return null - }) - .filter((c) => c !== null) - - // 제약 조건이 적용된 skeleton 생성 - return SkeletonBuilder.build(polygon, constraints) - } -} - -/** - * 지붕 폴리곤의 좌표를 스켈레톤 생성에 적합한 형태로 전처리합니다. - * - 연속된 중복 좌표를 제거합니다. - * - 폴리곤이 닫힌 형태가 되도록 마지막 좌표를 확인하고 필요시 제거합니다. - * - 좌표를 시계 방향으로 정렬합니다. - * @param {Array} initialPoints - 초기 폴리곤 좌표 배열 (e.g., [{x: 10, y: 10}, ...]) + * 폴리곤 좌표를 스켈레톤 생성에 적합하게 전처리합니다 (중복 제거, 시계 방향 정렬). + * @param {Array} initialPoints - 초기 폴리곤 좌표 배열 * @returns {Array>} 전처리된 좌표 배열 (e.g., [[10, 10], ...]) */ const preprocessPolygonCoordinates = (initialPoints) => { - // fabric.Point 객체를 [x, y] 배열로 변환 let coordinates = initialPoints.map(point => [point.x, point.y]); - - // 연속된 중복 좌표 제거 coordinates = coordinates.filter((coord, index) => { if (index === 0) return true; const prev = coordinates[index - 1]; return !(coord[0] === prev[0] && coord[1] === prev[1]); }); - - // 폴리곤의 첫 점과 마지막 점이 동일하면 마지막 점을 제거하여 닫힌 구조 보장 - if (coordinates.length > 1 && - coordinates[0][0] === coordinates[coordinates.length - 1][0] && - coordinates[0][1] === coordinates[coordinates.length - 1][1]) { + if (coordinates.length > 1 && coordinates[0][0] === coordinates[coordinates.length - 1][0] && coordinates[0][1] === coordinates[coordinates.length - 1][1]) { coordinates.pop(); } - - // SkeletonBuilder 라이브러리는 시계 방향 순서의 좌표를 요구하므로 배열을 뒤집습니다. - coordinates.reverse(); - - return coordinates; + return coordinates.reverse(); }; +/** + * 스켈레톤 Edge와 외벽선이 동일한지 확인합니다. + * @returns {boolean} 동일 여부 + */ const isSameLine = (edgeStartX, edgeStartY, edgeEndX, edgeEndY, baseLine) => { - const tolerance = 0.1 + const tolerance = 0.1; + const { x1, y1, x2, y2 } = baseLine; + const forwardMatch = Math.abs(edgeStartX - x1) < tolerance && Math.abs(edgeStartY - y1) < tolerance && Math.abs(edgeEndX - x2) < tolerance && Math.abs(edgeEndY - y2) < tolerance; + const backwardMatch = Math.abs(edgeStartX - x2) < tolerance && Math.abs(edgeStartY - y2) < tolerance && Math.abs(edgeEndX - x1) < tolerance && Math.abs(edgeEndY - y1) < tolerance; + return forwardMatch || backwardMatch; +}; - // 시계방향 매칭 (edgeResult.Edge.Begin -> End와 baseLine.x1 -> x2) - const clockwiseMatch = - Math.abs(edgeStartX - baseLine.x1) < tolerance && - Math.abs(edgeStartY - baseLine.y1) < tolerance && - Math.abs(edgeEndX - baseLine.x2) < tolerance && - Math.abs(edgeEndY - baseLine.y2) < tolerance - - // 반시계방향 매칭 (edgeResult.Edge.Begin -> End와 baseLine.x2 -> x1) - const counterClockwiseMatch = - Math.abs(edgeStartX - baseLine.x2) < tolerance && - Math.abs(edgeStartY - baseLine.y2) < tolerance && - Math.abs(edgeEndX - baseLine.x1) < tolerance && - Math.abs(edgeEndY - baseLine.y1) < tolerance - - return clockwiseMatch || counterClockwiseMatch -} +// --- Disconnected Line Processing --- /** - * skeletonLines에서 특정 점(polyPoint)을 지나는 라인을 찾는 함수 - * @param {Array} skeletonLines - 검색할 라인 배열 - * @param {Object} polyPoint - 검색할 점 {X, Y} - * @returns {Array} - 일치하는 라인 배열 - */ -function findLinesPassingPoint(skeletonLines, polyPoint) { - return skeletonLines.filter(line => { - // 라인의 시작점이나 끝점이 polyPoint와 일치하는지 확인 - const isP1Match = (Math.abs(line.p1.x - polyPoint.X) < 0.001 && - Math.abs(line.p1.y - polyPoint.Y) < 0.001); - const isP2Match = (Math.abs(line.p2.x - polyPoint.X) < 0.001 && - Math.abs(line.p2.y - polyPoint.Y) < 0.001); - - return isP1Match || isP2Match; - }); -} - -// 두 선분의 교차점을 찾는 함수 -// 두 선분의 교차점을 찾는 함수 (개선된 버전) -function findIntersection(p1, p2, p3, p4) { - // 선분1: p1 -> p2 - // 선분2: p3 -> p4 - - // 선분 방향 벡터 - const d1x = p2.x - p1.x; - const d1y = p2.y - p1.y; - const d2x = p4.x - p3.x; - const d2y = p4.y - p3.y; - - // 분모 계산 - const denominator = d1x * d2y - d1y * d2x; - - // 평행한 경우 (또는 매우 가까운 경우) - // if (Math.abs(denominator) < 0.0001) { - // return null; - // } - - // 매개변수 t와 u 계산 - const t = ((p3.x - p1.x) * d2y - (p3.y - p1.y) * d2x) / denominator; - const u = ((p3.x - p1.x) * d1y - (p3.y - p1.y) * d1x) / denominator; - - // 두 선분이 교차하는지 확인 (0 <= t <= 1, 0 <= u <= 1) - if (t >= -0.001 && t <= 1.001 && u >= -0.001 && u <= 1.001) { - // 교차점 계산 - const x = p1.x + t * d1x; - const y = p1.y + t * d1y; - return { x, y }; - } - - // 교차하지 않는 경우 - return null; -} - - -// baseLine 좌표 추출 헬퍼 함수 -const extractBaseLineCoordinates = (baseLine) => { - const left = baseLine.left || 0; - const top = baseLine.top || 0; - const width = baseLine.width || 0; - const height = baseLine.height || 0; - - // 수평선인 경우 (height가 0에 가까움) - if (Math.abs(height) < 0.1) { - return { - p1: { x: left, y: top }, - p2: { x: left + width, y: top } - }; - } - // 수직선인 경우 (width가 0에 가까움) - else if (Math.abs(width) < 0.1) { - return { - p1: { x: left, y: top }, - p2: { x: left, y: top + height } - }; - } - // 기타 경우 (기본값) - else { - return { - p1: { x: left, y: top }, - p2: { x: left + width, y: top + height } - }; - } -}; - -// 연결이 끊어진 라인들을 찾는 함수 -export const findDisconnectedSkeletonLines = (skeletonLines, baseLines, options = {}) => { - const { - includeDiagonal = true, - includeStraight = true, - minLength = 0 - } = options; - - if (!skeletonLines?.length) { - return { - disconnectedLines: [], - diagonalLines: [], - straightLines: [], - statistics: { total: 0, diagonal: 0, straight: 0, disconnected: 0 } - }; - } - - const disconnectedLines = []; - const diagonalLines = []; - const straightLines = []; - - // 점 일치 확인 헬퍼 함수 - const pointsEqual = (p1, p2, epsilon = 0.1) => { - return Math.abs(p1.x - p2.x) < epsilon && Math.abs(p1.y - p2.y) < epsilon; - }; - - // baseLine 좌표 추출 - const extractBaseLineCoordinates = (baseLine) => { - const left = baseLine.left || 0; - const top = baseLine.top || 0; - const width = baseLine.width || 0; - const height = baseLine.height || 0; - - if (Math.abs(height) < 0.1) { - return { p1: { x: left, y: top }, p2: { x: left + width, y: top } }; - } else if (Math.abs(width) < 0.1) { - return { p1: { x: left, y: top }, p2: { x: left, y: top + height } }; - } else { - return { p1: { x: left, y: top }, p2: { x: left + width, y: top + height } }; - } - }; - - // baseLine에 점이 있는지 확인 - const isPointOnBase = (point) => { - return baseLines?.some(baseLine => { - const coords = extractBaseLineCoordinates(baseLine); - return pointsEqual(point, coords.p1) || pointsEqual(point, coords.p2); - }) || false; - }; - - // baseLine과 교차하는지 확인 - const isIntersectingWithBase = (skeletonLine) => { - return baseLines?.some(baseLine => { - const coords = extractBaseLineCoordinates(baseLine); - const intersection = findIntersection( - skeletonLine.p1.x, skeletonLine.p1.y, skeletonLine.p2.x, skeletonLine.p2.y, - coords.p1.x, coords.p1.y, coords.p2.x, coords.p2.y - ); - return intersection !== null; - }) || false; - }; - - // 라인 타입 확인 - const getLineType = (p1, p2) => { - const dx = Math.abs(p2.x - p1.x); - const dy = Math.abs(p2.y - p1.y); - const tolerance = 0.1; - - if (dy < tolerance) return 'horizontal'; - if (dx < tolerance) return 'vertical'; - return 'diagonal'; - }; - - // 라인 길이 계산 - const getLineLength = (p1, p2) => { - return Math.sqrt(Math.pow(p2.x - p1.x, 2) + Math.pow(p2.y - p1.y, 2)); - }; - - /** - * 연결 상태 확인 함수 - * - * @param {Object} line - 검사할 skeletonLine (p1, p2) - * @param {number} lineIndex - 현재 라인의 인덱스 - * @returns {{isConnected: boolean, p1Connected: boolean, p2Connected: boolean}} 연결되어 있으면 true, 끊어져 있으면 false - * - * 연결 판단 기준: - * 1. p1이 baseLine과 연결되어 있는지 확인 - * 2. p1이 연결되어 있으면 p2가 skeletonLine과 연결되어 있는지 확인 - * 3. p1이 연결되어 있지 않으면 p2가 baseLine과 연결되어 있는지 확인 - * 4. p2도 연결되어 있지 않으면 p1과 p2가 skeletonLine과 연결되어 있는지 확인 - */ - const isConnected = (line, lineIndex) => { - const result= { - isConnected: false, - p1Connected: false, - p2Connected: false, - extendedLine: [] - } - const { p1, p2 } = line; - - // 1. p1이 baseLine과 연결되어 있는지 확인 - const isP1OnBase = isPointOnBase(p1); - const isP2OnBase = isPointOnBase(p2); - - if (isP1OnBase || isP2OnBase) { - - - // 2. p1 또는 p2가 baseLine과 연결되어 있음 - // -> 연결되지 않은 점이 skeletonLine과 연결되어 있는지 확인 - for (let i = 0; i < skeletonLines.length; i++) { - if (i === lineIndex) continue; // 자기 자신은 제외 - - const otherLine = skeletonLines[i]; - const otherP1 = otherLine.p1; - const otherP2 = otherLine.p2; - - // p2가 다른 skeletonLine의 p1 또는 p2와 일치하는지 확인 - if (pointsEqual(p2, otherP1) || pointsEqual(p2, otherP2)) { - result.p1Connected = true; - result.p2Connected = true; - result.isConnected = true; - // p2가 연결되어 있으므로 전체 라인이 연결됨 - }else{ - result.p1Connected = true; - result.p2Connected = false; - result.isConnected = false; - } - - if (pointsEqual(p1, otherP1) || pointsEqual(p1, otherP2)) { - result.p1Connected = true; - result.p2Connected = true; - // p1이 다른 skeletonLine의 p1 또는 p2와 일치하는지 확인 - result.isConnected = true; - // p1이 연결되어 있으므로 전체 라인이 연결됨 - }else{ - result.p1Connected = true; - result.p2Connected = false; - result.isConnected = false; - } - - } - return result; - - } else { - // 3. p1과 p2 모두 baseLine과 연결되어 있지 않음 - // -> p1과 p2가 skeletonLine과 연결되어 있는지 확인 - let p1Connected = false; // p1이 skeletonLine과 연결되어 있는지 - let p2Connected = false; // p2가 skeletonLine과 연결되어 있는지 - - - for (let i = 0; i < skeletonLines.length; i++) { - if (i === lineIndex) continue; // 자기 자신은 제외 - - const otherLine = skeletonLines[i]; - const otherP1 = otherLine.p1; - const otherP2 = otherLine.p2; - - // p1이 다른 skeletonLine의 p1 또는 p2와 일치하는지 확인 - if (!p1Connected && (pointsEqual(p1, otherP1) || pointsEqual(p1, otherP2))) { - p1Connected = true; - result.p1Connected = true; - }else{ - // p1이 skeletonLine과 연결되지 않음 - baseLine까지 연장 - result.extendedLine = extendToBaseLine(p1, baseLines); - } - -// p2가 다른 skeletonLine의 p1 또는 p2와 일치하는지 확인 - if (!p2Connected && (pointsEqual(p2, otherP1) || pointsEqual(p2, otherP2))) { - p2Connected = true; - result.p2Connected = true; - }else{ - // p2가 skeletonLine과 연결되지 않음 - baseLine까지 연장 - result.extendedLine = extendToBaseLine(p1, baseLines); - } - - // p1과 p2가 모두 연결되어 있으면 전체 라인이 연결됨 - if (p1Connected && p2Connected) { - result.isConnected = true; - } - - - } - - return result - } - }; - - // 각 라인 분석 - skeletonLines.forEach((line, index) => { - const { p1, p2 } = line; - const length = getLineLength(p1, p2); - const type = getLineType(p1, p2); - - if (length < minLength) return; - - const connected = isConnected(line, index); - const extendedLine = connected.extendedLine; - const p1Connected = connected.p1Connected; - const p2Connected = connected.p2Connected; - - if (type === 'diagonal') { - diagonalLines.push({ line, index, length, type, connected}); - } else { - straightLines.push({ line, index, length, type, connected }); - } - - if (!connected.isConnected) { - disconnectedLines.push({ - line, index, length, type, - isDiagonal: type === 'diagonal', - isHorizontal: type === 'horizontal', - isVertical: type === 'vertical', - p1Connected: p1Connected, - p2Connected: p2Connected, - extendedLine: extendedLine, - - - - }); - } - }); - - const filteredDisconnected = includeDiagonal && includeStraight - ? disconnectedLines - : disconnectedLines.filter(item => - (includeDiagonal && item.isDiagonal) || - (includeStraight && (item.isHorizontal || item.isVertical)) - ); - - return { - disconnectedLines: filteredDisconnected, - diagonalLines: includeDiagonal ? diagonalLines : [], - straightLines: includeStraight ? straightLines : [], - statistics: { - total: skeletonLines.length, - diagonal: diagonalLines.length, - straight: straightLines.length, - disconnected: filteredDisconnected.length - } - }; -}; -const extendToBaseLine = (point, baseLines) => { - // point에서 가장 가까운 baseLine을 찾아서 연장 - let closestBaseLine = null; - let minDistance = Infinity; - - for (const baseLine of baseLines) { - // point와 baseLine 사이의 거리 계산 - const distance = getDistanceToLine(point, baseLine); - - if (distance < minDistance) { - minDistance = distance; - closestBaseLine = baseLine; - } - } - - if (closestBaseLine) { - // point에서 closestBaseLine으로 연장하는 라인 생성 - // 연장된 라인을 skeletonLines에 추가 - return { - p1: point, - p2: getProjectionPoint(point, closestBaseLine) - } - } -}; -/** - * 점과 선분 사이의 거리를 계산하는 함수 - * @param {Object} point - 거리를 계산할 점 {x, y} - * @param {Object} line - 선분 {x1, y1, x2, y2} - * @returns {number} 점과 선분 사이의 최단 거리 - */ -const getDistanceToLine = (point, line) => { - const { x: px, y: py } = point; - const { x1, y1, x2, y2 } = line; - - // 선분의 방향 벡터 - const dx = x2 - x1; - const dy = y2 - y1; - const lineLength = Math.sqrt(dx * dx + dy * dy); - - if (lineLength === 0) { - // 선분이 점인 경우 - return Math.sqrt((px - x1) * (px - x1) + (py - y1) * (py - y1)); - } - - // 선분의 단위 방향 벡터 - const ux = dx / lineLength; - const uy = dy / lineLength; - - // 점에서 선분 시작점으로의 벡터 - const vx = px - x1; - const vy = py - y1; - - // 투영된 길이 (스칼라 투영) - const projectionLength = vx * ux + vy * uy; - - // 투영점이 선분 범위 내에 있는지 확인 - if (projectionLength >= 0 && projectionLength <= lineLength) { - // 투영점이 선분 내에 있음 - const distance = Math.sqrt(vx * vx + vy * vy - projectionLength * projectionLength); - return distance; - } else { - // 투영점이 선분 밖에 있음 - 끝점까지의 거리 중 작은 값 - const distToStart = Math.sqrt(vx * vx + vy * vy); - const distToEnd = Math.sqrt((px - x2) * (px - x2) + (py - y2) * (py - y2)); - return Math.min(distToStart, distToEnd); - } -}; - -/** - * 점을 선분에 투영한 점을 반환하는 함수 - * @param {Object} point - 투영할 점 {x, y} - * @param {Object} line - 선분 {x1, y1, x2, y2} - * @returns {Object} 투영된 점 {x, y} + * 점을 선분에 투영한 점의 좌표를 반환합니다. + * @param {object} point - 투영할 점 {x, y} + * @param {object} line - 기준 선분 {x1, y1, x2, y2} + * @returns {object} 투영된 점의 좌표 {x, y} */ const getProjectionPoint = (point, line) => { const { x: px, y: py } = point; const { x1, y1, x2, y2 } = line; - - // 선분의 방향 벡터 const dx = x2 - x1; const dy = y2 - y1; - const lineLength = Math.sqrt(dx * dx + dy * dy); + const lineLengthSq = dx * dx + dy * dy; - if (lineLength === 0) { - // 선분이 점인 경우 - return { x: x1, y: y1 }; + if (lineLengthSq === 0) return { x: x1, y: y1 }; + + const t = ((px - x1) * dx + (py - y1) * dy) / lineLengthSq; + if (t < 0) return { x: x1, y: y1 }; + if (t > 1) return { x: x2, y: y2 }; + + return { x: x1 + t * dx, y: y1 + t * dy }; +}; + + +/** + * 광선(Ray)과 선분(Segment)의 교차점을 찾습니다. + * @param {object} rayStart - 광선의 시작점 + * @param {object} rayDir - 광선의 방향 벡터 + * @param {object} segA - 선분의 시작점 + * @param {object} segB - 선분의 끝점 + * @returns {{point: object, t: number}|null} 교차점 정보 또는 null + */ +function getRayIntersectionWithSegment(rayStart, rayDir, segA, segB) { + const p = rayStart; + const r = rayDir; + const q = segA; + const s = { x: segB.x - segA.x, y: segB.y - segA.y }; + + const rxs = r.x * s.y - r.y * s.x; + if (Math.abs(rxs) < 1e-6) return null; // 평행 + + const q_p = { x: q.x - p.x, y: q.y - p.y }; + const t = (q_p.x * s.y - q_p.y * s.x) / rxs; + const u = (q_p.x * r.y - q_p.y * r.x) / rxs; + + if (t >= -1e-6 && u >= -1e-6 && u <= 1 + 1e-6) { + return { point: { x: p.x + t * r.x, y: p.y + t * r.y }, t }; + } + return null; +} + +/** + * 한 점에서 다른 점 방향으로 광선을 쏘아 가장 가까운 교차점을 찾습니다. + * @param {object} p1 - 광선의 방향을 결정하는 끝점 + * @param {object} p2 - 광선의 시작점 + * @param {Array} baseLines - 외벽선 배열 + * @param {Array} skeletonLines - 스켈레톤 라인 배열 + * @param {number} excludeIndex - 검사에서 제외할 현재 라인의 인덱스 + * @returns {object|null} 가장 가까운 교차점 정보 또는 null + */ +function extendFromP2TowardP1(p1, p2, baseLines, skeletonLines, excludeIndex) { + const dirVec = { x: p1.x - p2.x, y: p1.y - p2.y }; + const len = Math.sqrt(dirVec.x * dirVec.x + dirVec.y * dirVec.y) || 1; + const dir = { x: dirVec.x / len, y: dirVec.y / len }; + let closestHit = null; + + const checkHit = (hit) => { + if (hit && hit.t > len - 0.1) { // 원래 선분의 끝점(p1) 너머에서 교차하는지 확인 + if (!closestHit || hit.t < closestHit.t) { + closestHit = hit; + } + } + }; + + if (Array.isArray(baseLines)) { + baseLines.forEach(baseLine => { + const hit = getRayIntersectionWithSegment(p2, dir, { x: baseLine.x1, y: baseLine.y1 }, { x: baseLine.x2, y: baseLine.y2 }); + checkHit(hit); + }); } - // 선분의 단위 방향 벡터 - const ux = dx / lineLength; - const uy = dy / lineLength; - - // 점에서 선분 시작점으로의 벡터 - const vx = px - x1; - const vy = py - y1; - - // 투영된 길이 (스칼라 투영) - const projectionLength = vx * ux + vy * uy; - - // 투영점이 선분 범위 내에 있는지 확인 - if (projectionLength >= 0 && projectionLength <= lineLength) { - // 투영점이 선분 내에 있음 - const projX = x1 + ux * projectionLength; - const projY = y1 + uy * projectionLength; - return { x: projX, y: projY }; - } else if (projectionLength < 0) { - // 투영점이 시작점 앞에 있음 - return { x: x1, y: y1 }; - } else { - // 투영점이 끝점 뒤에 있음 - return { x: x2, y: y2 }; + if (Array.isArray(skeletonLines)) { + skeletonLines.forEach((seg, i) => { + if (i === excludeIndex) return; + const hit = getRayIntersectionWithSegment(p2, dir, seg.p1, seg.p2); + checkHit(hit); + }); } -}; \ No newline at end of file + + return closestHit; +} + +/** + * 연결이 끊어진 스켈레톤 라인들을 찾아 연장 정보를 계산합니다. + * @param {Array} skeletonLines - 스켈레톤 라인 배열 + * @param {Array} baseLines - 외벽선 배열 + * @returns {object} 끊어진 라인 정보가 담긴 객체 + */ +export const findDisconnectedSkeletonLines = (skeletonLines, baseLines) => { + if (!skeletonLines?.length) return { disconnectedLines: [] }; + + const disconnectedLines = []; + const pointsEqual = (p1, p2, epsilon = 0.1) => Math.abs(p1.x - p2.x) < epsilon && Math.abs(p1.y - p2.y) < epsilon; + + const isPointOnBase = (point) => + baseLines?.some(baseLine => { + const { x1, y1, x2, y2 } = baseLine; + if (pointsEqual(point, { x: x1, y: y1 }) || pointsEqual(point, { x: x2, y: y2 })) return true; + const dist = Math.sqrt(Math.pow(x2 - x1, 2) + Math.pow(y2 - y1, 2)); + const dist1 = Math.sqrt(Math.pow(point.x - x1, 2) + Math.pow(point.y - y1, 2)); + const dist2 = Math.sqrt(Math.pow(point.x - x2, 2) + Math.pow(point.y - y2, 2)); + return Math.abs(dist - (dist1 + dist2)) < 0.1; + }) || false; + + const isConnected = (line, lineIndex) => { + const { p1, p2 } = line; + let p1Connected = isPointOnBase(p1); + let p2Connected = isPointOnBase(p2); + + if (!p1Connected || !p2Connected) { + for (let i = 0; i < skeletonLines.length; i++) { + if (i === lineIndex) continue; + const other = skeletonLines[i]; + if (!p1Connected && (pointsEqual(p1, other.p1) || pointsEqual(p1, other.p2))) p1Connected = true; + if (!p2Connected && (pointsEqual(p2, other.p1) || pointsEqual(p2, other.p2))) p2Connected = true; + if (p1Connected && p2Connected) break; + } + } + return { p1Connected, p2Connected }; + }; + + skeletonLines.forEach((line, index) => { + const { p1Connected, p2Connected } = isConnected(line, index); + if (p1Connected && p2Connected) return; + + let extendedLine = null; + if (!p1Connected) { + extendedLine = extendFromP2TowardP1(line.p1, line.p2, baseLines, skeletonLines, index); + + // [수정] 1차 연장 시도(Raycast) 실패 시, 수직 투영(Projection) 대신 모든 선분과의 교차점을 찾는 방식으로 변경 + if (!extendedLine) { + let closestIntersection = null; + let minDistance = Infinity; + + // 모든 외벽선과 다른 내부선을 타겟으로 설정 + const allTargetLines = [ + ...baseLines.map(l => ({ p1: {x: l.x1, y: l.y1}, p2: {x: l.x2, y: l.y2} })), + ...skeletonLines.filter((_, i) => i !== index) + ]; + + allTargetLines.forEach(targetLine => { + // 무한 직선 간의 교차점을 찾음 + const intersection = getInfiniteLineIntersection(line.p1, line.p2, targetLine.p1, targetLine.p2); + + // 교차점이 존재하고, 타겟 '선분' 위에 있는지 확인 + if (intersection && isPointOnSegmentForExtension(intersection, targetLine.p1, targetLine.p2)) { + // 연장 방향이 올바른지 확인 (뒤로 가지 않도록) + const lineVec = { x: line.p1.x - line.p2.x, y: line.p1.y - line.p2.y }; + const intersectVec = { x: intersection.x - line.p1.x, y: intersection.y - line.p1.y }; + const dotProduct = lineVec.x * intersectVec.x + lineVec.y * intersectVec.y; + + if (dotProduct >= -1e-6) { // 교차점이 p1 기준으로 '앞'에 있을 경우 + const dist = Math.sqrt(Math.pow(line.p1.x - intersection.x, 2) + Math.pow(line.p1.y - intersection.y, 2)); + if (dist > 0.1 && dist < minDistance) { // 자기 자신이 아니고, 가장 가까운 교차점 갱신 + minDistance = dist; + closestIntersection = intersection; + } + } + } + }); + + if (closestIntersection) { + extendedLine = { point: closestIntersection }; + } + } + } else if (!p2Connected) { + extendedLine = extendFromP2TowardP1(line.p2, line.p1, baseLines, skeletonLines, index); + + // [수정] 1차 연장 시도(Raycast) 실패 시, 수직 투영(Projection) 대신 모든 선분과의 교차점을 찾는 방식으로 변경 + if (!extendedLine) { + let closestIntersection = null; + let minDistance = Infinity; + + // 모든 외벽선과 다른 내부선을 타겟으로 설정 + const allTargetLines = [ + ...baseLines.map(l => ({ p1: {x: l.x1, y: l.y1}, p2: {x: l.x2, y: l.y2} })), + ...skeletonLines.filter((_, i) => i !== index) + ]; + + allTargetLines.forEach(targetLine => { + // 무한 직선 간의 교차점을 찾음 + const intersection = getInfiniteLineIntersection(line.p2, line.p1, targetLine.p1, targetLine.p2); + + // 교차점이 존재하고, 타겟 '선분' 위에 있는지 확인 + if (intersection && isPointOnSegmentForExtension(intersection, targetLine.p1, targetLine.p2)) { + // 연장 방향이 올바른지 확인 (뒤로 가지 않도록) + const lineVec = { x: line.p2.x - line.p1.x, y: line.p2.y - line.p1.y }; + const intersectVec = { x: intersection.x - line.p2.x, y: intersection.y - line.p2.y }; + const dotProduct = lineVec.x * intersectVec.x + lineVec.y * intersectVec.y; + + if (dotProduct >= -1e-6) { // 교차점이 p2 기준으로 '앞'에 있을 경우 + const dist = Math.sqrt(Math.pow(line.p2.x - intersection.x, 2) + Math.pow(line.p2.y - intersection.y, 2)); + if (dist > 0.1 && dist < minDistance) { // 자기 자신이 아니고, 가장 가까운 교차점 갱신 + minDistance = dist; + closestIntersection = intersection; + } + } + } + }); + + if (closestIntersection) { + extendedLine = { point: closestIntersection }; + } + } + } + + disconnectedLines.push({ line, index, p1Connected, p2Connected, extendedLine }); + }); + + return { disconnectedLines }; +}; + +/** + * 연장된 스켈레톤 라인들이 서로 교차하는 경우, 교차점에서 잘라냅니다. + * 이 함수는 skeletonLines 배열의 요소를 직접 수정하여 접점에서 선이 멈추도록 합니다. + * @param {Array} skeletonLines - (수정될) 전체 스켈레톤 라인 배열 + * @param {Array} disconnectedLines - 연장 정보가 담긴 배열 + */ +const trimIntersectingExtendedLines = (skeletonLines, disconnectedLines) => { + // disconnectedLines에는 연장된 선들의 정보가 들어있음 + for (let i = 0; i < disconnectedLines.length; i++) { + for (let j = i + 1; j < disconnectedLines.length; j++) { + const dLine1 = disconnectedLines[i]; + const dLine2 = disconnectedLines[j]; + + // skeletonLines 배열에서 직접 참조를 가져오므로, 여기서 line1, line2를 수정하면 + // 원본 skeletonLines 배열의 내용이 변경됩니다. + const line1 = skeletonLines[dLine1.index]; + const line2 = skeletonLines[dLine2.index]; + + if(!line1 || !line2) continue; + + // 두 연장된 선분이 교차하는지 확인 + const intersection = getLineIntersection(line1.p1, line1.p2, line2.p1, line2.p2); + + if (intersection) { + // 교차점이 있다면, 각 선의 연장된 끝점을 교차점으로 업데이트합니다. + // 이 변경 사항은 skeletonLines 배열에 바로 반영됩니다. + if (!dLine1.p1Connected) { // p1이 연장된 점이었으면 + line1.p1 = intersection; + } else { // p2가 연장된 점이었으면 + line1.p2 = intersection; + } + + if (!dLine2.p1Connected) { // p1이 연장된 점이었으면 + line2.p1 = intersection; + } else { // p2가 연장된 점이었으면 + line2.p2 = intersection; + } + } + } + } +} + +/** + * skeletonLines와 selectBaseLine을 이용하여 다각형이 되는 좌표를 구합니다. + * selectBaseLine의 좌표는 제외합니다. + * @param {Array} skeletonLines - 스켈레톤 라인 배열 + * @param {Object} selectBaseLine - 선택된 베이스 라인 (p1, p2 속성을 가진 객체) + * @returns {Array>} 다각형 좌표 배열의 배열 + */ +const createPolygonsFromSkeletonLines = (skeletonLines, selectBaseLine) => { + if (!skeletonLines?.length) return []; + + // 1. 모든 교차점 찾기 + const intersections = findAllIntersections(skeletonLines); + + // 2. 모든 포인트 수집 (엔드포인트 + 교차점) + const allPoints = collectAllPoints(skeletonLines, intersections); + + // 3. selectBaseLine 상의 점들 제외 + const filteredPoints = allPoints.filter(point => { + if (!selectBaseLine?.startPoint || !selectBaseLine?.endPoint) return true; + + // 점이 selectBaseLine 상에 있는지 확인 + return !isPointOnSegment( + point, + selectBaseLine.startPoint, + selectBaseLine.endPoint + ); + }); + +}; + +/** + * 두 무한 직선의 교차점을 찾습니다. (선분X) + * @param {object} p1 - 직선1의 점1 + * @param {object} p2 - 직선1의 점2 + * @param {object} p3 - 직선2의 점1 + * @param {object} p4 - 직선2의 점2 + * @returns {object|null} 교차점 좌표 또는 null (평행/동일선) + */ +const getInfiniteLineIntersection = (p1, p2, p3, p4) => { + const x1 = p1.x, y1 = p1.y; + const x2 = p2.x, y2 = p2.y; + const x3 = p3.x, y3 = p3.y; + const x4 = p4.x, y4 = p4.y; + + const denom = (x1 - x2) * (y3 - y4) - (y1 - y2) * (x3 - x4); + if (Math.abs(denom) < 1e-10) return null; // 평행 또는 동일선 + + const t = ((x1 - x3) * (y3 - y4) - (y1 - y3) * (x3 - x4)) / denom; + + return { + x: x1 + t * (x2 - x1), + y: y1 + t * (y2 - y1) + }; +}; + +/** + * 점이 선분 위에 있는지 확인합니다. (연장 로직용) + * @param {object} point - 확인할 점 + * @param {object} segStart - 선분 시작점 + * @param {object} segEnd - 선분 끝점 + * @param {number} tolerance - 허용 오차 + * @returns {boolean} 선분 위 여부 + */ +const isPointOnSegmentForExtension = (point, segStart, segEnd, tolerance = 0.1) => { + const dist = Math.sqrt(Math.pow(segEnd.x - segStart.x, 2) + Math.pow(segEnd.y - segStart.y, 2)); + const dist1 = Math.sqrt(Math.pow(point.x - segStart.x, 2) + Math.pow(point.y - segStart.y, 2)); + const dist2 = Math.sqrt(Math.pow(point.x - segEnd.x, 2) + Math.pow(point.y - segEnd.y, 2)); + return Math.abs(dist - (dist1 + dist2)) < tolerance; +}; + +/** + * 스켈레톤 라인들 간의 모든 교차점을 찾습니다. + * @param {Array} skeletonLines - 스켈레톤 라인 배열 (각 요소는 {p1: {x, y}, p2: {x, y}} 형태) + * @returns {Array} 교차점 배열 + */ +const findAllIntersections = (skeletonLines) => { + const intersections = []; + const processedPairs = new Set(); + + for (let i = 0; i < skeletonLines.length; i++) { + for (let j = i + 1; j < skeletonLines.length; j++) { + const pairKey = `${i}-${j}`; + if (processedPairs.has(pairKey)) continue; + processedPairs.add(pairKey); + + const line1 = skeletonLines[i]; + const line2 = skeletonLines[j]; + + // 두 라인이 교차하는지 확인 + const intersection = getLineIntersection( + line1.p1, line1.p2, + line2.p1, line2.p2 + ); + + if (intersection) { + // 교차점이 실제로 두 선분 위에 있는지 확인 + if (isPointOnSegment(intersection, line1.p1, line1.p2) && + isPointOnSegment(intersection, line2.p1, line2.p2)) { + intersections.push(intersection); + } + } + } + } + + return intersections; +}; + +/** + * 스켈레톤 라인들과 교차점들을 모아서 모든 포인트를 수집합니다. + * @param {Array} skeletonLines - 스켈레톤 라인 배열 + * @param {Array} intersections - 교차점 배열 + * @returns {Array} 모든 포인트 배열 + */ +const collectAllPoints = (skeletonLines, intersections) => { + const allPoints = new Map(); + const pointKey = (point) => `${point.x.toFixed(3)},${point.y.toFixed(3)}`; + + // 스켈레톤 라인의 엔드포인트들 추가 + skeletonLines.forEach(line => { + const key1 = pointKey(line.p1); + const key2 = pointKey(line.p2); + + if (!allPoints.has(key1)) { + allPoints.set(key1, { ...line.p1 }); + } + if (!allPoints.has(key2)) { + allPoints.set(key2, { ...line.p2 }); + } + }); + + // 교차점들 추가 + intersections.forEach(intersection => { + const key = pointKey(intersection); + if (!allPoints.has(key)) { + allPoints.set(key, { ...intersection }); + } + }); + + return Array.from(allPoints.values()); +}; + +// 필요한 유틸리티 함수들 +const getLineIntersection = (p1, p2, p3, p4) => { + const x1 = p1.x, y1 = p1.y; + const x2 = p2.x, y2 = p2.y; + const x3 = p3.x, y3 = p3.y; + const x4 = p4.x, y4 = p4.y; + + const denom = (x1 - x2) * (y3 - y4) - (y1 - y2) * (x3 - x4); + if (Math.abs(denom) < 1e-10) return null; + + const t = ((x1 - x3) * (y3 - y4) - (y1 - y3) * (x3 - x4)) / denom; + const u = -((x1 - x2) * (y1 - y3) - (y1 - y2) * (x1 - x3)) / denom; + + if (t >= 0 && t <= 1 && u >= 0 && u <= 1) { + return { + x: x1 + t * (x2 - x1), + y: y1 + t * (y2 - y1) + }; + } + + return null; +}; + +const isPointOnSegment = (point, segStart, segEnd) => { + const tolerance = 1e-6; + const crossProduct = (point.y - segStart.y) * (segEnd.x - segStart.x) - + (point.x - segStart.x) * (segEnd.y - segStart.y); + + if (Math.abs(crossProduct) > tolerance) return false; + + const dotProduct = (point.x - segStart.x) * (segEnd.x - segStart.x) + + (point.y - segStart.y) * (segEnd.y - segStart.y); + + const squaredLength = (segEnd.x - segStart.x) ** 2 + (segEnd.y - segStart.y) ** 2; + + return dotProduct >= 0 && dotProduct <= squaredLength; +}; + +// Export all necessary functions +export { + findAllIntersections, + collectAllPoints, + createPolygonsFromSkeletonLines +}; + From 9a2c6adb966a429cb1d68becdd3780a4460764bc Mon Sep 17 00:00:00 2001 From: ysCha Date: Tue, 30 Sep 2025 14:22:59 +0900 Subject: [PATCH 04/16] =?UTF-8?q?[big.js]=20Division=20by=20zero=20-=20act?= =?UTF-8?q?ualSize=20=EA=B0=80=200=EC=9D=B4=20=EB=90=98=EC=96=B4=20?= =?UTF-8?q?=EB=82=98=EB=88=84=EA=B8=B0=EC=97=90=20=EB=AC=B8=EC=A0=9C?= =?UTF-8?q?=EA=B0=80=20=EB=90=A8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/util/qpolygon-utils.js | 13 +++++++++++-- 1 file changed, 11 insertions(+), 2 deletions(-) diff --git a/src/util/qpolygon-utils.js b/src/util/qpolygon-utils.js index 15b9e569..33737856 100644 --- a/src/util/qpolygon-utils.js +++ b/src/util/qpolygon-utils.js @@ -7579,7 +7579,12 @@ export const drawRidgeRoof = (roofId, canvas, textMode) => { hipBasePoint = { x1: line.x1, y1: line.y1, x2: line.x2, y2: line.y2 } point = [mergePoint[0].x, mergePoint[0].y, mergePoint[3].x, mergePoint[3].y] - const theta = Big(Math.acos(Big(line.line.attributes.planeSize).div(line.line.attributes.actualSize))) + const theta = Big(Math.acos(Big(line.line.attributes.planeSize).div( + line.line.attributes.actualSize === 0 || + line.line.attributes.actualSize === '' || + line.line.attributes.actualSize === undefined ? + line.line.attributes.planeSize : line.line.attributes.actualSize + ))) .times(180) .div(Math.PI) .round(1) @@ -9223,7 +9228,11 @@ const getSortedPoint = (points, lines) => { const reCalculateSize = (line) => { const oldPlaneSize = line.attributes.planeSize const oldActualSize = line.attributes.actualSize - const theta = Big(Math.acos(Big(oldPlaneSize).div(oldActualSize))) + const theta = Big(Math.acos(Big(oldPlaneSize).div( + oldActualSize === 0 || oldActualSize === '' || oldActualSize === undefined ? + oldPlaneSize : + oldActualSize + ))) .times(180) .div(Math.PI) const planeSize = calcLinePlaneSize({ From 5f726bf5dba56db414448a6b2de616c2e14820a9 Mon Sep 17 00:00:00 2001 From: ysCha Date: Tue, 30 Sep 2025 14:25:31 +0900 Subject: [PATCH 05/16] =?UTF-8?q?input=20=EA=B3=84=EC=82=B0=EA=B8=B0?= =?UTF-8?q?=EC=B6=94=EA=B0=80(=EC=A0=84=EA=B0=81)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/components/common/input/CalcInput.jsx | 4 +- .../placementShape/PlacementShapeSetting.jsx | 85 +++++++++++++++---- 2 files changed, 73 insertions(+), 16 deletions(-) diff --git a/src/components/common/input/CalcInput.jsx b/src/components/common/input/CalcInput.jsx index 3da3a7c5..32323560 100644 --- a/src/components/common/input/CalcInput.jsx +++ b/src/components/common/input/CalcInput.jsx @@ -3,7 +3,7 @@ import { createCalculator } from '@/util/calc-utils' import '@/styles/calc.scss' export const CalculatorInput = forwardRef( - ({ value, onChange, label, options = {}, id, className = 'calculator-input', readOnly = false, placeholder }, ref) => { + ({ value, onChange, label, options = {}, id, className = 'calculator-input', readOnly = false, placeholder, name='', disabled = false }, ref) => { const [showKeypad, setShowKeypad] = useState(false) const [displayValue, setDisplayValue] = useState(value || '0') const [hasOperation, setHasOperation] = useState(false) @@ -353,6 +353,7 @@ export const CalculatorInput = forwardRef( ref={inputRef} type="text" id={id} + name={name} value={displayValue} readOnly={readOnly} className={className} @@ -363,6 +364,7 @@ export const CalculatorInput = forwardRef( tabIndex={readOnly ? -1 : 0} placeholder={placeholder} autoComplete={'off'} + disabled={disabled} /> {showKeypad && !readOnly && ( diff --git a/src/components/floor-plan/modal/placementShape/PlacementShapeSetting.jsx b/src/components/floor-plan/modal/placementShape/PlacementShapeSetting.jsx index a8ee7102..8b1f7dc8 100644 --- a/src/components/floor-plan/modal/placementShape/PlacementShapeSetting.jsx +++ b/src/components/floor-plan/modal/placementShape/PlacementShapeSetting.jsx @@ -346,6 +346,7 @@ export default function PlacementShapeSetting({ id, pos = { x: 50, y: 180 }, pla /> */} W
- changeInput(normalizeDigits(e.target.value), e)}*/} + {/* readOnly={currentRoof?.widAuth === 'R'}*/} + {/* disabled={currentRoof?.roofSizeSet === '3'}*/} + {/*/>*/} + + changeInput(normalizeDigits(e.target.value), e)} + value={currentRoof?.width||0} + onChange={(value) => { + setCurrentRoof({ ...currentRoof, value }) + }} readOnly={currentRoof?.widAuth === 'R'} disabled={currentRoof?.roofSizeSet === '3'} + options={{ + allowNegative: false, + allowDecimal: false //(index !== 0), + }} />
@@ -429,15 +448,33 @@ export default function PlacementShapeSetting({ id, pos = { x: 50, y: 180 }, pla
L
- changeInput(normalizeDigits(e.target.value), e)}*/} + {/* readOnly={currentRoof?.lenAuth === 'R'}*/} + {/* disabled={currentRoof?.roofSizeSet === '3'}*/} + {/*/>*/} + + changeInput(normalizeDigits(e.target.value), e)} + value={currentRoof?.length||0} + onChange={(value) => { + setCurrentRoof({ ...currentRoof, value }) + }} readOnly={currentRoof?.lenAuth === 'R'} disabled={currentRoof?.roofSizeSet === '3'} + options={{ + allowNegative: false, + allowDecimal: false //(index !== 0), + }} />
@@ -465,16 +502,34 @@ export default function PlacementShapeSetting({ id, pos = { x: 50, y: 180 }, pla
{getMessage('hajebichi')}
- changeInput(normalizeDigits(e.target.value), e)}*/} + {/* readOnly={currentRoof?.roofPchAuth === 'R'}*/} + {/* disabled={currentRoof?.roofSizeSet === '3'}*/} + {/*/>*/} + changeInput(normalizeDigits(e.target.value), e)} + value={currentRoof?.hajebichi||0} + onChange={(value) => { + setCurrentRoof({ ...currentRoof, value }) + }} readOnly={currentRoof?.roofPchAuth === 'R'} disabled={currentRoof?.roofSizeSet === '3'} + options={{ + allowNegative: false, + allowDecimal: false //(index !== 0), + }} /> +
)} From b480345b24ef2880c9b168b1ae6610787c139415 Mon Sep 17 00:00:00 2001 From: ysCha Date: Tue, 30 Sep 2025 18:12:25 +0900 Subject: [PATCH 06/16] =?UTF-8?q?[1222]=EA=B2=AC=EC=A0=81=EC=9D=98=20?= =?UTF-8?q?=EC=A0=9C=ED=92=88=20=EC=A0=95=EB=B3=B4=20=ED=99=94=EB=A9=B4?= =?UTF-8?q?=EC=97=90=20=EB=8C=80=ED=95=B4=EC=84=9C=20-=20=EA=B7=B8?= =?UTF-8?q?=EB=A3=B9=EB=B3=84=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/components/estimate/Estimate.jsx | 36 +++++++++++++++++++++++++++- 1 file changed, 35 insertions(+), 1 deletion(-) diff --git a/src/components/estimate/Estimate.jsx b/src/components/estimate/Estimate.jsx index f6517960..d8b127fc 100644 --- a/src/components/estimate/Estimate.jsx +++ b/src/components/estimate/Estimate.jsx @@ -138,7 +138,27 @@ export default function Estimate({}) { updatedRes = [...res] } - setOriginDisplayItemList(res) + const groupByItemGroup = (items) => { + const grouped = items.reduce((acc, item) => { + const group = item.itemGroup || '기타'; + if (!acc[group]) { + acc[group] = { + label: group, + options: [] + }; + } + acc[group].options.push({ + value: item.itemId, + label: `${item.itemNo} - ${item.itemName}`, + ...item + }); + return acc; + }, {}); + + return Object.values(grouped); + }; + const groupedItems = groupByItemGroup(res); + setOriginDisplayItemList(groupedItems) setDisplayItemList(updatedRes) } }) @@ -153,6 +173,19 @@ export default function Estimate({}) { }) } + const groupStyles = { + groupHeading: (provided) => ({ + ...provided, + fontSize: '14px', + fontWeight: 'bold', + color: '#333', + backgroundColor: '#f5f5f5', + padding: '8px 12px', + marginBottom: '4px', + borderBottom: '2px solid #ddd' + }) + }; + useEffect(() => { // console.log('🚀 ~ Estimate ~ selectedPlan:', selectedPlan) if (selectedPlan) initEstimate(selectedPlan?.planNo?? currentPid) @@ -1998,6 +2031,7 @@ export default function Estimate({}) { classNamePrefix="custom" placeholder="Select" options={originDisplayItemList} + styles={groupStyles} onChange={(e) => { if (isObjectNotEmpty(e)) { onChangeDisplayItem(e.itemId, item.dispOrder, index, false) From 1aa6bc79a88abde7e23a7c181e83d25b730e8ca9 Mon Sep 17 00:00:00 2001 From: ysCha Date: Tue, 30 Sep 2025 18:19:16 +0900 Subject: [PATCH 07/16] =?UTF-8?q?=EA=B2=AC=EC=A0=81=EC=84=9C=20=EB=B0=8F?= =?UTF-8?q?=20=EB=B0=9C=EC=A0=84=EB=9F=89=EC=97=90=20=ED=8E=98=EC=9D=B4?= =?UTF-8?q?=EC=A7=80=EC=97=90=EC=84=9C=20canvas=EA=B0=80=20null=EC=9E=84..?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/hooks/option/useCanvasSetting.js | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/src/hooks/option/useCanvasSetting.js b/src/hooks/option/useCanvasSetting.js index 9b060fde..af3cee04 100644 --- a/src/hooks/option/useCanvasSetting.js +++ b/src/hooks/option/useCanvasSetting.js @@ -632,9 +632,12 @@ export function useCanvasSetting(executeEffect = true) { originHorizon: res.originHorizon, originVertical: res.originVertical, }) - canvas.setWidth(res.originHorizon) - canvas.setHeight(res.originVertical) - canvas.renderAll() + + if (canvas) { + canvas.setWidth(res.originHorizon) + canvas.setHeight(res.originVertical) + canvas.renderAll() + } /** 데이터 설정 */ setSettingModalFirstOptions({ From d32553416cb1da1ed2fd7afe72b8dea463b59a25 Mon Sep 17 00:00:00 2001 From: ysCha Date: Wed, 1 Oct 2025 10:14:15 +0900 Subject: [PATCH 08/16] =?UTF-8?q?[1309]=20=EB=AC=B8=EC=9D=98=EC=82=AC?= =?UTF-8?q?=ED=95=AD=203=20=EC=B9=B4=ED=85=8C=EA=B3=A0=EB=A6=AC=20?= =?UTF-8?q?=EB=AC=B8=EC=A0=9C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/components/community/modal/QnaRegModal.jsx | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/components/community/modal/QnaRegModal.jsx b/src/components/community/modal/QnaRegModal.jsx index d04f00fa..b26536e0 100644 --- a/src/components/community/modal/QnaRegModal.jsx +++ b/src/components/community/modal/QnaRegModal.jsx @@ -148,6 +148,8 @@ let fileCheck = false; + } else { + setHideSmFlg(true) } } From 320080e0c1804d690384bb8d37237f7897039aa7 Mon Sep 17 00:00:00 2001 From: ysCha Date: Wed, 1 Oct 2025 11:09:34 +0900 Subject: [PATCH 09/16] =?UTF-8?q?=EB=AC=B8=EC=9D=98=EA=B2=8C=EC=8B=9C?= =?UTF-8?q?=ED=8C=90=20=EC=83=81=EC=84=B8=20api=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/components/community/modal/QnaDetailModal.jsx | 1 + 1 file changed, 1 insertion(+) diff --git a/src/components/community/modal/QnaDetailModal.jsx b/src/components/community/modal/QnaDetailModal.jsx index 0e25d3a8..760a65d9 100644 --- a/src/components/community/modal/QnaDetailModal.jsx +++ b/src/components/community/modal/QnaDetailModal.jsx @@ -25,6 +25,7 @@ export default function QnaDetailModal({ qnaNo, setOpen, qnaType }) { compCd : 5200, loginId : sessionState.userId, langCd : 'JA', + siteTpCd : 'QC', }) const apiUrl = `${url}?${params.toString()}` From 92fd17ed7192fbdef0ffce1f7690288754b911c4 Mon Sep 17 00:00:00 2001 From: ysCha Date: Tue, 14 Oct 2025 10:59:10 +0900 Subject: [PATCH 10/16] =?UTF-8?q?=EB=AC=B8=EC=9D=98=EA=B2=8C=EC=8B=9C?= =?UTF-8?q?=ED=8C=90=20=EC=A0=80=EC=9E=A5=EC=88=98=EC=A0=95=20=EA=B8=80?= =?UTF-8?q?=EC=9E=90=EB=B3=80=EA=B2=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../community/modal/QnaRegModal.jsx | 76 ++++++++++--------- src/locales/ja.json | 2 +- 2 files changed, 42 insertions(+), 36 deletions(-) diff --git a/src/components/community/modal/QnaRegModal.jsx b/src/components/community/modal/QnaRegModal.jsx index b26536e0..8f9437f9 100644 --- a/src/components/community/modal/QnaRegModal.jsx +++ b/src/components/community/modal/QnaRegModal.jsx @@ -22,7 +22,8 @@ export default function QnaRegModal({ setOpen, setReload, searchValue, selectPag const [sessionState, setSessionState] = useRecoilState(sessionStore) const globalLocaleState = useRecoilValue(globalLocaleStore) const [files, setFiles] = useState([]) - const [qnaData, setQnaData] = useState([]) + //const [qnaData, setQnaData] = useState([]) + const [qnaData, setQnaData] = useState({}) const [closeMdFlg, setCloseMdFlg] = useState(true) const [closeSmFlg, setCloseSmFlg] = useState(true) const [hideSmFlg, setHideSmFlg] = useState(false) @@ -44,6 +45,10 @@ export default function QnaRegModal({ setOpen, setReload, searchValue, selectPag const [isBtnDisable, setIsBtnDisable] = useState(false); const { promiseGet, post, promisePost } = useAxios(globalLocaleState) + useEffect(() => { + console.log('qnaData updated:', qnaData); + }, [qnaData]); + let fileCheck = false; const regPhoneNumber = (e) => { const result = e.target.value @@ -80,14 +85,16 @@ let fileCheck = false; //setQnaData([]) setQnaData({ - ...qnaData, compCd: "5200", siteTpCd: "QC", schNoticeClsCd: "QNA", - regId: sessionState.userId, - storeId: sessionState.userId, - qstMail : sessionState.email - }) + regId: sessionState?.userId || '', + storeId: sessionState?.userId || '', + qstMail: sessionState?.email || '', + qnaClsLrgCd: '', + qnaClsMidCd: '', + qnaClsSmlCd: '' + }); const codeL = findCommonCode(204200) if (codeL != null) { @@ -119,43 +126,42 @@ let fileCheck = false; } const onChangeQnaTypeM = (e) => { + if (!e?.clCode) return; - if(e === undefined || e === null) return; - const codeS = findCommonCode(204400) - if (codeS != null) { - - let codeList = [] - - codeS.map((item) => { - - if (item.clRefChr1 === e.clCode) { - codeList.push(item); - - } - }) - - - setQnaData({ ...qnaData, qnaClsMidCd: e.clCode }) - setCloseSmFlg(false) - setQnaTypeSmCodeList(codeList) - qnaTypeSmCodeRef.current?.setValue(); - - if(codeList.length > 0) { - setHideSmFlg(false) - }else{ - setHideSmFlg(true) - } - + // 중분류 코드 업데이트 + setQnaData(prevState => ({ + ...prevState, + qnaClsMidCd: e.clCode, + // 소분류는 초기화 (새로 선택하도록) + qnaClsSmlCd: '' + })); + // 소분류 코드 목록 설정 + const codeS = findCommonCode(204400); + if (codeS) { + const filteredCodeList = codeS.filter(item => item.clRefChr1 === e.clCode); + setQnaTypeSmCodeList(filteredCodeList); + // 소분류가 있으면 초기화, 없으면 숨김 + const hasSubCategories = filteredCodeList.length > 0; + setCloseSmFlg(!hasSubCategories); + setHideSmFlg(!hasSubCategories); } else { setHideSmFlg(true) } - } + // 소분류 선택기 초기화 + qnaTypeSmCodeRef.current?.setValue(); + }; + + const onChangeQnaTypeS = (e) => { - if(e === undefined || e === null) return; - setQnaData({ ...qnaData, qnaClsSmlCd:e.clCode}) + if (!e?.clCode) return; + + setQnaData(prevState => ({ + ...prevState, + qnaClsSmlCd: e.clCode + })); } const onFileSave = () => { diff --git a/src/locales/ja.json b/src/locales/ja.json index 2999728f..db27c2ef 100644 --- a/src/locales/ja.json +++ b/src/locales/ja.json @@ -614,7 +614,7 @@ "qna.sub.title": "お問合せリスト", "qna.reg.header.regDt": "お問い合わせ登録日", "qna.reg.header.regUserNm": "名前", - "qna.reg.header.regUserTelNo": "お問い合わせ", + "qna.reg.header.regUserTelNo": "電話番号", "qna.reg.header.type": "お問い合わせ区分", "qna.reg.header.title": "お問い合わせタイトル", "qna.reg.header.contents": "お問い合わせ内容", From 1385683bce44686fa0115b75ff52394598fd5580 Mon Sep 17 00:00:00 2001 From: ysCha Date: Tue, 14 Oct 2025 11:05:50 +0900 Subject: [PATCH 11/16] =?UTF-8?q?hipSize=20=EA=B0=84=ED=98=B9=20=EC=98=A4?= =?UTF-8?q?=EB=A5=98=EB=B0=9C=EC=83=9D=20-=20=EA=B8=B0=EB=B3=B8=EA=B0=92?= =?UTF-8?q?=200=20=EC=84=A4=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/util/qpolygon-utils.js | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/src/util/qpolygon-utils.js b/src/util/qpolygon-utils.js index 33737856..6e25c18d 100644 --- a/src/util/qpolygon-utils.js +++ b/src/util/qpolygon-utils.js @@ -7665,7 +7665,11 @@ export const drawRidgeRoof = (roofId, canvas, textMode) => { .filter((line) => (line.x2 === ridge.x1 && line.y2 === ridge.y1) || (line.x2 === ridge.x2 && line.y2 === ridge.y2)) .filter((line) => baseLines.filter((baseLine) => baseLine.x1 === line.x1 && baseLine.y1 === line.y1).length > 0) basePoints.sort((a, b) => a.line.attributes.planeSize - b.line.attributes.planeSize) - hipSize = Big(basePoints[0].line.attributes.planeSize) + if (basePoints.length > 0 && basePoints[0].line) { + hipSize = Big(basePoints[0].line.attributes.planeSize) + } else { + hipSize = Big(0) // 또는 기본값 설정 + } } hipSize = hipSize.pow(2).div(2).sqrt().round().div(10).toNumber() From 41de001986cf47a6cd84bfe2f8f7cd7389b8aedd Mon Sep 17 00:00:00 2001 From: "hyojun.choi" Date: Tue, 14 Oct 2025 13:58:15 +0900 Subject: [PATCH 12/16] =?UTF-8?q?=EC=88=9C=EC=84=9C=EC=97=90=20=EB=94=B0?= =?UTF-8?q?=EB=9D=BC=20abs=EC=A0=81=EC=9A=A9=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/hooks/useMode.js | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/src/hooks/useMode.js b/src/hooks/useMode.js index 69eb955c..6030f43f 100644 --- a/src/hooks/useMode.js +++ b/src/hooks/useMode.js @@ -1820,7 +1820,13 @@ export function useMode() { x: xDiff.eq(0) ? offsetCurrentPoint.x : nextWall.x1, y: yDiff.eq(0) ? offsetCurrentPoint.y : nextWall.y1, } - const diffOffset = Big(nextWall.attributes.offset).minus(Big(currentWall.attributes.offset)) + let diffOffset + if (nextWall.index > currentWall.index) { + diffOffset = Big(nextWall.attributes.offset).minus(Big(currentWall.attributes.offset)).abs() + } else { + diffOffset = Big(currentWall.attributes.offset).minus(Big(nextWall.attributes.offset)) + } + const offsetPoint2 = { x: yDiff.eq(0) ? offsetPoint1.x : Big(offsetPoint1.x).plus(diffOffset).toNumber(), y: xDiff.eq(0) ? offsetPoint1.y : Big(offsetPoint1.y).plus(diffOffset).toNumber(), From 8b2cf6a9d3cc431578d0bba3d732dc3029822756 Mon Sep 17 00:00:00 2001 From: "hyojun.choi" Date: Tue, 14 Oct 2025 14:16:02 +0900 Subject: [PATCH 13/16] =?UTF-8?q?=EC=8B=9C=EA=B3=84=EB=B0=A9=ED=96=A5=20?= =?UTF-8?q?=EB=8C=80=EC=9D=91=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/hooks/roofcover/useOuterLineWall.js | 46 ++++++++++++++++++++++ src/hooks/roofcover/useRoofShapeSetting.js | 42 +------------------- 2 files changed, 47 insertions(+), 41 deletions(-) diff --git a/src/hooks/roofcover/useOuterLineWall.js b/src/hooks/roofcover/useOuterLineWall.js index e5f14cc1..ebe1d8ad 100644 --- a/src/hooks/roofcover/useOuterLineWall.js +++ b/src/hooks/roofcover/useOuterLineWall.js @@ -252,6 +252,7 @@ export function useOuterLineWall(id, propertiesId) { canvas?.renderAll() setOuterLineFix(true) closePopup(id) + ccwCheck() addPopup(propertiesId, 1, ) } @@ -905,6 +906,51 @@ export function useOuterLineWall(id, propertiesId) { } } + // 시계방향으로 그려진 경우 반시게방향으로 변경 + const ccwCheck = () => { + let outerLines = canvas.getObjects().filter((obj) => obj.name === 'outerLine') + + if (outerLines.length < 2) { + swalFire({ text: getMessage('wall.line.not.found') }) + return + } + + /** + * 외벽선이 시계방향인지 시계반대 방향인지 확인 + */ + const outerLinePoints = outerLines.map((line) => ({ x: line.x1, y: line.y1 })) + let counterClockwise = true + let signedArea = 0 + + outerLinePoints.forEach((point, index) => { + const nextPoint = outerLinePoints[(index + 1) % outerLinePoints.length] + signedArea += point.x * nextPoint.y - point.y * nextPoint.x + }) + + if (signedArea > 0) { + counterClockwise = false + } + /** 시계 방향일 경우 외벽선 reverse*/ + if (!counterClockwise) { + outerLines.reverse().forEach((line, index) => { + addLine([line.x2, line.y2, line.x1, line.y1], { + stroke: line.stroke, + strokeWidth: line.strokeWidth, + idx: index, + selectable: line.selectable, + name: 'outerLine', + x1: line.x2, + y1: line.y2, + x2: line.x1, + y2: line.y1, + visible: line.visible, + }) + canvas.remove(line) + }) + canvas.renderAll() + } + } + return { points, setPoints, diff --git a/src/hooks/roofcover/useRoofShapeSetting.js b/src/hooks/roofcover/useRoofShapeSetting.js index edf0e7b6..9e1d00ff 100644 --- a/src/hooks/roofcover/useRoofShapeSetting.js +++ b/src/hooks/roofcover/useRoofShapeSetting.js @@ -179,46 +179,6 @@ export function useRoofShapeSetting(id) { let outerLines = canvas.getObjects().filter((obj) => obj.name === 'outerLine') let direction - if (outerLines.length < 2) { - swalFire({ text: getMessage('wall.line.not.found') }) - return - } - - /** - * 외벽선이 시계방향인지 시계반대 방향인지 확인 - */ - const outerLinePoints = outerLines.map((line) => ({ x: line.x1, y: line.y1 })) - let counterClockwise = true - let signedArea = 0 - - outerLinePoints.forEach((point, index) => { - const nextPoint = outerLinePoints[(index + 1) % outerLinePoints.length] - signedArea += point.x * nextPoint.y - point.y * nextPoint.x - }) - - if (signedArea > 0) { - counterClockwise = false - } - /** 시계 방향일 경우 외벽선 reverse*/ - if (!counterClockwise) { - outerLines.reverse().forEach((line, index) => { - addLine([line.x2, line.y2, line.x1, line.y1], { - stroke: line.stroke, - strokeWidth: line.strokeWidth, - idx: index, - selectable: line.selectable, - name: 'outerLine', - x1: line.x2, - y1: line.y2, - x2: line.x1, - y2: line.y1, - visible: line.visible, - }) - canvas.remove(line) - }) - canvas.renderAll() - } - if ([1, 2, 3, 5, 6, 7, 8].includes(shapeNum)) { // 변별로 설정이 아닌 경우 경사를 지붕재에 적용해주어야함 setRoofPitch() @@ -507,7 +467,7 @@ export function useRoofShapeSetting(id) { originX: 'center', originY: 'center', }) - polygon.setViewLengthText(false) + // polygon.setViewLengthText(false) polygon.lines = [...outerLines] addPitchTextsByOuterLines() From ddf326ca6b2493fc2900dc16e3ff5c6885082eca Mon Sep 17 00:00:00 2001 From: ysCha Date: Wed, 15 Oct 2025 09:54:13 +0900 Subject: [PATCH 14/16] =?UTF-8?q?=EB=8F=99,=20=ED=98=84=EC=9D=B4=EB=8F=99?= =?UTF-8?q?=20=ED=99=95=EC=9D=B8=ED=95=AD=EB=AA=A9=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/hooks/roofcover/useMovementSetting.js | 24 +++++++++++++++++++---- 1 file changed, 20 insertions(+), 4 deletions(-) diff --git a/src/hooks/roofcover/useMovementSetting.js b/src/hooks/roofcover/useMovementSetting.js index 7d1d326a..08b31f4d 100644 --- a/src/hooks/roofcover/useMovementSetting.js +++ b/src/hooks/roofcover/useMovementSetting.js @@ -319,6 +319,14 @@ export function useMovementSetting(id) { const roofId = target.attributes.roofId const roof = canvas.getObjects().find((obj) => obj.id === roofId) + + // 현이동, 동이동 추가 + const moveFlowLine = typeRef.current === TYPE.FLOW_LINE ? FLOW_LINE_REF.POINTER_INPUT_REF.current.value : 0 + const moveUpDown = typeRef.current === TYPE.UP_DOWN ? UP_DOWN_REF.POINTER_INPUT_REF.current.value : 0 + roof.moveFlowLine = parseInt(moveFlowLine, 10) || 0; + roof.moveUpDown = parseInt(moveUpDown, 10) || 0; + roof.moveDirect = ""; + roof.moveSelectLine = target; const wall = canvas.getObjects().find((obj) => obj.name === POLYGON_TYPE.WALL && obj.attributes.roofId === roofId) const baseLines = wall.baseLines let targetBaseLines = [] @@ -348,6 +356,7 @@ export function useMovementSetting(id) { ? 'right' : 'left' let checkBaseLines, currentBaseLines + roof.moveDirect = lineVector switch (lineVector) { case 'up': checkBaseLines = baseLines.filter((line) => line.y1 === line.y2 && line.y1 < target.y1) @@ -442,10 +451,17 @@ export function useMovementSetting(id) { let value if (typeRef.current === TYPE.FLOW_LINE) { - value = - FLOW_LINE_REF.FILLED_INPUT_REF.current.value !== '' - ? Big(FLOW_LINE_REF.FILLED_INPUT_REF.current.value).times(2) - : Big(FLOW_LINE_REF.POINTER_INPUT_REF.current.value).times(2) + value = (() => { + const filledValue = FLOW_LINE_REF.FILLED_INPUT_REF.current?.value; + const pointerValue = FLOW_LINE_REF.POINTER_INPUT_REF.current?.value; + + if (filledValue && !isNaN(filledValue) && filledValue.trim() !== '') { + return Big(filledValue).times(2); + } else if (pointerValue && !isNaN(pointerValue) && pointerValue.trim() !== '') { + return Big(pointerValue).times(2); + } + return Big(0); // 기본값으로 0 반환 또는 다른 적절한 기본값 + })(); if (target.y1 === target.y2) { value = value.neg() } From d548b0e1f4a4bedadf035fd68ec188a7a4d494dd Mon Sep 17 00:00:00 2001 From: "hyojun.choi" Date: Wed, 15 Oct 2025 16:15:21 +0900 Subject: [PATCH 15/16] =?UTF-8?q?360=EB=8F=84=20=ED=9A=8C=EC=A0=84?= =?UTF-8?q?=EC=8B=9C=20=EC=9C=84=EC=B9=98=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/hooks/surface/useSurfaceShapeBatch.js | 52 ++++++++++++++++++++--- 1 file changed, 46 insertions(+), 6 deletions(-) diff --git a/src/hooks/surface/useSurfaceShapeBatch.js b/src/hooks/surface/useSurfaceShapeBatch.js index e42a7025..54721d94 100644 --- a/src/hooks/surface/useSurfaceShapeBatch.js +++ b/src/hooks/surface/useSurfaceShapeBatch.js @@ -1451,6 +1451,50 @@ export function useSurfaceShapeBatch({ isHidden, setIsHidden }) { // 그룹화할 객체들 배열 (currentObject + relatedObjects) const objectsToGroup = [currentObject, ...relatedObjects] + // 회전 카운트 초기화 및 최초 상태 저장 + if (!currentObject.rotationCount) { + currentObject.rotationCount = 0 + } + + // 최초 회전일 때 (rotationCount === 0) 원본 상태 저장 + if (currentObject.rotationCount === 0) { + objectsToGroup.forEach((obj) => { + if (!obj.originalState) { + obj.originalState = { + left: obj.left, + top: obj.top, + angle: obj.angle || 0, + points: obj.type === 'QPolygon' ? JSON.parse(JSON.stringify(obj.points)) : null, + scaleX: obj.scaleX || 1, + scaleY: obj.scaleY || 1, + } + } + }) + } + + // 회전 카운트 증가 (먼저 증가시켜서 목표 각도 계산) + currentObject.rotationCount = (currentObject.rotationCount + 1) % 4 + + // 목표 회전 각도 계산 (원본 기준) + const targetAngle = currentObject.rotationCount * 90 + + // 원본 상태로 먼저 복원한 후 목표 각도만큼 회전 + objectsToGroup.forEach((obj) => { + if (obj.originalState) { + // 원본 상태로 복원 + obj.set({ + left: obj.originalState.left, + top: obj.originalState.top, + angle: obj.originalState.angle, + scaleX: obj.originalState.scaleX, + scaleY: obj.originalState.scaleY, + }) + if (obj.originalState.points && obj.type === 'QPolygon') { + obj.set({ points: JSON.parse(JSON.stringify(obj.originalState.points)) }) + } + } + }) + // 기존 객체들을 캔버스에서 제거 objectsToGroup.forEach((obj) => canvas.remove(obj)) @@ -1463,12 +1507,8 @@ export function useSurfaceShapeBatch({ isHidden, setIsHidden }) { // 그룹을 캔버스에 추가 canvas.add(group) - // 현재 회전값에 90도 추가 - const currentAngle = group.angle || 0 - const newAngle = (currentAngle + 90) % 360 - - // 그룹 전체를 회전 - group.rotate(newAngle) + // 목표 각도로 회전 (원본 기준) + group.rotate(targetAngle) group.setCoords() // 그룹을 해제하고 개별 객체로 복원 From 3a3ff7c156639cebcbaf4a75af5468d8b2a0b740 Mon Sep 17 00:00:00 2001 From: ysCha Date: Thu, 16 Oct 2025 18:04:09 +0900 Subject: [PATCH 16/16] =?UTF-8?q?=EB=B3=B4=EC=A1=B0=EB=9D=BC=EC=9D=B8=20?= =?UTF-8?q?=EA=B8=B8=EC=9D=B4=20=EB=82=98=EC=98=A4=EC=A7=80=20=EC=95=8A?= =?UTF-8?q?=EC=9D=8C=20=3D>=20obj.name=20!=3D=3D=20'lengthText'=20&&=20?= =?UTF-8?q?=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/components/fabric/QPolygon.js | 1 + 1 file changed, 1 insertion(+) diff --git a/src/components/fabric/QPolygon.js b/src/components/fabric/QPolygon.js index 6021afc2..46fbb931 100644 --- a/src/components/fabric/QPolygon.js +++ b/src/components/fabric/QPolygon.js @@ -251,6 +251,7 @@ export const QPolygon = fabric.util.createClass(fabric.Polygon, { obj.parentId === this.id && obj.name !== POLYGON_TYPE.WALL && obj.name !== POLYGON_TYPE.ROOF && + obj.name !== 'lengthText' && obj.name !== 'outerLine' && obj.name !== 'baseLine', // && obj.name !== 'outerLinePoint',