From 1ddc176e7b3b3527464062988bd8696b1046ab2f Mon Sep 17 00:00:00 2001 From: Cha Date: Wed, 24 Sep 2025 22:50:05 +0900 Subject: [PATCH 1/4] skeleton v6 --- src/util/skeleton-utils.js | 544 +++++++++++++++++-------------------- 1 file changed, 255 insertions(+), 289 deletions(-) diff --git a/src/util/skeleton-utils.js b/src/util/skeleton-utils.js index 70d53085..77b694d2 100644 --- a/src/util/skeleton-utils.js +++ b/src/util/skeleton-utils.js @@ -4,11 +4,12 @@ import { calcLinePlaneSize, toGeoJSON } from '@/util/qpolygon-utils' import { QLine } from '@/components/fabric/QLine' /** - * 지붕 폴리곤의 스켈레톤(중심선)을 생성하고 캔버스에 그립니다. + * 지붕 폴리곤의 스켈레톤(중심선)을 생성하고 갱신합니다. + * 이 함수는 지붕의 스켈레톤 상태를 관리하며, 외곽선의 속성(gable 등) 변경에 따라 + * 스켈레톤을 재계산하고 다시 그립니다. * @param {string} roofId - 대상 지붕 객체의 ID * @param {fabric.Canvas} canvas - Fabric.js 캔버스 객체 * @param {string} textMode - 텍스트 표시 모드 - * @param {Array} existingSkeletonLines - 기존에 생성된 스켈레톤 라인 */ export const drawSkeletonRidgeRoof = (roofId, canvas, textMode) => { const roof = canvas?.getObjects().find((object) => object.id === roofId) @@ -16,121 +17,88 @@ export const drawSkeletonRidgeRoof = (roofId, canvas, textMode) => { console.error(`Roof with id "${roofId}" not found.`); return; } - //const skeletonLines = []; - // 1. 지붕 폴리곤 좌표 전처리 - const coordinates = preprocessPolygonCoordinates(roof.points); - if (coordinates.length < 3) { - console.warn("Polygon has less than 3 unique points. Cannot generate skeleton."); - return; + + // 기존에 그려진 내부선을 모두 제거합니다. + if (roof.innerLines) { + roof.innerLines.forEach(line => canvas.remove(line)); + roof.innerLines = []; } - const wall = canvas.getObjects().find((object) => object.name === POLYGON_TYPE.WALL && object.attributes.roofId === roofId) - if (!wall) { - console.error(`Wall for roof id "${roofId}" not found.`); - return; - } + const baseLines = roof.lines; - // 2. 스켈레톤 생성 및 그리기 - skeletonBuilder(roofId, canvas, textMode, roof) -} - -/** - * SkeletonBuilder를 사용하여 스켈레톤을 생성하고 내부선을 그립니다. - * @param {string} roofId - 지붕 ID - * @param {fabric.Canvas} canvas - 캔버스 객체 - * @param {string} textMode - 텍스트 모드 - * @param {fabric.Object} roof - 지붕 객체 - * @param {Array} skeletonLines - 스켈레톤 라인 배열 - */ -export const skeletonBuilder = (roofId, canvas, textMode, roof) => { - const geoJSONPolygon = toGeoJSON(roof.points) - const skeletonLines = [] try { - // SkeletonBuilder는 닫히지 않은 폴리곤을 기대하므로 마지막 점 제거 - geoJSONPolygon.pop() - const skeleton = SkeletonBuilder.BuildFromGeoJSON([[geoJSONPolygon]]) + // --- 1. 항상 최신 폴리곤 정보로 기본 스켈레톤 생성 --- + const geoJSONPolygon = toGeoJSON(roof.points); + geoJSONPolygon.pop(); // SkeletonBuilder는 닫히지 않은 폴리곤을 기대 + const initialSkeleton = SkeletonBuilder.BuildFromGeoJSON([[geoJSONPolygon]]); + console.log("Skeleton recalculated from base polygon.", initialSkeleton.edge_analysis); - console.log(`지붕 형태: ${skeleton.roof_type}`, skeleton.edge_analysis) + const skeletonLines = []; + const processedInnerEdges = new Set(); - // 스켈레톤 데이터를 기반으로 내부선 생성 - roof.innerLines = createInnerLinesFromSkeleton(skeleton, roof.lines, roof, canvas, textMode, skeletonLines) + // --- 2. 외곽선 속성에 따라 내부선 생성 여부 결정 --- + initialSkeleton.Edges.forEach(edgeResult => { + const { Begin, End } = edgeResult.Edge; + const baseLine = baseLines.find(line => isSameLine(Begin.X, Begin.Y, End.X, End.Y, line)); - // 캔버스에 스켈레톤 상태 저장 - if (!canvas.skeletonStates) { - canvas.skeletonStates = {} - canvas.skeletonLines = [] - } - canvas.skeletonStates[roofId] = true - - canvas.renderAll() - } catch (e) { - console.error('스켈레톤 생성 중 오류 발생:', e) - if (canvas.skeletonStates) { - canvas.skeletonStates[roofId] = false - } - } -} - -/** - * 스켈레톤 결과와 외벽선 정보를 바탕으로 내부선(용마루, 추녀)을 생성합니다. - * @param {object} skeleton - SkeletonBuilder로부터 반환된 스켈레톤 객체 - * @param {Array} baseLines - 원본 외벽선 QLine 객체 배열 - * @param {fabric.Object} roof - 대상 지붕 객체 - * @param {fabric.Canvas} canvas - Fabric.js 캔버스 객체 - * @param {string} textMode - 텍스트 표시 모드 ('plane', 'actual', 'none') - * @param {Array} skeletonLines - 스켈레톤 라인 배열 (수정 대상) - * @returns {Array} 생성된 내부선(QLine) 배열 - */ -const createInnerLinesFromSkeleton = (skeleton, baseLines, roof, canvas, textMode, skeletonLines) => { - if (!skeleton?.Edges) return [] - - const processedInnerEdges = new Set() - - // 1. 모든 Edge를 순회하며 기본 스켈레톤 선(용마루)을 수집합니다. - skeleton.Edges.forEach(edgeResult => { - processEavesEdge(edgeResult, skeletonLines, processedInnerEdges) - }); - - - // 2. 케라바(Gable) 속성을 가진 외벽선에 해당하는 스켈레톤을 후처리합니다. - skeleton.Edges.forEach(edgeResult => { - - const { Begin, End } = edgeResult.Edge; - const gableBaseLine = baseLines.find(line => - line.attributes.type === 'gable' && isSameLine(Begin.X, Begin.Y, End.X, End.Y, line) - ); - - if (gableBaseLine) { - if(canvas.skeletonLines.length > 0){ - skeletonLines = canvas.skeletonLines; + // 외곽선이 'gable'이 아닌 경우에만 해당 면의 내부선을 생성합니다. + if (baseLine && baseLine.attributes.type !== 'gable') { + processEavesEdge(edgeResult, skeletonLines, processedInnerEdges); } - processGableEdge(edgeResult, baseLines, skeletonLines, gableBaseLine); - canvas.skeletonLines = skeletonLines; - } - - }); - - // 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, - name: attributes.type, - textMode: textMode, - attributes: attributes, + // 'gable'인 경우, 의도적으로 내부선을 생성하지 않아 빈 공간을 만듭니다. }); - canvas.add(innerLine); - innerLine.bringToFront(); - innerLines.push(innerLine); - }); + // --- 3. 연결이 끊어진 선(케라바로 인해 생성됨)을 찾아 연장 --- + const { disconnectedLines } = findDisconnectedSkeletonLines(skeletonLines, baseLines); + disconnectedLines.forEach(dLine => { + const { index, extendedLine, p1Connected, p2Connected } = dLine; + const newPoint = extendedLine?.point; + if (!newPoint) return; - canvas.renderAll(); - return innerLines; + if (!p1Connected) { // p1 연장 + skeletonLines[index].p1 = { ...skeletonLines[index].p1, x: newPoint.x, y: newPoint.y }; + } else if (!p2Connected) { //p2 연장 + skeletonLines[index].p2 = { ...skeletonLines[index].p2, x: newPoint.x, y: newPoint.y }; + } + }); + + // --- 4. 최종 결과물을 지붕 객체에 저장하고 캔버스에 그리기 --- + roof.skeletonLines = skeletonLines; + roof.skeleton = rebuildSkeletonFromLines(skeletonLines, baseLines); // 데이터 구조 일관성 유지 + console.log("Skeleton processing complete. Storing final state.", roof.skeleton); + + const innerLines = []; + if (roof.skeletonLines) { + roof.skeletonLines.forEach(line => { + const { p1, p2, attributes, lineStyle } = line; + if (!p1 || !p2 || !attributes || !lineStyle) { + console.warn("Skipping incomplete skeleton line:", line); + return; + } + const innerLine = new QLine([p1.x, p1.y, p2.x, p2.y], { + parentId: roof.id, + fontSize: roof.fontSize, + stroke: lineStyle.color, + strokeWidth: lineStyle.width, + name: attributes.type, + textMode: textMode, + attributes: attributes, + }); + canvas.add(innerLine); + innerLine.bringToFront(); + innerLines.push(innerLine); + }); + } + roof.innerLines = innerLines; + + } catch (e) { + console.error('Skeleton processing failed:', e); + // 에러 발생 시 상태를 초기화하여 다음 실행에 영향 없도록 함 + roof.skeleton = null; + roof.skeletonLines = []; + } finally { + canvas.renderAll(); + } } /** @@ -153,44 +121,6 @@ function processEavesEdge(edgeResult, skeletonLines, processedInnerEdges) { } } -/** - * GABLE(케라바) Edge를 처리하여 스켈레톤 선을 정리하고 연장합니다. - * @param {object} edgeResult - 스켈레톤 Edge 데이터 - * @param {Array} baseLines - 전체 외벽선 배열 - * @param {Array} skeletonLines - 전체 스켈레톤 라인 배열 - */ -function processGableEdge(edgeResult, baseLines, skeletonLines, selectBaseLine) { - const edgePoints = [{ x:selectBaseLine.startPoint.x, y:selectBaseLine.startPoint.y}, {x:selectBaseLine.endPoint.x, y:selectBaseLine.endPoint.y}]//edgeResult.Polygon.map(p => ({ x: p.X, y: p.Y })); - const polygons = createPolygonsFromSkeletonLines(skeletonLines, selectBaseLine); - // 1. 케라바 면과 관련된 불필요한 스켈레톤 선을 제거합니다. - for (let i = skeletonLines.length - 1; i >= 0; i--) { - 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); - - if (isEdgeLine) { - skeletonLines.splice(i, 1); - } - } - - // 2. 연결이 끊어진 스켈레톤 선을 찾아 연장합니다. - const { disconnectedLines } = findDisconnectedSkeletonLines(skeletonLines, baseLines); - - 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 }; - } - }); -} - - // --- Helper Functions --- /** @@ -243,24 +173,6 @@ function addRawLine(skeletonLines, processedInnerEdges, p1, p2, lineType, color, }); } -/** - * 폴리곤 좌표를 스켈레톤 생성에 적합하게 전처리합니다 (중복 제거, 시계 방향 정렬). - * @param {Array} initialPoints - 초기 폴리곤 좌표 배열 - * @returns {Array>} 전처리된 좌표 배열 (e.g., [[10, 10], ...]) - */ -const preprocessPolygonCoordinates = (initialPoints) => { - 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]) { - coordinates.pop(); - } - return coordinates.reverse(); -}; - /** * 스켈레톤 Edge와 외벽선이 동일한지 확인합니다. * @returns {boolean} 동일 여부 @@ -341,7 +253,8 @@ function extendFromP2TowardP1(p1, p2, baseLines, skeletonLines, excludeIndex) { let closestHit = null; const checkHit = (hit) => { - if (hit && hit.t > len - 0.1) { // 원래 선분의 끝점(p1) 너머에서 교차하는지 확인 + // 교차점이 원래 선분의 길이(len)보다 멀리 있어야 유효한 연장으로 간주 + if (hit && hit.t > len - 0.1) { if (!closestHit || hit.t < closestHit.t) { closestHit = hit; } @@ -376,7 +289,6 @@ 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 => { @@ -413,7 +325,6 @@ export const findDisconnectedSkeletonLines = (skeletonLines, baseLines) => { if (!p1Connected) { extendedLine = extendFromP2TowardP1(line.p1, line.p2, baseLines, skeletonLines, index); if (!extendedLine) { - let closestBaseLine = null; let minDistance = Infinity; let projection = null; baseLines.forEach(base => { @@ -421,7 +332,6 @@ export const findDisconnectedSkeletonLines = (skeletonLines, baseLines) => { const d = Math.sqrt(Math.pow(line.p1.x - p.x, 2) + Math.pow(line.p1.y - p.y, 2)); if (d < minDistance) { minDistance = d; - closestBaseLine = base; projection = p; } }); @@ -430,7 +340,6 @@ export const findDisconnectedSkeletonLines = (skeletonLines, baseLines) => { } else if (!p2Connected) { extendedLine = extendFromP2TowardP1(line.p2, line.p1, baseLines, skeletonLines, index); if (!extendedLine) { - let closestBaseLine = null; let minDistance = Infinity; let projection = null; baseLines.forEach(base => { @@ -438,7 +347,6 @@ export const findDisconnectedSkeletonLines = (skeletonLines, baseLines) => { const d = Math.sqrt(Math.pow(line.p2.x - p.x, 2) + Math.pow(line.p2.y - p.y, 2)); if (d < minDistance) { minDistance = d; - closestBaseLine = base; projection = p; } }); @@ -452,148 +360,206 @@ export const findDisconnectedSkeletonLines = (skeletonLines, baseLines) => { return { disconnectedLines }; }; + +// --- Skeleton Rebuilding Functions --- + /** - * skeletonLines와 selectBaseLine을 이용하여 다각형이 되는 좌표를 구합니다. - * selectBaseLine의 좌표는 제외합니다. - * @param {Array} skeletonLines - 스켈레톤 라인 배열 - * @param {Object} selectBaseLine - 선택된 베이스 라인 (p1, p2 속성을 가진 객체) - * @returns {Array>} 다각형 좌표 배열의 배열 + * 두 점이 거의 같은 위치에 있는지 확인합니다. + * @param {object} p1 - 점1 {x, y} + * @param {object} p2 - 점2 {x, y} + * @param {number} [epsilon=0.1] - 허용 오차 + * @returns {boolean} 동일한지 여부 */ -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 - ); - }); - +const pointsEqual = (p1, p2, epsilon = 0.1) => { + return Math.abs(p1.x - p2.x) < epsilon && Math.abs(p1.y - p2.y) < epsilon; }; /** - * 스켈레톤 라인들 간의 모든 교차점을 찾습니다. - * @param {Array} skeletonLines - 스켈레톤 라인 배열 (각 요소는 {p1: {x, y}, p2: {x, y}} 형태) - * @returns {Array} 교차점 배열 + * 점 객체를 고유한 문자열 키로 변환합니다. 정밀도 문제를 피하기 위해 소수점 자리를 고정합니다. + * @param {object} p - 점 {x, y} + * @returns {string} - 고유 키 (e.g., "123.456,789.012") */ -const findAllIntersections = (skeletonLines) => { - const intersections = []; - const processedPairs = new Set(); +const pointToKey = (p) => { + return `${p.x.toFixed(3)},${p.y.toFixed(3)}`; +}; - 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); +/** + * 문자열 키를 점 객체로 변환합니다. + * @param {string} key - 고유 키 + * @returns {object} - 점 {x, y} + */ +const keyToPoint = (key) => { + const [x, y] = key.split(','); + return { x: parseFloat(x), y: parseFloat(y) }; +}; - const line1 = skeletonLines[i]; - const line2 = skeletonLines[j]; +/** + * 모든 라인 세그먼트로부터 그래프를 구축합니다. + * 각 정점(포인트)에 대해 연결된 이웃 정점 목록을 각도순으로 정렬하여 저장합니다. + * 이는 면(face)을 시계 반대 방향으로 순회하는 데 필수적입니다. + * @param {Array} lines - `{p1, p2}` 형태의 라인 배열 + * @returns {Map>} - 그래프 데이터 구조 + */ +const buildAngularSortedGraph = (lines) => { + const graph = new Map(); - // 두 라인이 교차하는지 확인 - const intersection = getLineIntersection( - line1.p1, line1.p2, - line2.p1, line2.p2 - ); + // 그래프에 양방향 간선을 추가하는 헬퍼 함수 + const addEdge = (p1, p2) => { + const key1 = pointToKey(p1); + const key2 = pointToKey(p2); - if (intersection) { - // 교차점이 실제로 두 선분 위에 있는지 확인 - if (isPointOnSegment(intersection, line1.p1, line1.p2) && - isPointOnSegment(intersection, line2.p1, line2.p2)) { - intersections.push(intersection); + if (!graph.has(key1)) graph.set(key1, []); + if (!graph.has(key2)) graph.set(key2, []); + + const angle1 = Math.atan2(p2.y - p1.y, p2.x - p1.x); + const angle2 = Math.atan2(p1.y - p2.y, p1.x - p2.x); + + graph.get(key1).push({ point: p2, angle: angle1 }); + graph.get(key2).push({ point: p1, angle: angle2 }); + }; + + lines.forEach(line => addEdge(line.p1, line.p2)); + + // 각 정점의 이웃 리스트를 각도 기준으로 정렬 + for (const neighbors of graph.values()) { + neighbors.sort((a, b) => a.angle - b.angle); + } + + return graph; +}; + +/** + * 각도순으로 정렬된 그래프에서 모든 면(폴리곤)을 찾습니다. + * 평면 그래프의 모든 간선을 순회하며 아직 방문하지 않은 간선에서 출발하여 + * 하나의 면을 구성하는 사이클을 찾습니다. + * @param {Map} graph - `buildAngularSortedGraph`로 생성된 그래프 + * @returns {Array>} - 폴리곤(점들의 배열)들의 배열 + */ +const findFaces = (graph) => { + const polygons = []; + const visitedHalfEdges = new Set(); // "p1_key->p2_key" 형식으로 방문한 반-간선 저장 + + for (const [p1Key, neighbors] of graph.entries()) { + for (const neighbor of neighbors) { + const p2Key = pointToKey(neighbor.point); + const halfEdge = `${p1Key}->${p2Key}`; + + if (visitedHalfEdges.has(halfEdge)) { + continue; // 이미 다른 면을 통해 방문한 간선 + } + + // 새로운 면 탐색 시작 + const newPolygon = []; + let currentHalfEdge = halfEdge; + + while (!visitedHalfEdges.has(currentHalfEdge)) { + if (visitedHalfEdges.size > graph.size * 2) { // 무한 루프 방지 + console.error("Infinite loop detected in face finding."); + return []; + } + visitedHalfEdges.add(currentHalfEdge); + + const [startKey, endKey] = currentHalfEdge.split('->'); + newPolygon.push(keyToPoint(startKey)); + + // 현재 간선의 끝점에서, 들어온 간선의 다음(CCW) 간선을 찾아 다음 경로로 설정 + const endNodeNeighbors = graph.get(endKey); + const incomingEdgeIndex = endNodeNeighbors.findIndex(n => pointToKey(n.point) === startKey); + + if (incomingEdgeIndex === -1) { + console.error("Graph is inconsistent."); + break; + } + + const nextNeighbor = endNodeNeighbors[(incomingEdgeIndex + 1) % endNodeNeighbors.length]; + currentHalfEdge = `${endKey}->${pointToKey(nextNeighbor.point)}`; + } + + if (newPolygon.length > 2) { + // 중복 폴리곤 방지 + const polygonKey = newPolygon.map(p => pointToKey(p)).sort().join('|'); + if (!polygons.some(p => p.key === polygonKey)) { + polygons.push({ key: polygonKey, points: newPolygon }); } } } } - - return intersections; + return polygons.map(p => p.points); }; + /** - * 스켈레톤 라인들과 교차점들을 모아서 모든 포인트를 수집합니다. - * @param {Array} skeletonLines - 스켈레톤 라인 배열 - * @param {Array} intersections - 교차점 배열 - * @returns {Array} 모든 포인트 배열 + * 후처리된 스켈레톤 라인과 외벽선을 기반으로 스켈레톤 유사 구조를 재생성합니다. + * @param {Array} skeletonLines - 내부 스켈레톤 라인 배열. {p1, p2} 또는 QLine({x1, y1, x2, y2}) 형식을 지원합니다. + * @param {Array} baseLines - 외벽선 QLine 객체 배열. (e.g., fabric.Line) + * x1, y1, x2, y2 속성을 가져야 합니다. + * @returns {object|null} - 원본 스켈레톤과 유사한 구조의 객체 { Edges: [...] }. 실패 시 null. */ -const collectAllPoints = (skeletonLines, intersections) => { - const allPoints = new Map(); - const pointKey = (point) => `${point.x.toFixed(3)},${point.y.toFixed(3)}`; +export const rebuildSkeletonFromLines = (skeletonLines, baseLines) => { + if (!skeletonLines || !baseLines) return null; - // 스켈레톤 라인의 엔드포인트들 추가 - skeletonLines.forEach(line => { - const key1 = pointKey(line.p1); - const key2 = pointKey(line.p2); - - if (!allPoints.has(key1)) { - allPoints.set(key1, { ...line.p1 }); + // 1. 모든 선분(내부선 + 외벽선)을 동일한 형식({p1, p2})으로 변환하여 결합합니다. + // 입력되는 skeletonLines의 타입이 QLine({x1, y1, x2, y2}) 형식일 수 있으므로 두 경우 모두 처리합니다. + const allLines = skeletonLines.map(line => { + // { p1, p2 } 형태의 raw 객체 처리 + if (line.p1 && line.p2) { + return { p1: line.p1, p2: line.p2 }; } - if (!allPoints.has(key2)) { - allPoints.set(key2, { ...line.p2 }); + // QLine 또는 fabric.Line과 같이 x1, y1, x2, y2 속성을 가진 객체 처리 + if (typeof line.x1 === 'number' && typeof line.y1 === 'number' && + typeof line.x2 === 'number' && typeof line.y2 === 'number') { + return { p1: { x: line.x1, y: line.y1 }, p2: { x: line.x2, y: line.y2 } }; + } + console.warn('Unsupported line format in skeletonLines:', line); + return null; + }).filter(Boolean); // 유효하지 않은 형식은 걸러냅니다. + + baseLines.forEach(line => { + allLines.push({ + p1: { x: line.x1, y: line.y1 }, + p2: { x: line.x2, y: line.y2 } + }); + }); + + // 2. 그래프를 구축 + const graph = buildAngularSortedGraph(allLines); + if(graph.size === 0) return { Edges: [] }; + + // 3. 그래프에서 모든 면(폴리곤)을 찾음 + const polygons = findFaces(graph); + + // 4. 각 외벽선에 해당하는 폴리곤을 찾아 스켈레톤 Edge 구조를 만듦 + const rebuiltEdges = []; + baseLines.forEach(baseLine => { + const p1 = { x: baseLine.x1, y: baseLine.y1 }; + const p2 = { x: baseLine.x2, y: baseLine.y2 }; + + // 이 baseLine을 변으로 포함하는 폴리곤을 찾음 + const associatedPolygon = polygons.find(polygon => { + for (let i = 0; i < polygon.length; i++) { + const polyP1 = polygon[i]; + const polyP2 = polygon[(i + 1) % polygon.length]; + if ((pointsEqual(p1, polyP1) && pointsEqual(p2, polyP2)) || + (pointsEqual(p1, polyP2) && pointsEqual(p2, polyP1))) { + return true; + } + } + return false; + }); + + if (associatedPolygon) { + rebuiltEdges.push({ + Edge: { + Begin: { X: p1.x, Y: p1.y }, + End: { X: p2.x, Y: p2.y } + }, + Polygon: associatedPolygon.map(p => ({ X: p.x, Y: p.y })), + // 원본 skeleton 객체의 다른 속성들(e.g., roof_type)은 + // 라인 정보만으로는 재생성할 수 없으므로 포함하지 않습니다. + }); } }); - // 교차점들 추가 - intersections.forEach(intersection => { - const key = pointKey(intersection); - if (!allPoints.has(key)) { - allPoints.set(key, { ...intersection }); - } - }); + return { Edges: rebuiltEdges }; +} - 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 3fd1a0b5756c492e367e4511e16ea1bca12acad8 Mon Sep 17 00:00:00 2001 From: Cha Date: Wed, 24 Sep 2025 23:42:17 +0900 Subject: [PATCH 2/4] skeleton v6 --- src/util/skeleton-utils.js | 17 +++++++++++++---- 1 file changed, 13 insertions(+), 4 deletions(-) diff --git a/src/util/skeleton-utils.js b/src/util/skeleton-utils.js index 77b694d2..ac74b217 100644 --- a/src/util/skeleton-utils.js +++ b/src/util/skeleton-utils.js @@ -41,11 +41,20 @@ export const drawSkeletonRidgeRoof = (roofId, canvas, textMode) => { const { Begin, End } = edgeResult.Edge; const baseLine = baseLines.find(line => isSameLine(Begin.X, Begin.Y, End.X, End.Y, line)); - // 외곽선이 'gable'이 아닌 경우에만 해당 면의 내부선을 생성합니다. - if (baseLine && baseLine.attributes.type !== 'gable') { - processEavesEdge(edgeResult, skeletonLines, processedInnerEdges); + if (baseLine) { + const lineType = baseLine.attributes.type; + + if (lineType === 'gable') { + // 'gable'인 경우, 의도적으로 내부선을 생성하지 않아 빈 공간을 만듭니다. + } else if (lineType === 'wall') { + // TODO: 'wall' 타입에 대한 처리가 필요합니다. + // 현재는 아무 작업도 하지 않지만, 향후 관련 로직이 이곳에 추가될 수 있습니다. + // 예를 들어, 벽에 맞닿는 부분의 선을 다르게 처리하거나 특정 정보를 추가할 수 있습니다. + } else { + // 'gable' 또는 'wall'이 아닌 경우 (e.g., 'eaves') 내부선을 생성합니다. + processEavesEdge(edgeResult, skeletonLines, processedInnerEdges); + } } - // 'gable'인 경우, 의도적으로 내부선을 생성하지 않아 빈 공간을 만듭니다. }); // --- 3. 연결이 끊어진 선(케라바로 인해 생성됨)을 찾아 연장 --- From 6cccdfb9875d4a136cac85a0f607cd7030dda081 Mon Sep 17 00:00:00 2001 From: Cha Date: Wed, 24 Sep 2025 23:48:10 +0900 Subject: [PATCH 3/4] =?UTF-8?q?skeleton=20v6=20-=20=20=EA=B5=90=EC=A0=90?= =?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/util/skeleton-utils.js_b.js | 2085 +++++++++++++++++++++++++++++++ 1 file changed, 2085 insertions(+) create mode 100644 src/util/skeleton-utils.js_b.js diff --git a/src/util/skeleton-utils.js_b.js b/src/util/skeleton-utils.js_b.js new file mode 100644 index 00000000..36ba6742 --- /dev/null +++ b/src/util/skeleton-utils.js_b.js @@ -0,0 +1,2085 @@ +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 { QLine } from '@/components/fabric/QLine' +import { getDegreeByChon } from '@/util/canvas-util' + + +/** + * 지붕 폴리곤의 스켈레톤(중심선)을 생성하고 캔버스에 그립니다. + * @param {string} roofId - 대상 지붕 객체의 ID + * @param {fabric.Canvas} canvas - Fabric.js 캔버스 객체 + * @param {string} textMode - 텍스트 표시 모드 + * @param existingSkeletonLines + */ +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. 지붕 폴리곤 좌표 전처리 + const coordinates = preprocessPolygonCoordinates(roof.points); + if (coordinates.length < 3) { + console.warn("Polygon has less than 3 unique points. Cannot generate skeleton."); + return; + } + + + 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) + +} + +/** + * 스켈레톤의 edge를 각도가 있는 구간으로 변형합니다. + * @param {Object} skeleton - 스켈레톤 객체 + * @param {number} edgeIndex - 변형할 edge의 인덱스 + * @param {number} angleOffset - 추가할 각도 (도 단위) + * @param {number} splitRatio - 분할 비율 (0-1 사이, 0.5면 중간점) + * @returns {Object} 변형된 스켈레톤 객체 + */ +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 형식으로 변환합니다. + const geoJSONPolygon = toGeoJSON(roof.points) + + try { + // 2. SkeletonBuilder를 사용하여 스켈레톤을 생성합니다. + geoJSONPolygon.pop() + let skeleton = SkeletonBuilder.BuildFromGeoJSON([[geoJSONPolygon]]) + + console.log(`지붕 형태: ${skeleton.roof_type}`) // "complex" + console.log('Edge 분석:', skeleton.edge_analysis) + + // 3. 라인을 그림 + const innerLines = createInnerLinesFromSkeleton(skeleton, roof.lines, roof, canvas, textMode, skeletonLines) + + 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.skeletonStates[roofId] = true + + canvas.renderAll() + } catch (e) { + console.error('지붕 생성 중 오류 발생:', e) + // 오류 발생 시 기존 로직으로 대체하거나 사용자에게 알림 + if (canvas.skeletonStates) { + canvas.skeletonStates[roofId] = false + } + } +} + +/** + * 스켈레톤 결과와 원본 외벽선 정보를 바탕으로 내부선(마루, 추녀)들을 생성합니다. + * @param {object} skeleton - SkeletonBuilder로부터 반환된 스켈레톤 객체 + * @param {Array} baseLines - 원본 외벽선 QLine 객체 배열 + * @param {QPolygon} roof - 대상 지붕 QPolygon 객체 + * @param {fabric.Canvas} canvas - Fabric.js 캔버스 객체 + * @param {string} textMode - 텍스트 표시 모드 ('plane', 'actual', 'none') + * @returns {Array} 생성된 내부선(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 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) + // 방향을 고려하지 않고 같은 라인인지 확인하는 함수 + + let edgeType = 'eaves' + let baseLineIndex = 0 + + processEavesEdge(edgeResult, baseLines, skeletonLines, processedInnerEdges) + + } + + + for (let edgeIndex = 0; edgeIndex < skeleton.Edges.length; edgeIndex++) { + + 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 + + + //외벽선 라인과 같은 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 // 매칭되는 라인을 찾았으므로 루프 종료 + } + } + + } + + + + + } + + 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 + const innerLine = new QLine([p1.x, p1.y, p2.x, p2.y], { + parentId: roof.id, + fontSize: roof.fontSize, + stroke: lineStyle.color, + strokeWidth: lineStyle?.width, + name: attributes.type, + textMode: textMode, + attributes: attributes, + }) + + canvas.add(innerLine) + innerLine.bringToFront() + canvas.renderAll() + + innerLines.push(innerLine) + } + + 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 })) + + for (let i = 0; i < polygonPoints.length; i++) { + //시계방향 + 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) + } + } +} + +// ✅ 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) 제거: 항상 수평/수직 내부 용마루만 생성 + const edgePoints = edgeResult.Polygon.map(p => ({ x: p.X, y: p.Y })); + + //제거 + 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 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); + } + } + + //확장 + const breakLinePont = findDisconnectedSkeletonLines(skeletonLines, baseLines) + console.log('breakLinePont:', breakLinePont) + +if(breakLinePont.disconnectedLines.length > 0) { + + + for (const dLine of breakLinePont.disconnectedLines) { + const inx = dLine.index; + const exLine = dLine.extendedLine; + + //확장 + if (dLine.p1Connected) { // This means p2 is disconnected + if (exLine && exLine.p2) { + skeletonLines[inx].p2 = { ...skeletonLines[inx].p2, x: exLine.p2.x, y: exLine.p2.y }; + } + } else if (dLine.p2Connected && !dLine.p1Connected) { // This means p1 is disconnected + // p2가 시작점, p1이 끝점인 경우: p1을 히트 지점(기준선 또는 다른 스켈레톤)으로 이동 + if (exLine && exLine.p2) { + skeletonLines[inx].p1 = { ...skeletonLines[inx].p1, x: exLine.p2.x, y: exLine.p2.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 }; +// } +// } +// +// } +// +// } + + +} + +// ✅ 헬퍼 함수들 +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) + ) + }) +} + +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('|') + + 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 + + skeletonLines.push({ + p1: p1, + p2: p2, + attributes: { + 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, + }, + }) + +} + +/** + * 특정 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}, ...]) + * @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]) { + coordinates.pop(); + } + + // SkeletonBuilder 라이브러리는 시계 방향 순서의 좌표를 요구하므로 배열을 뒤집습니다. + coordinates.reverse(); + + return coordinates; +}; + +const isSameLine = (edgeStartX, edgeStartY, edgeEndX, edgeEndY, baseLine) => { + const tolerance = 0.1 + + // 시계방향 매칭 (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 +} + +/** + * 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; +} + + +// Ray(p = start + t*dir, t >= 0)와 선분([a,b])의 교차를 계산 +// 반환: { point: {x,y}, t: number } 또는 null +function getRayIntersectionWithSegment(rayStart, rayDir, segA, segB) { + const p = rayStart; + const r = { x: rayDir.x, y: rayDir.y }; + 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; + const q_p = { x: q.x - p.x, y: q.y - p.y }; + const q_pxr = q_p.x * r.y - q_p.y * r.x; + + // 평행 (또는 거의 평행) + if (Math.abs(rxs) < 1e-6) { + return null; + } + + const t = (q_p.x * s.y - q_p.y * s.x) / rxs; // ray parameter (>= 0) + const u = q_pxr / rxs; // segment parameter (0..1) + + 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; +} + +// p2에서 p1 방향으로 레이를 쏴서 baseLine 또는 다른 skeletonLine에 최초로 닿는 지점을 찾는다 +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; // { point, t, type: 'base'|'skeleton' } + + // baseLines 교차 검사 + if (Array.isArray(baseLines)) { + for (const baseLine of baseLines) { + const { p1: b1, p2: b2 } = extractBaseLineCoordinates(baseLine); + const hit = getRayIntersectionWithSegment(p2, dir, b1, b2); + if (hit && hit.t > len - 0.1) { // MODIFIED: Check if the hit is beyond p1 + if (!closestHit || hit.t < closestHit.t) { + closestHit = { point: hit.point, t: hit.t, type: 'base' }; + } + } + } + } + + // skeletonLines 교차 검사 (자기 자신 제외) + if (Array.isArray(skeletonLines)) { + for (let i = 0; i < skeletonLines.length; i++) { + if (i === excludeIndex) continue; + const seg = skeletonLines[i]; + const hit = getRayIntersectionWithSegment(p2, dir, seg.p1, seg.p2); + if (hit && hit.t > len - 0.1) { // MODIFIED: Check if the hit is beyond p1 + if (!closestHit || hit.t < closestHit.t) { + closestHit = { point: hit.point, t: hit.t, type: 'skeleton' }; + } + } + } + } + + return closestHit; // 또는 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 {x1, y1, x2, y2} = baseLine; + const baseP1 = {x: x1, y: y1}; + const baseP2 = {x: x2, y: y2}; + + // Check if the point is one of the endpoints of the baseline + if (pointsEqual(point, baseP1) || pointsEqual(point, baseP2)) { + return true; + } + + // Check if the point lies on the baseline segment + 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; + }; + + // 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)); + }; + + /** + * [MODIFIED] 연결 상태 확인 및 끊어진 선 연장 정보 계산 함수 + * @param {Object} line - 검사할 skeletonLine (p1, p2) + * @param {number} lineIndex - 현재 라인의 인덱스 + * @returns {{isConnected: boolean, p1Connected: boolean, p2Connected: boolean, extendedLine: Object|null}} + * 연결 상태와, 끊어진 경우 연장될 선의 정보를 담은 객체 + */ + const isConnected = (line, lineIndex) => { + const { p1, p2 } = line; + const result = { + isConnected: false, + p1Connected: false, + p2Connected: false, + extendedLine: null, + }; + + // 1. 기준선(baseLine)과의 연결 상태 확인 + const isP1OnBase = isPointOnBase(p1); + const isP2OnBase = isPointOnBase(p2); + + // 2. 다른 스켈레톤 선(skeletonLines)과의 연결 상태 확인 + let p1ConnectedToSkeleton = false; + let p2ConnectedToSkeleton = false; + for (let i = 0; i < skeletonLines.length; i++) { + if (i === lineIndex) continue; // 자기 자신은 제외 + + const otherLine = skeletonLines[i]; + if (!p1ConnectedToSkeleton && (pointsEqual(p1, otherLine.p1) || pointsEqual(p1, otherLine.p2))) { + p1ConnectedToSkeleton = true; + } + if (!p2ConnectedToSkeleton && (pointsEqual(p2, otherLine.p1) || pointsEqual(p2, otherLine.p2))) { + p2ConnectedToSkeleton = true; + } + if (p1ConnectedToSkeleton && p2ConnectedToSkeleton) break; // 최적화: 양쪽 다 연결되면 루프 종료 + } + + // 3. 최종 연결 상태 결정 + result.p1Connected = isP1OnBase || p1ConnectedToSkeleton; + result.p2Connected = isP2OnBase || p2ConnectedToSkeleton; + result.isConnected = result.p1Connected && result.p2Connected; + + // 4. 선이 완전히 연결되지 않은 경우, 끊어진 점에 대한 연장선 계산 + if (!result.isConnected) { + if (!result.p1Connected) { + // p1이 끊어진 경우: p2에서 시작하여 p1을 지나 연장하는 선의 교차점을 찾음 + const hit = extendFromP2TowardP1(p1, p2, baseLines, skeletonLines, lineIndex); + if (hit) { + // 교차점을 새로운 끝점으로 하는 연장선 정보 저장 + result.extendedLine = { p1: p2, p2: hit.point, hitType: hit.type }; + } else { + // 교차점이 없는 경우: 가장 가까운 기준선에 수직으로 투영 (Fallback) + result.extendedLine = extendToBaseLine(p1, baseLines); + } + } else if (!result.p2Connected) { + // p2가 끊어진 경우: p1에서 시작하여 p2를 지나 연장하는 선의 교차점을 찾음 + const hit = extendFromP2TowardP1(p2, p1, baseLines, skeletonLines, lineIndex); + if (hit) { + result.extendedLine = { p1: p1, p2: hit.point, hitType: hit.type }; + } else { + result.extendedLine = extendToBaseLine(p2, baseLines); + } + } + } + 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 connectedResult = isConnected(line, index); + const { isConnected: isLineConnected, p1Connected, p2Connected, extendedLine } = connectedResult; + + if (type === 'diagonal') { + diagonalLines.push({ line, index, length, type, isConnected: isLineConnected}); + } else { + straightLines.push({ line, index, length, type, isConnected: isLineConnected }); + } + + if (!isLineConnected) { + 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; + let projection = null; + + for (const baseLine of baseLines) { + const currentProjection = getProjectionPoint(point, baseLine); + const distance = getDistanceToLine(point, { + x1: point.x, + y1: point.y, + x2: currentProjection.x, + y2: currentProjection.y, + }); + + + if (distance < minDistance) { + minDistance = distance; + closestBaseLine = baseLine; + projection = currentProjection; + } + } + + if (closestBaseLine) { + // point에서 closestBaseLine으로 연장하는 라인 생성 + // 연장된 라인을 skeletonLines에 추가 + return { + p1: point, + p2: projection + } + } + return null; +}; +/** + * 점과 선분 사이의 거리를 계산하는 함수 + * @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.abs(vx * uy - vy * ux); // Perpendicular distance + 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} + */ +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 lineLengthSq = dx * dx + dy * dy; + + if (lineLengthSq === 0) { + // 선분이 점인 경우 + return { x: x1, y: y1 }; + } + + // t = ((px - x1) * dx + (py - y1) * dy) / lineLengthSq + const t = ((px - x1) * dx + (py - y1) * dy) / lineLengthSq; + + // 투영점이 선분 범위 내에 있는지 확인 + if (t >= 0 && t <= 1) { + // 투영점이 선분 내에 있음 + const projX = x1 + t * dx; + const projY = y1 + t * dy; + return { x: projX, y: projY }; + } else if (t < 0) { + // 투영점이 시작점 앞에 있음 + return { x: x1, y: y1 }; + } else { + // 투영점이 끝점 뒤에 있음 + return { x: x2, y: y2 }; + } +}; From 3aac4f8ac096fe5eaa0f65e757b36289f82ac9fe Mon Sep 17 00:00:00 2001 From: Cha Date: Wed, 24 Sep 2025 23:55:28 +0900 Subject: [PATCH 4/4] =?UTF-8?q?skeleton=20v6=20-=20=20=EA=B5=90=EC=A0=90?= =?UTF-8?q?=EC=B6=94=EA=B0=80=20=EC=A0=88=EC=82=AD=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/util/skeleton-utils.js | 233 ++++++++++++++++++++++++------------- 1 file changed, 149 insertions(+), 84 deletions(-) diff --git a/src/util/skeleton-utils.js b/src/util/skeleton-utils.js index ac74b217..4bd66594 100644 --- a/src/util/skeleton-utils.js +++ b/src/util/skeleton-utils.js @@ -48,16 +48,13 @@ export const drawSkeletonRidgeRoof = (roofId, canvas, textMode) => { // 'gable'인 경우, 의도적으로 내부선을 생성하지 않아 빈 공간을 만듭니다. } else if (lineType === 'wall') { // TODO: 'wall' 타입에 대한 처리가 필요합니다. - // 현재는 아무 작업도 하지 않지만, 향후 관련 로직이 이곳에 추가될 수 있습니다. - // 예를 들어, 벽에 맞닿는 부분의 선을 다르게 처리하거나 특정 정보를 추가할 수 있습니다. } else { - // 'gable' 또는 'wall'이 아닌 경우 (e.g., 'eaves') 내부선을 생성합니다. processEavesEdge(edgeResult, skeletonLines, processedInnerEdges); } } }); - // --- 3. 연결이 끊어진 선(케라바로 인해 생성됨)을 찾아 연장 --- + // --- 3. 연결이 끊어진 선을 찾아 연장 --- const { disconnectedLines } = findDisconnectedSkeletonLines(skeletonLines, baseLines); disconnectedLines.forEach(dLine => { const { index, extendedLine, p1Connected, p2Connected } = dLine; @@ -71,9 +68,12 @@ export const drawSkeletonRidgeRoof = (roofId, canvas, textMode) => { } }); + // --- 3.5. 모든 선을 교차점에서 분할 --- + const finalSkeletonLines = splitLinesAtIntersections(skeletonLines); + // --- 4. 최종 결과물을 지붕 객체에 저장하고 캔버스에 그리기 --- - roof.skeletonLines = skeletonLines; - roof.skeleton = rebuildSkeletonFromLines(skeletonLines, baseLines); // 데이터 구조 일관성 유지 + roof.skeletonLines = finalSkeletonLines; + roof.skeleton = rebuildSkeletonFromLines(finalSkeletonLines, baseLines); // 데이터 구조 일관성 유지 console.log("Skeleton processing complete. Storing final state.", roof.skeleton); const innerLines = []; @@ -123,7 +123,6 @@ function processEavesEdge(edgeResult, skeletonLines, processedInnerEdges) { const p1 = polygonPoints[i]; const p2 = polygonPoints[(i + 1) % polygonPoints.length]; - // 외벽선에 해당하는 스켈레톤 선은 제외하고 내부선만 추가 if (!isOuterEdge(p1, p2, [edgeResult.Edge])) { addRawLine(skeletonLines, processedInnerEdges, p1, p2, 'RIDGE', '#FF0000', 3); } @@ -132,6 +131,18 @@ function processEavesEdge(edgeResult, skeletonLines, processedInnerEdges) { // --- Helper Functions --- +/** + * 두 점이 거의 같은 위치에 있는지 확인합니다. + * @param {object} p1 - 점1 {x, y} + * @param {object} p2 - 점2 {x, y} + * @param {number} [epsilon=0.1] - 허용 오차 + * @returns {boolean} 동일한지 여부 + */ +const pointsEqual = (p1, p2, epsilon = 0.1) => { + return Math.abs(p1.x - p2.x) < epsilon && Math.abs(p1.y - p2.y) < epsilon; +}; + + /** * 두 점으로 이루어진 선분이 외벽선인지 확인합니다. * @param {object} p1 - 점1 {x, y} @@ -140,13 +151,11 @@ function processEavesEdge(edgeResult, skeletonLines, processedInnerEdges) { * @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; + return (pointsEqual(p1, lineStart) && pointsEqual(p2, lineEnd)) || + (pointsEqual(p1, lineEnd) && pointsEqual(p2, lineStart)); }); } @@ -187,11 +196,13 @@ function addRawLine(skeletonLines, processedInnerEdges, p1, p2, lineType, color, * @returns {boolean} 동일 여부 */ const isSameLine = (edgeStartX, edgeStartY, edgeEndX, edgeEndY, baseLine) => { - 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; + const p1 = { x: edgeStartX, y: edgeStartY }; + const p2 = { x: edgeEndX, y: edgeEndY }; + const baseP1 = { x: x1, y: y1 }; + const baseP2 = { x: x2, y: y2 }; + return (pointsEqual(p1, baseP1) && pointsEqual(p2, baseP2)) || + (pointsEqual(p1, baseP2) && pointsEqual(p2, baseP1)); }; // --- Disconnected Line Processing --- @@ -247,47 +258,44 @@ function getRayIntersectionWithSegment(rayStart, rayDir, segA, segB) { } /** - * 한 점에서 다른 점 방향으로 광선을 쏘아 가장 가까운 교차점을 찾습니다. - * @param {object} p1 - 광선의 방향을 결정하는 끝점 - * @param {object} p2 - 광선의 시작점 + * 주어진 점에서 특정 방향으로 광선을 쏘아 가장 가까운 교차점을 찾습니다. + * @param {object} rayOrigin - 광선의 시작점 + * @param {object} rayDirection - 광선의 방향 벡터 * @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 }; +function findClosestIntersection(rayOrigin, rayDirection, baseLines, skeletonLines, excludeIndex) { + const len = Math.sqrt(rayDirection.x ** 2 + rayDirection.y ** 2) || 1; + const dir = { x: rayDirection.x / len, y: rayDirection.y / len }; let closestHit = null; - const checkHit = (hit) => { - // 교차점이 원래 선분의 길이(len)보다 멀리 있어야 유효한 연장으로 간주 - if (hit && hit.t > len - 0.1) { + const targetLines = []; + if (Array.isArray(baseLines)) { + baseLines.forEach(line => targetLines.push({ p1: { x: line.x1, y: line.y1 }, p2: { x: line.x2, y: line.y2 } })); + } + if (Array.isArray(skeletonLines)) { + skeletonLines.forEach((line, i) => { + if (i !== excludeIndex) { + targetLines.push({ p1: line.p1, p2: line.p2 }); + } + }); + } + + targetLines.forEach(targetLine => { + const hit = getRayIntersectionWithSegment(rayOrigin, dir, targetLine.p1, targetLine.p2); + if (hit && hit.t > 1e-6) { 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); - }); - } - - if (Array.isArray(skeletonLines)) { - skeletonLines.forEach((seg, i) => { - if (i === excludeIndex) return; - const hit = getRayIntersectionWithSegment(p2, dir, seg.p1, seg.p2); - checkHit(hit); - }); - } + }); return closestHit; } + /** * 연결이 끊어진 스켈레톤 라인들을 찾아 연장 정보를 계산합니다. * @param {Array} skeletonLines - 스켈레톤 라인 배열 @@ -332,7 +340,9 @@ export const findDisconnectedSkeletonLines = (skeletonLines, baseLines) => { let extendedLine = null; if (!p1Connected) { - extendedLine = extendFromP2TowardP1(line.p1, line.p2, baseLines, skeletonLines, index); + const rayOrigin = line.p1; + const rayDirection = { x: line.p1.x - line.p2.x, y: line.p1.y - line.p2.y }; + extendedLine = findClosestIntersection(rayOrigin, rayDirection, baseLines, skeletonLines, index); if (!extendedLine) { let minDistance = Infinity; let projection = null; @@ -344,10 +354,12 @@ export const findDisconnectedSkeletonLines = (skeletonLines, baseLines) => { projection = p; } }); - if(projection) extendedLine = { point: projection }; + if (projection) extendedLine = { point: projection }; } } else if (!p2Connected) { - extendedLine = extendFromP2TowardP1(line.p2, line.p1, baseLines, skeletonLines, index); + const rayOrigin = line.p2; + const rayDirection = { x: line.p2.x - line.p1.x, y: line.p2.y - line.p1.y }; + extendedLine = findClosestIntersection(rayOrigin, rayDirection, baseLines, skeletonLines, index); if (!extendedLine) { let minDistance = Infinity; let projection = null; @@ -359,7 +371,7 @@ export const findDisconnectedSkeletonLines = (skeletonLines, baseLines) => { projection = p; } }); - if(projection) extendedLine = { point: projection }; + if (projection) extendedLine = { point: projection }; } } @@ -369,19 +381,94 @@ export const findDisconnectedSkeletonLines = (skeletonLines, baseLines) => { return { disconnectedLines }; }; - -// --- Skeleton Rebuilding Functions --- +// --- Line Splitting Functions --- /** - * 두 점이 거의 같은 위치에 있는지 확인합니다. - * @param {object} p1 - 점1 {x, y} - * @param {object} p2 - 점2 {x, y} - * @param {number} [epsilon=0.1] - 허용 오차 - * @returns {boolean} 동일한지 여부 + * 두 선분의 교차점을 찾습니다. (선분 내부에서 교차하는 경우만) + * @param {object} p1 - 선분1의 시작점 {x, y} + * @param {object} p2 - 선분1의 끝점 {x, y} + * @param {object} p3 - 선분2의 시작점 {x, y} + * @param {object} p4 - 선분2의 끝점 {x, y} + * @returns {object|null} 교차점 {x, y} 또는 null */ -const pointsEqual = (p1, p2, epsilon = 0.1) => { - return Math.abs(p1.x - p2.x) < epsilon && Math.abs(p1.y - p2.y) < epsilon; -}; +function getLineSegmentIntersection(p1, p2, p3, p4) { + const { x: x1, y: y1 } = p1; + const { x: x2, y: y2 } = p2; + const { x: x3, y: y3 } = p3; + const { x: x4, y: y4 } = p4; + + const den = (x1 - x2) * (y3 - y4) - (y1 - y2) * (x3 - x4); + if (Math.abs(den) < 1e-6) return null; + + const t = ((x1 - x3) * (y3 - y4) - (y1 - y3) * (x3 - x4)) / den; + const u = -((x1 - x2) * (y1 - y3) - (y1 - y2) * (x1 - x3)) / den; + + if (t >= 0 && t <= 1 && u >= 0 && u <= 1) { + const intersectX = x1 + t * (x2 - x1); + const intersectY = y1 + t * (y2 - y1); + return { x: intersectX, y: intersectY }; + } + return null; +} + +/** + * 스켈레톤 라인들을 모든 교점에서 분할합니다. + * @param {Array} skeletonLines - 분할할 스켈레톤 라인 배열 + * @returns {Array} - 모든 교점에서 분할된 새로운 라인 세그먼트 배열 + */ +function splitLinesAtIntersections(skeletonLines) { + if (!skeletonLines || skeletonLines.length === 0) return []; + + const finalSegments = new Map(); + const lines = [...skeletonLines]; + + for (let i = 0; i < lines.length; i++) { + const line1 = lines[i]; + const intersectionsOnLine1 = []; + + for (let j = 0; j < lines.length; j++) { + if (i === j) continue; + const line2 = lines[j]; + + const intersection = getLineSegmentIntersection(line1.p1, line1.p2, line2.p1, line2.p2); + if (intersection) { + if (!pointsEqual(intersection, line1.p1) && !pointsEqual(intersection, line1.p2)) { + intersectionsOnLine1.push(intersection); + } + } + } + + if (intersectionsOnLine1.length > 0) { + const pointsOnLine = [line1.p1, line1.p2, ...intersectionsOnLine1]; + pointsOnLine.sort((a, b) => { + const distA = (a.x - line1.p1.x) ** 2 + (a.y - line1.p1.y) ** 2; + const distB = (b.x - line1.p1.x) ** 2 + (b.y - line1.p1.y) ** 2; + return distA - distB; + }); + + for (let k = 0; k < pointsOnLine.length - 1; k++) { + const pA = pointsOnLine[k]; + const pB = pointsOnLine[k + 1]; + + const key = [pointToKey(pA), pointToKey(pB)].sort().join('|'); + if (!finalSegments.has(key)) { + const newSegment = { ...line1, p1: pA, p2: pB }; + finalSegments.set(key, newSegment); + } + } + } else { + const key = [pointToKey(line1.p1), pointToKey(line1.p2)].sort().join('|'); + if (!finalSegments.has(key)) { + finalSegments.set(key, line1); + } + } + } + + return Array.from(finalSegments.values()); +} + + +// --- Skeleton Rebuilding Functions --- /** * 점 객체를 고유한 문자열 키로 변환합니다. 정밀도 문제를 피하기 위해 소수점 자리를 고정합니다. @@ -404,15 +491,12 @@ const keyToPoint = (key) => { /** * 모든 라인 세그먼트로부터 그래프를 구축합니다. - * 각 정점(포인트)에 대해 연결된 이웃 정점 목록을 각도순으로 정렬하여 저장합니다. - * 이는 면(face)을 시계 반대 방향으로 순회하는 데 필수적입니다. * @param {Array} lines - `{p1, p2}` 형태의 라인 배열 * @returns {Map>} - 그래프 데이터 구조 */ const buildAngularSortedGraph = (lines) => { const graph = new Map(); - // 그래프에 양방향 간선을 추가하는 헬퍼 함수 const addEdge = (p1, p2) => { const key1 = pointToKey(p1); const key2 = pointToKey(p2); @@ -429,7 +513,6 @@ const buildAngularSortedGraph = (lines) => { lines.forEach(line => addEdge(line.p1, line.p2)); - // 각 정점의 이웃 리스트를 각도 기준으로 정렬 for (const neighbors of graph.values()) { neighbors.sort((a, b) => a.angle - b.angle); } @@ -439,30 +522,25 @@ const buildAngularSortedGraph = (lines) => { /** * 각도순으로 정렬된 그래프에서 모든 면(폴리곤)을 찾습니다. - * 평면 그래프의 모든 간선을 순회하며 아직 방문하지 않은 간선에서 출발하여 - * 하나의 면을 구성하는 사이클을 찾습니다. * @param {Map} graph - `buildAngularSortedGraph`로 생성된 그래프 * @returns {Array>} - 폴리곤(점들의 배열)들의 배열 */ const findFaces = (graph) => { const polygons = []; - const visitedHalfEdges = new Set(); // "p1_key->p2_key" 형식으로 방문한 반-간선 저장 + const visitedHalfEdges = new Set(); for (const [p1Key, neighbors] of graph.entries()) { for (const neighbor of neighbors) { const p2Key = pointToKey(neighbor.point); const halfEdge = `${p1Key}->${p2Key}`; - if (visitedHalfEdges.has(halfEdge)) { - continue; // 이미 다른 면을 통해 방문한 간선 - } + if (visitedHalfEdges.has(halfEdge)) continue; - // 새로운 면 탐색 시작 const newPolygon = []; let currentHalfEdge = halfEdge; while (!visitedHalfEdges.has(currentHalfEdge)) { - if (visitedHalfEdges.size > graph.size * 2) { // 무한 루프 방지 + if (visitedHalfEdges.size > graph.size * 2) { console.error("Infinite loop detected in face finding."); return []; } @@ -471,7 +549,6 @@ const findFaces = (graph) => { const [startKey, endKey] = currentHalfEdge.split('->'); newPolygon.push(keyToPoint(startKey)); - // 현재 간선의 끝점에서, 들어온 간선의 다음(CCW) 간선을 찾아 다음 경로로 설정 const endNodeNeighbors = graph.get(endKey); const incomingEdgeIndex = endNodeNeighbors.findIndex(n => pointToKey(n.point) === startKey); @@ -485,7 +562,6 @@ const findFaces = (graph) => { } if (newPolygon.length > 2) { - // 중복 폴리곤 방지 const polygonKey = newPolygon.map(p => pointToKey(p)).sort().join('|'); if (!polygons.some(p => p.key === polygonKey)) { polygons.push({ key: polygonKey, points: newPolygon }); @@ -499,29 +575,24 @@ const findFaces = (graph) => { /** * 후처리된 스켈레톤 라인과 외벽선을 기반으로 스켈레톤 유사 구조를 재생성합니다. - * @param {Array} skeletonLines - 내부 스켈레톤 라인 배열. {p1, p2} 또는 QLine({x1, y1, x2, y2}) 형식을 지원합니다. - * @param {Array} baseLines - 외벽선 QLine 객체 배열. (e.g., fabric.Line) - * x1, y1, x2, y2 속성을 가져야 합니다. + * @param {Array} skeletonLines - 내부 스켈레톤 라인 배열. + * @param {Array} baseLines - 외벽선 QLine 객체 배열. * @returns {object|null} - 원본 스켈레톤과 유사한 구조의 객체 { Edges: [...] }. 실패 시 null. */ export const rebuildSkeletonFromLines = (skeletonLines, baseLines) => { if (!skeletonLines || !baseLines) return null; - // 1. 모든 선분(내부선 + 외벽선)을 동일한 형식({p1, p2})으로 변환하여 결합합니다. - // 입력되는 skeletonLines의 타입이 QLine({x1, y1, x2, y2}) 형식일 수 있으므로 두 경우 모두 처리합니다. const allLines = skeletonLines.map(line => { - // { p1, p2 } 형태의 raw 객체 처리 if (line.p1 && line.p2) { return { p1: line.p1, p2: line.p2 }; } - // QLine 또는 fabric.Line과 같이 x1, y1, x2, y2 속성을 가진 객체 처리 if (typeof line.x1 === 'number' && typeof line.y1 === 'number' && typeof line.x2 === 'number' && typeof line.y2 === 'number') { return { p1: { x: line.x1, y: line.y1 }, p2: { x: line.x2, y: line.y2 } }; } console.warn('Unsupported line format in skeletonLines:', line); return null; - }).filter(Boolean); // 유효하지 않은 형식은 걸러냅니다. + }).filter(Boolean); baseLines.forEach(line => { allLines.push({ @@ -530,20 +601,16 @@ export const rebuildSkeletonFromLines = (skeletonLines, baseLines) => { }); }); - // 2. 그래프를 구축 const graph = buildAngularSortedGraph(allLines); - if(graph.size === 0) return { Edges: [] }; + if (graph.size === 0) return { Edges: [] }; - // 3. 그래프에서 모든 면(폴리곤)을 찾음 const polygons = findFaces(graph); - // 4. 각 외벽선에 해당하는 폴리곤을 찾아 스켈레톤 Edge 구조를 만듦 const rebuiltEdges = []; baseLines.forEach(baseLine => { const p1 = { x: baseLine.x1, y: baseLine.y1 }; const p2 = { x: baseLine.x2, y: baseLine.y2 }; - // 이 baseLine을 변으로 포함하는 폴리곤을 찾음 const associatedPolygon = polygons.find(polygon => { for (let i = 0; i < polygon.length; i++) { const polyP1 = polygon[i]; @@ -563,8 +630,6 @@ export const rebuildSkeletonFromLines = (skeletonLines, baseLines) => { End: { X: p2.x, Y: p2.y } }, Polygon: associatedPolygon.map(p => ({ X: p.x, Y: p.y })), - // 원본 skeleton 객체의 다른 속성들(e.g., roof_type)은 - // 라인 정보만으로는 재생성할 수 없으므로 포함하지 않습니다. }); } });