import { LINE_TYPE, POLYGON_TYPE } from '@/common/common' import { SkeletonBuilder } from '@/lib/skeletons' import { calcLinePlaneSize, toGeoJSON } from '@/util/qpolygon-utils' import { QLine } from '@/components/fabric/QLine' /** * 지붕 폴리곤의 스켈레톤(중심선)을 생성하고 캔버스에 그립니다. * @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) if (!roof) { 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; } 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; } // 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]]) console.log(`지붕 형태: ${skeleton.roof_type}`, skeleton.edge_analysis) // 스켈레톤 데이터를 기반으로 내부선 생성 roof.innerLines = createInnerLinesFromSkeleton(skeleton, roof.lines, roof, canvas, textMode, skeletonLines) // 캔버스에 스켈레톤 상태 저장 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; } 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, }); canvas.add(innerLine); innerLine.bringToFront(); innerLines.push(innerLine); }); canvas.renderAll(); return innerLines; } /** * EAVES(처마) Edge를 처리하여 내부 스켈레톤 선을 추가합니다. * @param {object} edgeResult - 스켈레톤 Edge 데이터 * @param {Array} skeletonLines - 스켈레톤 라인 배열 * @param {Set} processedInnerEdges - 중복 처리를 방지하기 위한 Set */ function processEavesEdge(edgeResult, skeletonLines, processedInnerEdges) { 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, [edgeResult.Edge])) { addRawLine(skeletonLines, processedInnerEdges, p1, p2, 'RIDGE', '#FF0000', 3); } } } /** * 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 --- /** * 두 점으로 이루어진 선분이 외벽선인지 확인합니다. * @param {object} p1 - 점1 {x, y} * @param {object} p2 - 점2 {x, y} * @param {Array} edges - 확인할 외벽선 Edge 배열 * @returns {boolean} 외벽선 여부 */ function isOuterEdge(p1, p2, edges) { const tolerance = 0.1; return edges.some(edge => { const lineStart = { x: edge.Begin.X, y: edge.Begin.Y }; const lineEnd = { x: edge.End.X, y: edge.End.Y }; const forwardMatch = Math.abs(lineStart.x - p1.x) < tolerance && Math.abs(lineStart.y - p1.y) < tolerance && Math.abs(lineEnd.x - p2.x) < tolerance && Math.abs(lineEnd.y - p2.y) < tolerance; const backwardMatch = Math.abs(lineStart.x - p2.x) < tolerance && Math.abs(lineStart.y - p2.y) < tolerance && Math.abs(lineEnd.x - p1.x) < tolerance && Math.abs(lineEnd.y - p1.y) < tolerance; return forwardMatch || backwardMatch; }); } /** * 스켈레톤 라인 배열에 새로운 라인을 추가합니다. (중복 방지) * @param {Array} skeletonLines - 스켈레톤 라인 배열 * @param {Set} processedInnerEdges - 처리된 Edge 키 Set * @param {object} p1 - 시작점 * @param {object} p2 - 끝점 * @param {string} lineType - 라인 타입 * @param {string} color - 색상 * @param {number} width - 두께 */ 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 dx = Math.abs(p2.x - p1.x); const dy = Math.abs(p2.y - p1.y); const isDiagonal = dx > 0.1 && dy > 0.1; const normalizedType = isDiagonal ? LINE_TYPE.SUBLINE.HIP : lineType; skeletonLines.push({ p1, 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, width }, }); } /** * 폴리곤 좌표를 스켈레톤 생성에 적합하게 전처리합니다 (중복 제거, 시계 방향 정렬). * @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} 동일 여부 */ 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; }; // --- Disconnected Line Processing --- /** * 점을 선분에 투영한 점의 좌표를 반환합니다. * @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 }; const t = ((px - x1) * dx + (py - y1) * dy) / lineLengthSq; if (t < 0) return { x: x1, y: y1 }; if (t > 1) return { x: x2, y: y2 }; return { x: x1 + t * dx, y: y1 + t * dy }; }; /** * 광선(Ray)과 선분(Segment)의 교차점을 찾습니다. * @param {object} rayStart - 광선의 시작점 * @param {object} rayDir - 광선의 방향 벡터 * @param {object} segA - 선분의 시작점 * @param {object} segB - 선분의 끝점 * @returns {{point: object, t: number}|null} 교차점 정보 또는 null */ function getRayIntersectionWithSegment(rayStart, rayDir, segA, segB) { const p = rayStart; const r = rayDir; const q = segA; const s = { x: segB.x - segA.x, y: segB.y - segA.y }; const rxs = r.x * s.y - r.y * s.x; if (Math.abs(rxs) < 1e-6) return null; // 평행 const q_p = { x: q.x - p.x, y: q.y - p.y }; const t = (q_p.x * s.y - q_p.y * s.x) / rxs; const u = (q_p.x * r.y - q_p.y * r.x) / rxs; if (t >= -1e-6 && u >= -1e-6 && u <= 1 + 1e-6) { return { point: { x: p.x + t * r.x, y: p.y + t * r.y }, t }; } return null; } /** * 한 점에서 다른 점 방향으로 광선을 쏘아 가장 가까운 교차점을 찾습니다. * @param {object} p1 - 광선의 방향을 결정하는 끝점 * @param {object} p2 - 광선의 시작점 * @param {Array} baseLines - 외벽선 배열 * @param {Array} skeletonLines - 스켈레톤 라인 배열 * @param {number} excludeIndex - 검사에서 제외할 현재 라인의 인덱스 * @returns {object|null} 가장 가까운 교차점 정보 또는 null */ function extendFromP2TowardP1(p1, p2, baseLines, skeletonLines, excludeIndex) { const dirVec = { x: p1.x - p2.x, y: p1.y - p2.y }; const len = Math.sqrt(dirVec.x * dirVec.x + dirVec.y * dirVec.y) || 1; const dir = { x: dirVec.x / len, y: dirVec.y / len }; let closestHit = null; const checkHit = (hit) => { if (hit && hit.t > len - 0.1) { // 원래 선분의 끝점(p1) 너머에서 교차하는지 확인 if (!closestHit || hit.t < closestHit.t) { closestHit = hit; } } }; if (Array.isArray(baseLines)) { baseLines.forEach(baseLine => { const hit = getRayIntersectionWithSegment(p2, dir, { x: baseLine.x1, y: baseLine.y1 }, { x: baseLine.x2, y: baseLine.y2 }); checkHit(hit); }); } 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 - 스켈레톤 라인 배열 * @param {Array} baseLines - 외벽선 배열 * @returns {object} 끊어진 라인 정보가 담긴 객체 */ export const findDisconnectedSkeletonLines = (skeletonLines, baseLines) => { if (!skeletonLines?.length) return { disconnectedLines: [] }; const disconnectedLines = []; const pointsEqual = (p1, p2, epsilon = 0.1) => Math.abs(p1.x - p2.x) < epsilon && Math.abs(p1.y - p2.y) < epsilon; const isPointOnBase = (point) => baseLines?.some(baseLine => { const { x1, y1, x2, y2 } = baseLine; if (pointsEqual(point, { x: x1, y: y1 }) || pointsEqual(point, { x: x2, y: y2 })) return true; const dist = Math.sqrt(Math.pow(x2 - x1, 2) + Math.pow(y2 - y1, 2)); const dist1 = Math.sqrt(Math.pow(point.x - x1, 2) + Math.pow(point.y - y1, 2)); const dist2 = Math.sqrt(Math.pow(point.x - x2, 2) + Math.pow(point.y - y2, 2)); return Math.abs(dist - (dist1 + dist2)) < 0.1; }) || false; const isConnected = (line, lineIndex) => { const { p1, p2 } = line; let p1Connected = isPointOnBase(p1); let p2Connected = isPointOnBase(p2); if (!p1Connected || !p2Connected) { for (let i = 0; i < skeletonLines.length; i++) { if (i === lineIndex) continue; const other = skeletonLines[i]; if (!p1Connected && (pointsEqual(p1, other.p1) || pointsEqual(p1, other.p2))) p1Connected = true; if (!p2Connected && (pointsEqual(p2, other.p1) || pointsEqual(p2, other.p2))) p2Connected = true; if (p1Connected && p2Connected) break; } } return { p1Connected, p2Connected }; }; skeletonLines.forEach((line, index) => { const { p1Connected, p2Connected } = isConnected(line, index); if (p1Connected && p2Connected) return; let extendedLine = null; if (!p1Connected) { extendedLine = extendFromP2TowardP1(line.p1, line.p2, baseLines, skeletonLines, index); if (!extendedLine) { let closestBaseLine = null; let minDistance = Infinity; let projection = null; baseLines.forEach(base => { const p = getProjectionPoint(line.p1, base); 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; } }); if(projection) extendedLine = { point: projection }; } } 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 => { const p = getProjectionPoint(line.p2, base); 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; } }); if(projection) extendedLine = { point: projection }; } } disconnectedLines.push({ line, index, p1Connected, p2Connected, extendedLine }); }); return { disconnectedLines }; }; /** * skeletonLines와 selectBaseLine을 이용하여 다각형이 되는 좌표를 구합니다. * selectBaseLine의 좌표는 제외합니다. * @param {Array} skeletonLines - 스켈레톤 라인 배열 * @param {Object} selectBaseLine - 선택된 베이스 라인 (p1, p2 속성을 가진 객체) * @returns {Array>} 다각형 좌표 배열의 배열 */ const createPolygonsFromSkeletonLines = (skeletonLines, selectBaseLine) => { if (!skeletonLines?.length) return []; // 1. 모든 교차점 찾기 const intersections = findAllIntersections(skeletonLines); // 2. 모든 포인트 수집 (엔드포인트 + 교차점) const allPoints = collectAllPoints(skeletonLines, intersections); // 3. selectBaseLine 상의 점들 제외 const filteredPoints = allPoints.filter(point => { if (!selectBaseLine?.startPoint || !selectBaseLine?.endPoint) return true; // 점이 selectBaseLine 상에 있는지 확인 return !isPointOnSegment( point, selectBaseLine.startPoint, selectBaseLine.endPoint ); }); }; /** * 스켈레톤 라인들 간의 모든 교차점을 찾습니다. * @param {Array} skeletonLines - 스켈레톤 라인 배열 (각 요소는 {p1: {x, y}, p2: {x, y}} 형태) * @returns {Array} 교차점 배열 */ const findAllIntersections = (skeletonLines) => { const intersections = []; const processedPairs = new Set(); for (let i = 0; i < skeletonLines.length; i++) { for (let j = i + 1; j < skeletonLines.length; j++) { const pairKey = `${i}-${j}`; if (processedPairs.has(pairKey)) continue; processedPairs.add(pairKey); const line1 = skeletonLines[i]; const line2 = skeletonLines[j]; // 두 라인이 교차하는지 확인 const intersection = getLineIntersection( line1.p1, line1.p2, line2.p1, line2.p2 ); if (intersection) { // 교차점이 실제로 두 선분 위에 있는지 확인 if (isPointOnSegment(intersection, line1.p1, line1.p2) && isPointOnSegment(intersection, line2.p1, line2.p2)) { intersections.push(intersection); } } } } return intersections; }; /** * 스켈레톤 라인들과 교차점들을 모아서 모든 포인트를 수집합니다. * @param {Array} skeletonLines - 스켈레톤 라인 배열 * @param {Array} intersections - 교차점 배열 * @returns {Array} 모든 포인트 배열 */ const collectAllPoints = (skeletonLines, intersections) => { const allPoints = new Map(); const pointKey = (point) => `${point.x.toFixed(3)},${point.y.toFixed(3)}`; // 스켈레톤 라인의 엔드포인트들 추가 skeletonLines.forEach(line => { const key1 = pointKey(line.p1); const key2 = pointKey(line.p2); if (!allPoints.has(key1)) { allPoints.set(key1, { ...line.p1 }); } if (!allPoints.has(key2)) { allPoints.set(key2, { ...line.p2 }); } }); // 교차점들 추가 intersections.forEach(intersection => { const key = pointKey(intersection); if (!allPoints.has(key)) { allPoints.set(key, { ...intersection }); } }); return Array.from(allPoints.values()); }; // 필요한 유틸리티 함수들 const getLineIntersection = (p1, p2, p3, p4) => { const x1 = p1.x, y1 = p1.y; const x2 = p2.x, y2 = p2.y; const x3 = p3.x, y3 = p3.y; const x4 = p4.x, y4 = p4.y; const denom = (x1 - x2) * (y3 - y4) - (y1 - y2) * (x3 - x4); if (Math.abs(denom) < 1e-10) return null; const t = ((x1 - x3) * (y3 - y4) - (y1 - y3) * (x3 - x4)) / denom; const u = -((x1 - x2) * (y1 - y3) - (y1 - y2) * (x1 - x3)) / denom; if (t >= 0 && t <= 1 && u >= 0 && u <= 1) { return { x: x1 + t * (x2 - x1), y: y1 + t * (y2 - y1) }; } return null; }; const isPointOnSegment = (point, segStart, segEnd) => { const tolerance = 1e-6; const crossProduct = (point.y - segStart.y) * (segEnd.x - segStart.x) - (point.x - segStart.x) * (segEnd.y - segStart.y); if (Math.abs(crossProduct) > tolerance) return false; const dotProduct = (point.x - segStart.x) * (segEnd.x - segStart.x) + (point.y - segStart.y) * (segEnd.y - segStart.y); const squaredLength = (segEnd.x - segStart.x) ** 2 + (segEnd.y - segStart.y) ** 2; return dotProduct >= 0 && dotProduct <= squaredLength; }; // Export all necessary functions export { findAllIntersections, collectAllPoints, createPolygonsFromSkeletonLines };