diff --git a/src/util/skeleton-utils.js b/src/util/skeleton-utils.js index 85211624..8ed97d37 100644 --- a/src/util/skeleton-utils.js +++ b/src/util/skeleton-utils.js @@ -17,7 +17,7 @@ export const drawSkeletonRidgeRoof = (roofId, canvas, textMode, existingSkeleton return; } - // 1. 지붕 폴리곤 좌표 전처리 + // 지붕 폴리곤 좌표 전처리 const coordinates = preprocessPolygonCoordinates(roof.points); if (coordinates.length < 3) { console.warn("Polygon has less than 3 unique points. Cannot generate skeleton."); @@ -30,7 +30,7 @@ export const drawSkeletonRidgeRoof = (roofId, canvas, textMode, existingSkeleton return; } - // 2. 스켈레톤 생성 및 그리기 + // 스켈레톤 생성 및 그리기 skeletonBuilder(roofId, canvas, textMode, roof, existingSkeletonLines) } @@ -46,65 +46,70 @@ export const skeletonBuilder = (roofId, canvas, textMode, roof, skeletonLines) = const geoJSONPolygon = toGeoJSON(roof.points) try { - // SkeletonBuilder는 닫히지 않은 폴리곤을 기대하므로 마지막 점 제거 geoJSONPolygon.pop() const skeleton = SkeletonBuilder.BuildFromGeoJSON([[geoJSONPolygon]]) - console.log(`지붕 형태: ${skeleton.roof_type}`, skeleton.edge_analysis) - // 스켈레톤 데이터를 기반으로 내부선 생성 const innerLines = createInnerLinesFromSkeleton(skeleton, roof.lines, roof, canvas, textMode, skeletonLines) roof.innerLines = innerLines - // 캔버스에 스켈레톤 상태 저장 - if (!canvas.skeletonStates) { - canvas.skeletonStates = {} - } + if (!canvas.skeletonStates) canvas.skeletonStates = {} canvas.skeletonStates[roofId] = true canvas.renderAll() } catch (e) { console.error('스켈레톤 생성 중 오류 발생:', e) - if (canvas.skeletonStates) { - canvas.skeletonStates[roofId] = false - } + 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 - 스켈레톤 라인 배열 (수정 대상) + * 스켈레톤 결과와 외벽선 정보를 바탕으로 내부선을 생성합니다. + * @param {object} skeleton - 스켈레톤 객체 + * @param {Array} baseLines - 외벽선 배열 + * @param {fabric.Object} roof - 지붕 객체 + * @param {fabric.Canvas} canvas - 캔버스 객체 + * @param {string} textMode - 텍스트 표시 모드 + * @param {Array} skeletonLines - 스켈레톤 라인 배열 (이 함수 내에서 생성 및 수정됨) * @returns {Array} 생성된 내부선(QLine) 배열 */ const createInnerLinesFromSkeleton = (skeleton, baseLines, roof, canvas, textMode, skeletonLines) => { if (!skeleton?.Edges) return [] const processedInnerEdges = new Set() + const gableEdges = []; - // 1. 모든 Edge를 순회하며 기본 스켈레톤 선(용마루)을 수집합니다. - skeleton.Edges.forEach(edgeResult => { - processEavesEdge(edgeResult, skeletonLines, processedInnerEdges) - }); - - // 2. 케라바(Gable) 속성을 가진 외벽선에 해당하는 스켈레톤을 후처리합니다. + // 1단계: 모든 내부 스켈레톤 라인을 생성하고, 케라바(Gable) Edge를 식별합니다. skeleton.Edges.forEach(edgeResult => { + processEavesEdge(edgeResult, skeletonLines, processedInnerEdges); 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) { - processGableEdge(edgeResult, baseLines, skeletonLines); + if (baseLines.some(line => line.attributes.type === 'gable' && isSameLine(Begin.X, Begin.Y, End.X, End.Y, line))) { + gableEdges.push(edgeResult); } }); - // 3. 최종적으로 정리된 스켈레톤 선들을 QLine 객체로 변환하여 캔버스에 추가합니다. + // 2단계: 모든 케라바 영역에 포함된 불필요한 내부 선들을 한번에 제거합니다. + if (gableEdges.length > 0) { + const allGablePolygonPoints = gableEdges.flatMap(edge => edge.Polygon.map(p => ({ x: p.X, y: p.Y }))); + const gablePointSet = new Set(allGablePolygonPoints.map(p => `${p.x.toFixed(3)},${p.y.toFixed(3)}`)); + + for (let i = skeletonLines.length - 1; i >= 0; i--) { + const line = skeletonLines[i]; + const p1Key = `${line.p1.x.toFixed(3)},${line.p1.y.toFixed(3)}`; + const p2Key = `${line.p2.x.toFixed(3)},${line.p2.y.toFixed(3)}`; + + if (gablePointSet.has(p1Key) && gablePointSet.has(p2Key)) { + skeletonLines.splice(i, 1); + } + } + } + + // 3단계: 선 제거로 인해 발생한 모든 끊어진 선들을 한번에 연장하고 정리합니다. + handleDisconnectedLines(skeletonLines, baseLines); + + // 4단계: 최종적으로 정리된 선들을 QLine 객체로 변환하여 캔버스에 추가합니다. const innerLines = []; skeletonLines.forEach(line => { + if (!line.p1 || !line.p2) return; const { p1, p2, attributes, lineStyle } = line; const innerLine = new QLine([p1.x, p1.y, p2.x, p2.y], { parentId: roof.id, @@ -133,12 +138,9 @@ const createInnerLinesFromSkeleton = (skeleton, baseLines, roof, canvas, textMod */ 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); } @@ -146,256 +148,274 @@ function processEavesEdge(edgeResult, skeletonLines, processedInnerEdges) { } /** - * GABLE(케라바) Edge를 처리하여 스켈레톤 선을 정리하고 연장합니다. - * @param {object} edgeResult - 스켈레톤 Edge 데이터 + * 끊어진 모든 선들을 찾아 연장하고, 교차점에서 정리하는 로직을 수행합니다. + * @param {Array} skeletonLines - 수정할 전체 스켈레톤 라인 배열 * @param {Array} baseLines - 전체 외벽선 배열 - * @param {Array} skeletonLines - 전체 스켈레톤 라인 배열 */ -function processGableEdge(edgeResult, baseLines, skeletonLines) { - const edgePoints = edgeResult.Polygon.map(p => ({ x: p.X, y: p.Y })); - - // 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. 연결이 끊어진 스켈레톤 선을 찾아 연장합니다. +function handleDisconnectedLines(skeletonLines, baseLines) { + // 1. 연결이 끊어진 모든 스켈레톤 선을 찾아 연장합니다. const { disconnectedLines } = findDisconnectedSkeletonLines(skeletonLines, baseLines); + const modifiedIndices = new Set(); disconnectedLines.forEach(dLine => { const { index, extendedLine, p1Connected, p2Connected } = dLine; const newPoint = extendedLine?.point; if (!newPoint) return; - // p1이 끊어졌으면 p1을, p2가 끊어졌으면 p2를 연장된 지점으로 업데이트 + modifiedIndices.add(index); if (!p1Connected) { skeletonLines[index].p1 = { ...skeletonLines[index].p1, x: newPoint.x, y: newPoint.y }; } else if (!p2Connected) { skeletonLines[index].p2 = { ...skeletonLines[index].p2, x: newPoint.x, y: newPoint.y }; } }); -} + if (modifiedIndices.size === 0) return; + + // 2. 연장된 라인들이 서로 교차하는 경우, 가장 가까운 교차점에서 잘라냅니다. + const extendedLinesSnapshot = JSON.parse(JSON.stringify(skeletonLines)); + const distanceSq = (p1, p2) => Math.pow(p1.x - p2.x, 2) + Math.pow(p1.y - p2.y, 2); + + modifiedIndices.forEach(index => { + const dLineInfo = disconnectedLines.find(d => d.index === index); + if (!dLineInfo) return; + + const modifiedLine = extendedLinesSnapshot[index]; + const originalConnectedPoint = dLineInfo.p1Connected ? dLineInfo.line.p2 : dLineInfo.line.p1; + let closestIntersection = null; + let minDistanceSq = Infinity; + + for (let i = 0; i < extendedLinesSnapshot.length; i++) { + if (i === index) continue; + const otherLine = extendedLinesSnapshot[i]; + const intersection = findSegmentIntersection(modifiedLine.p1, modifiedLine.p2, otherLine.p1, otherLine.p2); + + if (intersection) { + const distSq = distanceSq(originalConnectedPoint, intersection); + if (distSq < minDistanceSq) { + minDistanceSq = distSq; + closestIntersection = intersection; + } + } + } + + if (closestIntersection) { + if (!dLineInfo.p1Connected) { + skeletonLines[index].p1 = closestIntersection; + } else { + skeletonLines[index].p2 = closestIntersection; + } + } + }); +} // --- 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; + const forward = 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 backward = 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 forward || backward; }); } -/** - * 스켈레톤 라인 배열에 새로운 라인을 추가합니다. (중복 방지) - * @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); +function addRawLine(skeletonLines, processedEdges, p1, p2, type, color, width) { + const key = [`${p1.x.toFixed(1)},${p1.y.toFixed(1)}`, `${p2.x.toFixed(1)},${p2.y.toFixed(1)}`].sort().join('|'); + if (processedEdges.has(key)) return; + processedEdges.add(key); - const dx = Math.abs(p2.x - p1.x); - const dy = Math.abs(p2.y - p1.y); - const isDiagonal = dx > 0.1 && dy > 0.1; - const normalizedType = isDiagonal ? LINE_TYPE.SUBLINE.HIP : lineType; + const isDiagonal = Math.abs(p2.x - p1.x) > 0.1 && Math.abs(p2.y - p1.y) > 0.1; + const lineType = isDiagonal ? LINE_TYPE.SUBLINE.HIP : type; skeletonLines.push({ - p1, - p2, + p1, p2, attributes: { - type: normalizedType, + type: lineType, planeSize: calcLinePlaneSize({ x1: p1.x, y1: p1.y, x2: p2.x, y2: p2.y }), - isRidge: normalizedType === LINE_TYPE.SUBLINE.RIDGE, + isRidge: lineType === 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(); +const preprocessPolygonCoordinates = (points) => { + let coords = points.map(p => [p.x, p.y]); + coords = coords.filter((c, i) => i === 0 || !(c[0] === coords[i - 1][0] && c[1] === coords[i - 1][1])); + if (coords.length > 1 && coords[0][0] === coords[coords.length - 1][0] && coords[0][1] === coords[coords.length - 1][1]) { + coords.pop(); } - return coordinates.reverse(); + return coords.reverse(); }; -/** - * 스켈레톤 Edge와 외벽선이 동일한지 확인합니다. - * @returns {boolean} 동일 여부 - */ -const isSameLine = (edgeStartX, edgeStartY, edgeEndX, edgeEndY, baseLine) => { +const isSameLine = (x1, y1, x2, y2, 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 { x1: bx1, y1: by1, x2: bx2, y2: by2 } = baseLine; + const forward = Math.abs(x1 - bx1) < tolerance && Math.abs(y1 - by1) < tolerance && Math.abs(x2 - bx2) < tolerance && Math.abs(y2 - by2) < tolerance; + const backward = Math.abs(x1 - bx2) < tolerance && Math.abs(y1 - by2) < tolerance && Math.abs(x2 - bx1) < tolerance && Math.abs(y2 - by1) < tolerance; + return forward || backward; }; -// --- 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; + const dx = x2 - x1, dy = y2 - y1; + const lenSq = dx * dx + dy * dy; + if (lenSq === 0) return { x: x1, y: y1 }; + const t = ((px - x1) * dx + (py - y1) * dy) / lenSq; 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 p = rayStart, r = rayDir, 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 (Math.abs(rxs) < 1e-6) return null; + const qp = { x: q.x - p.x, y: q.y - p.y }; + const t = (qp.x * s.y - qp.y * s.x) / rxs; + const u = (qp.x * r.y - qp.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 findSegmentIntersection(p1, p2, p3, p4) { + const d1x = p2.x - p1.x, d1y = p2.y - p1.y; + const d2x = p4.x - p3.x, d2y = p4.y - p3.y; + const denom = d1x * d2y - d1y * d2x; + if (Math.abs(denom) < 1e-6) return null; + const t = ((p3.x - p1.x) * d2y - (p3.y - p1.y) * d2x) / denom; + const u = ((p1.x - p3.x) * d1y - (p1.y - p3.y) * d1x) / denom; + if (t >= -1e-6 && t <= 1 + 1e-6 && u >= -1e-6 && u <= 1 + 1e-6) { + return { x: p1.x + t * d1x, y: p1.y + t * d1y }; + } + return 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 }; + const dir = { x: p1.x - p2.x, y: p1.y - p2.y }; + const len = Math.sqrt(dir.x * dir.x + dir.y * dir.y) || 1; + dir.x /= len; dir.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 (hit && hit.t > len - 0.1) { + 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); - }); - } + skeletonLines.forEach((seg, i) => { + if (i !== excludeIndex) checkHit(getRayIntersectionWithSegment(p2, dir, seg.p1, seg.p2)); + }); - if (Array.isArray(skeletonLines)) { - skeletonLines.forEach((seg, i) => { - if (i === excludeIndex) return; - const hit = getRayIntersectionWithSegment(p2, dir, seg.p1, seg.p2); - checkHit(hit); - }); - } + if (closestHit) return closestHit; // 스켈레톤 교차점 우선 + + baseLines.forEach(line => { + checkHit(getRayIntersectionWithSegment(p2, dir, { x: line.x1, y: line.y1 }, { x: line.x2, y: line.y2 })); + }); return closestHit; } /** - * 연결이 끊어진 스켈레톤 라인들을 찾아 연장 정보를 계산합니다. - * @param {Array} skeletonLines - 스켈레톤 라인 배열 + * raycast가 실패했을 때, 선의 방향을 유지하며 연장할 지점을 찾는 fallback 함수 + * @param {object} p_disconnect - 연결이 끊어진 점 + * @param {object} p_connect - 연결된 점 * @param {Array} baseLines - 외벽선 배열 - * @returns {object} 끊어진 라인 정보가 담긴 객체 + * @returns {{point: object}|null} 연장될 지점 또는 null */ +function getExtendedLineFallback(p_disconnect, p_connect, baseLines) { + const isVertical = Math.abs(p_disconnect.x - p_connect.x) < 1; + const isHorizontal = Math.abs(p_disconnect.y - p_connect.y) < 1; + let extendedPoint = null; + let minDistance = Infinity; + + if (isVertical) { + // 수직선일 경우, 가장 가까운 수평 외벽선으로 연장 + baseLines.forEach(b => { + const isBHorizontal = Math.abs(b.y1 - b.y2) < 1; + // 외벽선이 수평이고, x좌표 범위 내에 있는지 확인 + if (isBHorizontal && p_disconnect.x >= Math.min(b.x1, b.x2) - 1 && p_disconnect.x <= Math.max(b.x1, b.x2) + 1) { + const dist = Math.abs(p_disconnect.y - b.y1); + if (dist < minDistance) { + minDistance = dist; + extendedPoint = { x: p_disconnect.x, y: b.y1 }; + } + } + }); + } else if (isHorizontal) { + // 수평선일 경우, 가장 가까운 수직 외벽선으로 연장 + baseLines.forEach(b => { + const isBVertical = Math.abs(b.x1 - b.x2) < 1; + // 외벽선이 수직이고, y좌표 범위 내에 있는지 확인 + if (isBVertical && p_disconnect.y >= Math.min(b.y1, b.y2) - 1 && p_disconnect.y <= Math.max(b.y1, b.y2) + 1) { + const dist = Math.abs(p_disconnect.x - b.x1); + if (dist < minDistance) { + minDistance = dist; + extendedPoint = { x: b.x1, y: p_disconnect.y }; + } + } + }); + } + + if (extendedPoint) { + return { point: extendedPoint }; + } + + // 최후의 수단: 방향성을 고려하여 가장 가까운 외벽선에 투영 + let candidateBaselines = []; + if (isVertical) { + candidateBaselines = baseLines.filter(b => Math.abs(b.y1 - b.y2) < 1); // 수평선 후보 + } else if (isHorizontal) { + candidateBaselines = baseLines.filter(b => Math.abs(b.x1 - b.x2) < 1); // 수직선 후보 + } + + // 적합한 방향의 후보가 없으면 모든 외벽선을 대상으로 함 + if (candidateBaselines.length === 0) { + candidateBaselines = baseLines; + } + + if (candidateBaselines.length > 0) { + const closestBaseline = candidateBaselines.sort((a, b) => getDistanceToLine(p_disconnect, a) - getDistanceToLine(p_disconnect, b))[0]; + if (closestBaseline) { + return { point: getProjectionPoint(p_disconnect, closestBaseline) }; + } + } + + return null; +} + 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 pointsEqual = (p1, p2, e = 0.1) => Math.abs(p1.x - p2.x) < e && Math.abs(p1.y - p2.y) < e; - 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 isPointOnBase = (point) => baseLines?.some(line => { + if (pointsEqual(point, { x: line.x1, y: line.y1 }) || pointsEqual(point, { x: line.x2, y: line.y2 })) return true; + const d = Math.sqrt(Math.pow(line.x2 - line.x1, 2) + Math.pow(line.y2 - line.y1, 2)); + const d1 = Math.sqrt(Math.pow(point.x - line.x1, 2) + Math.pow(point.y - line.y1, 2)); + const d2 = Math.sqrt(Math.pow(point.x - line.x2, 2) + Math.pow(point.y - line.y2, 2)); + return Math.abs(d - (d1 + d2)) < 0.1; + }); 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; - } + let p1c = isPointOnBase(line.p1); + let p2c = isPointOnBase(line.p2); + if (p1c && p2c) return { p1Connected: true, p2Connected: true }; + for (let i = 0; i < skeletonLines.length; i++) { + if (i === lineIndex) continue; + const other = skeletonLines[i]; + if (!p1c && (pointsEqual(line.p1, other.p1) || pointsEqual(line.p1, other.p2))) p1c = true; + if (!p2c && (pointsEqual(line.p2, other.p1) || pointsEqual(line.p2, other.p2))) p2c = true; + if (p1c && p2c) break; } - return { p1Connected, p2Connected }; + return { p1Connected: p1c, p2Connected: p2c }; }; skeletonLines.forEach((line, index) => { @@ -406,36 +426,12 @@ 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 => { - 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 }; + extendedLine = getExtendedLineFallback(line.p1, line.p2, 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 => { - 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 }; + if (!extendedLine) { + extendedLine = getExtendedLineFallback(line.p2, line.p1, baseLines); } } @@ -445,3 +441,17 @@ export const findDisconnectedSkeletonLines = (skeletonLines, baseLines) => { return { disconnectedLines }; }; +// getDistanceToLine 함수 추가 (fallback 로직에 필요) +const getDistanceToLine = (point, line) => { + const { x: px, y: py } = point; + const { x1, y1, x2, y2 } = line; + const dx = x2 - x1, dy = y2 - y1; + const lenSq = dx * dx + dy * dy; + if (lenSq === 0) return Math.sqrt(Math.pow(px - x1, 2) + Math.pow(py - y1, 2)); + let t = ((px - x1) * dx + (py - y1) * dy) / lenSq; + t = Math.max(0, Math.min(1, t)); + const projX = x1 + t * dx; + const projY = y1 + t * dy; + return Math.sqrt(Math.pow(px - projX, 2) + Math.pow(py - projY, 2)); +}; +