From 21943536c9a9ef078a77cdbe3fddddbd61d61eea Mon Sep 17 00:00:00 2001 From: ysCha Date: Tue, 16 Sep 2025 18:19:13 +0900 Subject: [PATCH] skeleton 20% --- src/util/skeleton-utils.js | 1844 +++++++++++++++++++++++++++++++----- 1 file changed, 1581 insertions(+), 263 deletions(-) diff --git a/src/util/skeleton-utils.js b/src/util/skeleton-utils.js index 77ce3412..bbf87407 100644 --- a/src/util/skeleton-utils.js +++ b/src/util/skeleton-utils.js @@ -1,13 +1,1571 @@ +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, isPointOnLine } from '@/util/canvas-util' + /** - * @file skeleton-utils.js - * @description 지붕 폴리곤의 스켈레톤(중심선)을 생성하고 Fabric.js 캔버스에 그리는 유틸리티 함수들을 포함합니다. + * 지붕 폴리곤의 스켈레톤(중심선)을 생성하고 캔버스에 그립니다. + * @param {string} roofId - 대상 지붕 객체의 ID + * @param {fabric.Canvas} canvas - Fabric.js 캔버스 객체 + * @param {string} textMode - 텍스트 표시 모드 + */ +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; + } + + // 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) +} + +/** + * 스켈레톤의 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) => { + // 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) + + 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) => { + console.log('=== Edge Properties 기반 후처리 시작 ===') + + if (!skeleton || !skeleton.Edges) return [] + + const innerLines = [] + const processedInnerEdges = new Set() + const rawLines = [] + + // 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] + + // 방향을 고려하지 않고 같은 라인인지 확인하는 함수 + + let edgeType = 'eaves' + let baseLineIndex = 0 + + processEavesEdge(edgeResult, baseLines, rawLines, processedInnerEdges, edgeIndex, baseLineIndex) + // // ✅ Edge 타입별 처리 분기 + // switch (edgeType) { + // case 'eaves': + // processEavesEdge(edgeResult, baseLines, rawLines, processedInnerEdges, edgeIndex, baseLineIndex) + // break + // + // case 'wall': + // processWallEdge(edgeResult, baseLines, rawLines, processedInnerEdges, edgeIndex) + // break + // + // case 'gable': + // processGableEdge(edgeResult, baseLines, rawLines, processedInnerEdges, edgeIndex, baseLineIndex) + // break + // + // default: + // console.warn(`알 수 없는 edge 타입: ${edgeType}`) + // processEavesEdge(edgeResult, baseLines, rawLines, processedInnerEdges, edgeIndex, baseLineIndex) + // } + } + + for (let baseLineIndex = 0; baseLineIndex < baseLines.length; baseLineIndex++) { + + if (baseLines[baseLineIndex].attributes.type === 'gable') { + // 일다 그려서 rawLines를 만들어 + 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 + + //외벽선 동일 라인이면 + if (isSameLine(startX, startY, endX, endY, baseLines[baseLineIndex])) { + processGableEdge(edgeResult, baseLines, rawLines, processedInnerEdges, edgeIndex, baseLineIndex) // + break // 매칭되는 라인을 찾았으므로 루프 종료 + } + + } + } + + } + + console.log(`처리된 rawLines: ${rawLines.length}개`) + + // 2. 겹치는 선분 병합 + // const mergedLines = mergeCollinearLines(rawLines) + // console.log('mergedLines', mergedLines) + // 3. QLine 객체로 변환 + for (const line of rawLines) { + 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, rawLines, processedInnerEdges) { + console.log(`processEavesEdge::`, rawLines) + + // 내부 선분 수집 (스케레톤은 다각형) + 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)) { + addRawLine(rawLines, processedInnerEdges, p1, p2, 'RIDGE', '#FF0000', 3) + } + } +} + +// ✅ WALL (벽) 처리 - 선분 개수 최소화 +function processWallEdge(edgeResult, baseLines, rawLines, 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(rawLines, processedInnerEdges, p1, p2, 'HIP', '#000000', 2) + } else { + console.log(`WALL: 짧은 선분 제거 (길이: ${lineLength.toFixed(1)})`) + } + } + } +} + +// ✅ GABLE (케라바) 처리 - 직선 생성, 다른 선분 제거 +function processGableEdge(edgeResult, baseLines, rawLines, 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, + } + // + // // polygonPoints와 gableMidpoint 비교: x 또는 y가 같은 점 찾기 (허용 오차 적용) + // const axisTolerance = 0.1 + // const sameXPoints = polygonPoints.filter((p) => Math.abs(p.x - gableMidpoint.x) < axisTolerance) + // const sameYPoints = polygonPoints.filter((p) => Math.abs(p.y - gableMidpoint.y) < axisTolerance) + // if (sameXPoints.length || sameYPoints.length) { + // console.log('GABLE: gableMidpoint와 같은 축의 폴리곤 점', { + // gableMidpoint, + // sameXPoints, + // sameYPoints, + // }) + // } + // + // // 폴리곤 중심점 (대략적) + // 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 colinearityTolerance = 0.1 + // + // // 폴리곤 선분 생성 (연속 점 쌍) + // const segments = [] + // for (let i = 0; i < polygonPoints.length; i++) { + // const p1 = polygonPoints[i] + // const p2 = polygonPoints[(i + 1) % polygonPoints.length] + // segments.push({ p1, p2 }) + // } + // + // // gableMidpoint와 같은 축(Y 또는 X)에 있는 수직/수평 선분만 추출 + // const sameAxisSegments = segments.filter(({ p1, p2 }) => { + // const isVertical = Math.abs(p1.x - p2.x) < colinearityTolerance + // const isHorizontal = Math.abs(p1.y - p2.y) < colinearityTolerance + // const sameXAxis = isVertical && Math.abs(p1.x - gableMidpoint.x) < axisTolerance + // const sameYAxis = isHorizontal && Math.abs(p1.y - gableMidpoint.y) < axisTolerance + // return sameXAxis || sameYAxis + // }) + // + // // 가장 가까운(또는 가장 긴) 용마루 후보 선택 + // let ridgeCandidate = null + // if (sameAxisSegments.length) { + // // 1) 중점과의 최단거리 기준 + // ridgeCandidate = sameAxisSegments.reduce((best, seg) => { + // const mid = { x: (seg.p1.x + seg.p2.x) / 2, y: (seg.p1.y + seg.p2.y) / 2 } + // const dist2 = (mid.x - gableMidpoint.x) ** 2 + (mid.y - gableMidpoint.y) ** 2 + // if (!best) return { seg, score: dist2 } + // return dist2 < best.score ? { seg, score: dist2 } : best + // }, null)?.seg + // + // + // } + + // + + + const selectBaseLine = baseLines[baseLineIndex]; + console.log('selectBaseLine:', selectBaseLine); + console.log('rawLines:', rawLines) + //selectBaseLine 과 같은 edgeResult.ed + + // 대각선 보정(fallback) 제거: 항상 수평/수직 내부 용마루만 생성 + + for (let i = rawLines.length - 1; i >= 0; i--) { + const line = rawLines[i]; + console.log('line:', line) + console.log('line.attributes.type:', line.attributes.type) + if (line.attributes.type === LINE_TYPE.SUBLINE.HIP || line.attributes.type === 'HIP') { + + // 선택한 기준선 을 중심으로 대각선 삭제 + // Get line and edge points + const edgeStart = { x: edgeResult.Edge.Begin.X, y: edgeResult.Edge.Begin.Y }; + const edgeEnd = { x: edgeResult.Edge.End.X, y: edgeResult.Edge.End.Y }; + const lineStart = { x: line.p1.x, y: line.p1.y }; + const lineEnd = { x: line.p2.x, y: line.p2.y }; + + const pointsEqual = (p1, p2) => { + return p1.x === p2.x && p1.y === p2.y; + } + // Check if line shares an endpoint with the edge + const sharesStartPoint = pointsEqual(edgeStart, lineStart) || pointsEqual(edgeStart, lineEnd); + const sharesEndPoint = pointsEqual(edgeEnd, lineStart) || pointsEqual(edgeEnd, lineEnd); + + if (sharesStartPoint || sharesEndPoint) { + rawLines.splice(i, 1); + // ridge extension logic can go here + } + + + + + }else if (line.attributes.type === LINE_TYPE.SUBLINE.RIDGE || line.attributes.type === 'RIDGE') { + //마루일때 + + if(edgeResult.Polygon.length > 3){ + 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; + + for (const polyPoint of edgeResult.Polygon) { + if (!hasP1 && polyPoint.X === lineP1.x && polyPoint.Y === lineP1.y) { + hasP1 = true; + } + if (!hasP2 && polyPoint.X === lineP2.x && polyPoint.Y === lineP2.y) { + hasP2 = true; + } + + // Early exit if both points are found + if (hasP1 && hasP2) break; + } + + if (hasP1 && hasP2) { + rawLines.splice(i, 1); + } + + } + } + + console.log('result rawLines:', rawLines) + } + + + // addRawLine( + // rawLines, + // processedInnerEdges, + // gableMidpoint, + // polygonCenter, + // 'RIDGE', + // '#0000FF', // 파란색으로 구분 + // 3, // 두껍게 + // ) +} + +// ✅ 헬퍼 함수들 +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(rawLines, 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 + + rawLines.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 - 선택된 외곽선의 인덱스 */ -import SkeletonBuilder from '@/lib/skeletons/SkeletonBuilder'; -import { fabric } from 'fabric' -import { LINE_TYPE, POLYGON_TYPE } from '@/common/common' -import { QLine } from '@/components/fabric/QLine'; -import { calcLinePlaneSize } from '@/util/qpolygon-utils'; +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}가 범위 [0, ${totalPoints - 1}]을 벗어났습니다`) + 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 + + 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) + } +} /** * 지붕 폴리곤의 좌표를 스켈레톤 생성에 적합한 형태로 전처리합니다. @@ -41,262 +1599,22 @@ const preprocessPolygonCoordinates = (initialPoints) => { return coordinates; }; -/** - * 생성된 스켈레톤 엣지들로부터 중복되지 않는 고유한 선분 목록을 추출합니다. - * 각 엣지는 작은 폴리곤으로 구성되며, 인접한 엣지들은 선분을 공유하므로 중복 제거가 필요합니다. - * @param {Array} skeletonEdges - SkeletonBuilder로부터 반환된 엣지 배열 - * @returns {Array} 고유한 선분 객체의 배열 (e.g., [{x1, y1, x2, y2, edgeIndex}, ...]) - */ -const extractUniqueLinesFromEdges = (skeletonEdges) => { - const uniqueLines = new Set(); - const linesToDraw = []; +const isSameLine = (edgeStartX, edgeStartY, edgeEndX, edgeEndY, baseLine) => { + const tolerance = 0.1 - skeletonEdges.forEach((edge, edgeIndex) => { - // 엣지 데이터가 유효한 폴리곤인지 확인 - if (!edge || !edge.Polygon || edge.Polygon.length < 2) { - console.warn(`Edge ${edgeIndex} has invalid polygon data:`, edge.Polygon); - return; - } + // 시계방향 매칭 (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 - // 폴리곤의 각 변을 선분으로 변환 - for (let i = 0; i < edge.Polygon.length; i++) { - const p1 = edge.Polygon[i]; - const p2 = edge.Polygon[(i + 1) % edge.Polygon.length]; // 다음 점 (마지막 점은 첫 점과 연결) + // 반시계방향 매칭 (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 - // 선분의 시작점과 끝점을 일관된 순서로 정렬하여 정규화된 문자열 키 생성 - // 이를 통해 동일한 선분(방향만 다른 경우 포함)을 식별하고 중복을 방지 - const normalizedLineKey = p1.X < p2.X || (p1.X === p2.X && p1.Y < p2.Y) - ? `${p1.X},${p1.Y}-${p2.X},${p2.Y}` - : `${p2.X},${p2.Y}-${p1.X},${p1.Y}`; - - // Set에 정규화된 키가 없으면 새로운 선분으로 간주하고 추가 - if (!uniqueLines.has(normalizedLineKey)) { - uniqueLines.add(normalizedLineKey); - linesToDraw.push({ - x1: p1.X, y1: p1.Y, - x2: p2.X, y2: p2.Y, - edgeIndex - }); - } - } - }); - - return linesToDraw; -}; - -/** - * 지붕 폴리곤의 스켈레톤(중심선)을 생성하고 캔버스에 그립니다. - * @param {string} roofId - 대상 지붕 객체의 ID - * @param {fabric.Canvas} canvas - Fabric.js 캔버스 객체 - * @param {string} textMode - 텍스트 표시 모드 - */ -export const drawSkeletonRidgeRoof = (roofId, canvas, textMode) => { - try { - const roof = canvas?.getObjects().find((object) => object.id === roofId); - const wall = canvas.getObjects().find((object) => object.name === POLYGON_TYPE.WALL && object.attributes.roofId === roofId) - - if (!roof) { - console.error(`Roof with id "${roofId}" not found.`); - return; - } - - - // 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 baseLines = wall.baseLines.filter((line) => line.attributes.planeSize > 0) - const baseLinePoints = baseLines.map((line) => ({ x: line.x1, y: line.y1 })) - - - // 3. 스켈레톤 생성 - const multiPolygon = [[coordinates]]; // GeoJSON MultiPolygon 형식 - const skeleton = SkeletonBuilder.BuildFromGeoJSON(multiPolygon); - - if (!skeleton || !skeleton.Edges || skeleton.Edges.length === 0) { - console.log('No valid skeleton edges found for this roof.'); - return; - } - - // 4. 스켈레톤 엣지에서 고유 선분 추출 - const linesToDraw = extractUniqueLinesFromEdges(skeleton.Edges); - - // 5. 캔버스에 스켈레톤 라인 렌더링 - const skeletonLines = []; - const outerLines = pointsToLines(coordinates); - - for (const baseLine of baseLines) { - const { type } = baseLine.get("attributes"); - - if(type === LINE_TYPE.WALLLINE.EAVES) { - - - - }else if(type === LINE_TYPE.WALLLINE.RIDGE) { - - } - } - - linesToDraw.forEach((line, index) => { - // 외곽선과 겹치는 스켈레톤 라인은 그리지 않음 - const isOverlapping = outerLines.some(outerLine => linesOverlap(line, outerLine)); - if (isOverlapping) { - // Array.find()를 사용하여 baseLines 배열에서 일치하는 라인을 찾습니다. - const foundBaseLine = baseLines.filter(baseLine => { - // baseLine (fabric.QLine)에서 좌표를 추출합니다. - const { p1: baseP1, p2: baseP2 } = getPointsFromQLine(baseLine); - - const attributes = baseLine.get('attributes'); - - // 2. 속성 객체에서 type 값을 추출합니다. - const type = attributes.type; - - // 이제 'type' 변수를 조건문 등에서 사용할 수 있습니다. - console.log('라인 타입:', type); - - // lineToDraw의 좌표 (p1, p2)와 baseLine의 좌표를 비교합니다. - // 라인 방향이 다를 수 있으므로 정방향과 역방향 모두 확인합니다. - - // 정방향 일치: (p1 -> p2) == (baseP1 -> baseP2) - const forwardMatch = - line.x1 === baseP1.x && line.y1 === baseP1.y && - line.x2 === baseP2.x && line.y2 === baseP2.y; - - // 역방향 일치: (p1 -> p2) == (baseP2 -> baseP1) - const reverseMatch = - line.x1 === baseP2.x && line.y1 === baseP2.y && - line.x2 === baseP1.x && line.y2 === baseP1.y; - - return forwardMatch || reverseMatch; - }); - - // 일치하는 라인을 찾았는지 확인 - if (foundBaseLine) { - console.log(`linesToDraw[${index}]와 일치하는 라인을 찾았습니다:`, foundBaseLine); - // 여기서 foundBaseLine을 사용하여 필요한 작업을 수행할 수 있습니다. - } else { - - console.log(`linesToDraw[${index}]에 대한 일치하는 라인을 찾지 못했습니다.`); - } - console.log(`Skeleton line (edge ${line.edgeIndex}) is overlapping with a baseLine. It will not be drawn.`); - return; - } - - const isDiagonal = Math.abs(line.x1 - line.x2) > 1e-6 && Math.abs(line.y1 - line.y2) > 1e-6; - const isEaves = baseLinePoints.some(point => linesOverlap(line, point)); - - const skeletonLine = new QLine([line.x1, line.y1, line.x2, line.y2], { - parentId: roofId, - stroke: '#ff0000', // 스켈레톤은 빨간색으로 표시 - strokeWidth: 2, - strokeDashArray: [3, 3], // 점선으로 표시 - name: isDiagonal ? LINE_TYPE.SUBLINE.HIP : LINE_TYPE.SUBLINE.RIDGE, - fontSize: roof.fontSize || 12, - textMode: textMode, - attributes: { - roofId: roofId, - type: LINE_TYPE.WALLLINE.EAVES, // 스켈레톤 타입 식별자 - skeletonIndex: line.edgeIndex, - lineIndex: index, - planeSize: calcLinePlaneSize(line), - actualSize: calcLinePlaneSize(line), // 실제 크기는 필요시 별도 계산 로직 추가 - }, - }); - - skeletonLine.startPoint = { x: line.x1, y: line.y1 }; - skeletonLine.endPoint = { x: line.x2, y: line.y2 }; - - skeletonLines.push(skeletonLine); - canvas.add(skeletonLine); - - // 6. roof 객체에 스켈레톤 라인 정보 업데이트 - roof.innerLines = [...(roof.innerLines || []), ...skeletonLines]; - skeletonLines.forEach(line => line.bringToFront()); - - canvas.renderAll(); - console.log(`Successfully drew ${linesToDraw.length} unique skeleton lines from ${skeleton.Edges.length} polygons.`); - - - - }); - - - - } catch (error) { - console.error('An error occurred while generating the skeleton:', error); - } -}; - -/** - * 두 선분이 동일 선상에 있으면서 서로 겹치는지 확인합니다. - * @param {object} line1 - 첫 번째 선분 객체 { x1, y1, x2, y2 } - * @param {object} line2 - 두 번째 선분 객체 { x1, y1, x2, y2 } - * @param {number} [epsilon=1e-6] - 부동 소수점 계산 오차 허용 범위 - * @returns {boolean} 동일 선상에서 겹치면 true, 아니면 false - */ -function linesOverlap(line1, line2, epsilon = 1e-6) { - // 1. 세 점의 면적 계산을 통해 동일 선상에 있는지 확인 (면적이 0에 가까우면 동일 선상) - const area1 = (line1.x2 - line1.x1) * (line2.y1 - line1.y1) - (line2.x1 - line1.x1) * (line1.y2 - line1.y1); - const area2 = (line1.x2 - line1.x1) * (line2.y2 - line1.y1) - (line2.x2 - line1.x1) * (line1.y2 - line1.y1); - - if (Math.abs(area1) > epsilon || Math.abs(area2) > epsilon) { - return false; // 동일 선상에 없음 - } - - // 2. 동일 선상에 있을 경우, 두 선분의 범위(x, y)가 겹치는지 확인 - const xOverlap = Math.max(line1.x1, line1.x2) >= Math.min(line2.x1, line2.x2) && - Math.max(line2.x1, line2.x2) >= Math.min(line1.x1, line1.x2); - - const yOverlap = Math.max(line1.y1, line1.y2) >= Math.min(line2.y1, line2.y2) && - Math.max(line2.y1, line2.y2) >= Math.min(line1.y1, line1.y2); - - return xOverlap && yOverlap; -} - -/** - * 점들의 배열을 닫힌 폴리곤을 구성하는 선분 객체의 배열로 변환합니다. - * @param {Array>} points - [x, y] 형태의 점 좌표 배열 - * @returns {Array<{x1: number, y1: number, x2: number, y2: number}>} 선분 객체 배열 - */ -function pointsToLines(points) { - if (!points || points.length < 2) { - return []; - } - - const lines = []; - const numPoints = points.length; - - for (let i = 0; i < numPoints; i++) { - const startPoint = points[i]; - const endPoint = points[(i + 1) % numPoints]; // 마지막 점은 첫 번째 점과 연결 - - lines.push({ - x1: startPoint[0], - y1: startPoint[1], - x2: endPoint[0], - y2: endPoint[1], - }); - } - - return lines; -} - - -/** - * fabric.QLine에서 시작점과 끝점을 가져옵니다. - * @param {fabric.QLine} line - * @returns {{p1: {x: number, y: number}, p2: {x: number, y: number}}} - */ -export const getPointsFromQLine = (line) => { - return { p1: { x: line.x1, y: line.y1 }, p2: { x: line.x2, y: line.y2 } }; -}; \ No newline at end of file + return clockwiseMatch || counterClockwiseMatch +} \ No newline at end of file