diff --git a/src/components/floor-plan/modal/basic/step/Orientation.jsx b/src/components/floor-plan/modal/basic/step/Orientation.jsx index 976e2cd1..96fbc6e4 100644 --- a/src/components/floor-plan/modal/basic/step/Orientation.jsx +++ b/src/components/floor-plan/modal/basic/step/Orientation.jsx @@ -77,7 +77,7 @@ export const Orientation = forwardRef((props, ref) => { }; useEffect(() => { - if (basicSetting.roofSizeSet == '3') { + if (basicSetting.roofSizeSet === '3') { restoreModuleInstArea() } }, []) @@ -187,7 +187,7 @@ export const Orientation = forwardRef((props, ref) => { title: getMessage('module.not.found'), icon: 'warning', }) - return + } } } @@ -250,8 +250,17 @@ export const Orientation = forwardRef((props, ref) => { // 필터링된 목록의 첫 번째 모듈을 자동 선택 if (filtered.length > 0) { - setSelectedModules(filtered[0]) + const firstModule = filtered[0] + setSelectedModules(firstModule) + // 상위 컴포넌트의 handleChangeModule 호출 + if (handleChangeModule) { + handleChangeModule(firstModule) + } } + } else { + // 모듈 리스트가 비어있는 경우 + setFilteredModuleList([]) + setSelectedModules(null) } } @@ -342,10 +351,14 @@ export const Orientation = forwardRef((props, ref) => { setSelectedModuleSeries(currentSeries) } else { setSelectedModuleSeries(allOption) + // "ALL"이 선택되면 자동으로 모듈 필터링 및 선택 실행 + setTimeout(() => handleChangeModuleSeries(allOption), 0) } } else { // 선택된 모듈이 없으면 "전체"를 기본 선택 setSelectedModuleSeries(allOption) + // "ALL"이 선택되면 자동으로 모듈 필터링 및 선택 실행 + setTimeout(() => handleChangeModuleSeries(allOption), 0) } } } @@ -369,6 +382,9 @@ export const Orientation = forwardRef((props, ref) => { if (filtered.length > 0 && !selectedModules) { setSelectedModules(filtered[0]) } + } else if (moduleList.length === 0 && filteredModuleList.length === 0 && selectedModuleSeries) { + // 모듈 리스트가 비어있는 경우 빈 배열로 설정 + setFilteredModuleList([]) } }, [moduleList, selectedModuleSeries]); return ( @@ -462,6 +478,7 @@ export const Orientation = forwardRef((props, ref) => { sourceKey={'itemId'} showKey={'itemNm'} onChange={(e) => handleChangeModule(e)} + showFirstOptionWhenEmpty = {true} /> )} @@ -512,7 +529,7 @@ export const Orientation = forwardRef((props, ref) => { - {basicSetting && basicSetting.roofSizeSet == '3' && ( + {basicSetting && basicSetting.roofSizeSet === '3' && (
{getMessage('modal.module.basic.setting.module.placement.area')}
@@ -523,7 +540,7 @@ export const Orientation = forwardRef((props, ref) => { )}
- {basicSetting && basicSetting.roofSizeSet != '3' && ( + {basicSetting && basicSetting.roofSizeSet !== '3' && (
diff --git a/src/components/floor-plan/modal/basic/step/Trestle.jsx b/src/components/floor-plan/modal/basic/step/Trestle.jsx index c8b40ef3..77ade743 100644 --- a/src/components/floor-plan/modal/basic/step/Trestle.jsx +++ b/src/components/floor-plan/modal/basic/step/Trestle.jsx @@ -18,6 +18,11 @@ const Trestle = forwardRef((props, ref) => { const currentAngleType = useRecoilValue(currentAngleTypeSelector) const pitchText = useRecoilValue(pitchTextSelector) const [selectedRoof, setSelectedRoof] = useState(null) + const [isAutoSelecting, setIsAutoSelecting] = useState(false) // 자동 선택 중인지 상태 + const [autoSelectTimeout, setAutoSelectTimeout] = useState(null) // 타임아웃 참조 + const autoSelectTimeoutRef = useRef(null) + // 공통 타임아웃 설정 (밀리초) + const AUTO_SELECT_TIMEOUT = 700 // API 호출 완료 대기 시간 const { trestleState, trestleDetail, @@ -63,7 +68,7 @@ const Trestle = forwardRef((props, ref) => { useEffect(() => { - if (roofs && !selectedRoof) { + if (roofs && roofs.length > 0 && !selectedRoof) { console.log("roofs:::::", roofs.length) setLengthBase(roofs[0].length); setSelectedRoof(roofs[0]) @@ -71,12 +76,12 @@ const Trestle = forwardRef((props, ref) => { if (selectedRoof && selectedRoof.lenAuth === "C") { onChangeLength(selectedRoof.length); } - if (selectedRoof && ["C", "R"].includes(selectedRoof.raftAuth)) { + if (selectedRoof && ["C", "R"].includes(selectedRoof.raftAuth) && roofs && roofs.length > 0) { onChangeRaftBase(roofs[0]); } //모듈 설치 영역 복구 restoreModuleInstArea() - }, [roofs]) + }, [roofs, selectedRoof]) // selectedRoof 추가 useEffect(() => { if (flag && moduleSelectionData) { @@ -161,7 +166,7 @@ const Trestle = forwardRef((props, ref) => { useEffect(() => { if (constructionList.length > 0) { - const existingConstruction = constructionList.find((construction) => construction.constTp === trestleState?.construction?.constTp) + const existingConstruction = constructionList.find((construction) => construction.constTp === trestleState.constTp) if (existingConstruction) { setSelectedConstruction(existingConstruction) } else if (autoSelectStep === 'construction') { @@ -252,7 +257,7 @@ const Trestle = forwardRef((props, ref) => { // 다음 단계(가대메이커) 자동 선택 설정 - 지연 실행 setTimeout(() => { setAutoSelectStep('trestle') - }, 500) // API 호출 완료를 위한 더 긴 지연 + }, AUTO_SELECT_TIMEOUT) // API 호출 완료를 위한 더 긴 지연 } const onChangeHajebichi = (e) => { @@ -282,7 +287,7 @@ const Trestle = forwardRef((props, ref) => { // 다음 단계(가대메이커) 자동 선택 설정 - 지연 실행 setTimeout(() => { setAutoSelectStep('trestle') - }, 500) + }, AUTO_SELECT_TIMEOUT) } const onChangeTrestleMaker = (e) => { @@ -305,7 +310,7 @@ const Trestle = forwardRef((props, ref) => { // API 호출 완료 후 다음 단계(공법) 자동 선택 설정 setTimeout(() => { setAutoSelectStep('constMthd') - }, 300) + }, AUTO_SELECT_TIMEOUT) } const onChangeConstMthd = (e) => { @@ -325,10 +330,21 @@ const Trestle = forwardRef((props, ref) => { }, }) + // 기존 타임아웃 취소 + if (autoSelectTimeoutRef.current) { + clearTimeout(autoSelectTimeoutRef.current) + } + + // 자동 선택 중 상태 활성화 + setIsAutoSelecting(true) + // API 호출 완료 후 다음 단계(지붕밑바탕) 자동 선택 설정 - setTimeout(() => { + const timeoutId = setTimeout(() => { setAutoSelectStep('roofBase') - }, 300) + setIsAutoSelecting(false) + }, AUTO_SELECT_TIMEOUT) + + autoSelectTimeoutRef.current = timeoutId } const onChangeRoofBase = (e) => { @@ -356,7 +372,7 @@ const Trestle = forwardRef((props, ref) => { // API 호출 완료 후 다음 단계(construction) 자동 선택 설정 setTimeout(() => { setAutoSelectStep('construction') - }, 300) + }, AUTO_SELECT_TIMEOUT) } const handleConstruction = (index) => { @@ -451,7 +467,7 @@ const Trestle = forwardRef((props, ref) => { ...selectedRoofBase, }, construction: { - ...constructionList.find((data) => data.constTp === trestleState.constTp), + ...constructionList.find((data) => newAddedRoofs[index].construction.constTp === data.constTp), cvrYn, snowGdPossYn, cvrChecked, diff --git a/src/util/skeleton-utils.js b/src/util/skeleton-utils.js index 8ed97d37..70d53085 100644 --- a/src/util/skeleton-utils.js +++ b/src/util/skeleton-utils.js @@ -1,6 +1,6 @@ import { LINE_TYPE, POLYGON_TYPE } from '@/common/common' import { SkeletonBuilder } from '@/lib/skeletons' -import { calcLineActualSize, calcLinePlaneSize, toGeoJSON } from '@/util/qpolygon-utils' +import { calcLinePlaneSize, toGeoJSON } from '@/util/qpolygon-utils' import { QLine } from '@/components/fabric/QLine' /** @@ -10,14 +10,14 @@ import { QLine } from '@/components/fabric/QLine' * @param {string} textMode - 텍스트 표시 모드 * @param {Array} existingSkeletonLines - 기존에 생성된 스켈레톤 라인 */ -export const drawSkeletonRidgeRoof = (roofId, canvas, textMode, 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."); @@ -30,8 +30,8 @@ export const drawSkeletonRidgeRoof = (roofId, canvas, textMode, existingSkeleton return; } - // 스켈레톤 생성 및 그리기 - skeletonBuilder(roofId, canvas, textMode, roof, existingSkeletonLines) + // 2. 스켈레톤 생성 및 그리기 + skeletonBuilder(roofId, canvas, textMode, roof) } /** @@ -42,74 +42,77 @@ export const drawSkeletonRidgeRoof = (roofId, canvas, textMode, existingSkeleton * @param {fabric.Object} roof - 지붕 객체 * @param {Array} skeletonLines - 스켈레톤 라인 배열 */ -export const skeletonBuilder = (roofId, canvas, textMode, roof, 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) - const innerLines = createInnerLinesFromSkeleton(skeleton, roof.lines, roof, canvas, textMode, skeletonLines) - roof.innerLines = innerLines + // 스켈레톤 데이터를 기반으로 내부선 생성 + roof.innerLines = createInnerLinesFromSkeleton(skeleton, roof.lines, roof, canvas, textMode, skeletonLines) - if (!canvas.skeletonStates) canvas.skeletonStates = {} + // 캔버스에 스켈레톤 상태 저장 + 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 + if (canvas.skeletonStates) { + canvas.skeletonStates[roofId] = false + } } } /** - * 스켈레톤 결과와 외벽선 정보를 바탕으로 내부선을 생성합니다. - * @param {object} skeleton - 스켈레톤 객체 - * @param {Array} baseLines - 외벽선 배열 - * @param {fabric.Object} roof - 지붕 객체 - * @param {fabric.Canvas} canvas - 캔버스 객체 - * @param {string} textMode - 텍스트 표시 모드 - * @param {Array} skeletonLines - 스켈레톤 라인 배열 (이 함수 내에서 생성 및 수정됨) + * 스켈레톤 결과와 외벽선 정보를 바탕으로 내부선(용마루, 추녀)을 생성합니다. + * @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() - const gableEdges = []; - // 1단계: 모든 내부 스켈레톤 라인을 생성하고, 케라바(Gable) Edge를 식별합니다. + // 1. 모든 Edge를 순회하며 기본 스켈레톤 선(용마루)을 수집합니다. skeleton.Edges.forEach(edgeResult => { - processEavesEdge(edgeResult, skeletonLines, processedInnerEdges); - const { Begin, End } = edgeResult.Edge; - if (baseLines.some(line => line.attributes.type === 'gable' && isSameLine(Begin.X, Begin.Y, End.X, End.Y, line))) { - gableEdges.push(edgeResult); - } + processEavesEdge(edgeResult, skeletonLines, processedInnerEdges) }); - // 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)}`; + // 2. 케라바(Gable) 속성을 가진 외벽선에 해당하는 스켈레톤을 후처리합니다. + skeleton.Edges.forEach(edgeResult => { - if (gablePointSet.has(p1Key) && gablePointSet.has(p2Key)) { - skeletonLines.splice(i, 1); + 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단계: 선 제거로 인해 발생한 모든 끊어진 선들을 한번에 연장하고 정리합니다. - handleDisconnectedLines(skeletonLines, baseLines); + }); - // 4단계: 최종적으로 정리된 선들을 QLine 객체로 변환하여 캔버스에 추가합니다. + // 3. 최종적으로 정리된 스켈레톤 선들을 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, @@ -138,9 +141,12 @@ 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); } @@ -148,274 +154,255 @@ function processEavesEdge(edgeResult, skeletonLines, processedInnerEdges) { } /** - * 끊어진 모든 선들을 찾아 연장하고, 교차점에서 정리하는 로직을 수행합니다. - * @param {Array} skeletonLines - 수정할 전체 스켈레톤 라인 배열 + * GABLE(케라바) Edge를 처리하여 스켈레톤 선을 정리하고 연장합니다. + * @param {object} edgeResult - 스켈레톤 Edge 데이터 * @param {Array} baseLines - 전체 외벽선 배열 + * @param {Array} skeletonLines - 전체 스켈레톤 라인 배열 */ -function handleDisconnectedLines(skeletonLines, baseLines) { - // 1. 연결이 끊어진 모든 스켈레톤 선을 찾아 연장합니다. +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); - const modifiedIndices = new Set(); disconnectedLines.forEach(dLine => { const { index, extendedLine, p1Connected, p2Connected } = dLine; const newPoint = extendedLine?.point; if (!newPoint) return; - - modifiedIndices.add(index); - if (!p1Connected) { - skeletonLines[index].p1 = { ...skeletonLines[index].p1, x: newPoint.x, y: newPoint.y }; - } else if (!p2Connected) { + // p1이 끊어졌으면 p1을, p2가 끊어졌으면 p2를 연장된 지점으로 업데이트 + if (p1Connected) { //p2 연장 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; - } + } 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 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; + const forwardMatch = Math.abs(lineStart.x - p1.x) < tolerance && Math.abs(lineStart.y - p1.y) < tolerance && Math.abs(lineEnd.x - p2.x) < tolerance && Math.abs(lineEnd.y - p2.y) < tolerance; + const backwardMatch = Math.abs(lineStart.x - p2.x) < tolerance && Math.abs(lineStart.y - p2.y) < tolerance && Math.abs(lineEnd.x - p1.x) < tolerance && Math.abs(lineEnd.y - p1.y) < tolerance; + return forwardMatch || backwardMatch; }); } -function addRawLine(skeletonLines, 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); +/** + * 스켈레톤 라인 배열에 새로운 라인을 추가합니다. (중복 방지) + * @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 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; + 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, + p1, + p2, attributes: { - type: lineType, + type: normalizedType, planeSize: calcLinePlaneSize({ x1: p1.x, y1: p1.y, x2: p2.x, y2: p2.y }), - isRidge: lineType === LINE_TYPE.SUBLINE.RIDGE, + isRidge: normalizedType === LINE_TYPE.SUBLINE.RIDGE, }, lineStyle: { color, width }, }); } -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(); +/** + * 폴리곤 좌표를 스켈레톤 생성에 적합하게 전처리합니다 (중복 제거, 시계 방향 정렬). + * @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 coords.reverse(); + return coordinates.reverse(); }; -const isSameLine = (x1, y1, x2, y2, baseLine) => { +/** + * 스켈레톤 Edge와 외벽선이 동일한지 확인합니다. + * @returns {boolean} 동일 여부 + */ +const isSameLine = (edgeStartX, edgeStartY, edgeEndX, edgeEndY, baseLine) => { const tolerance = 0.1; - 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; + 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, 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 }; + 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, r = rayDir, q = segA; + 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 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 (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; } -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; -} - +/** + * 한 점에서 다른 점 방향으로 광선을 쏘아 가장 가까운 교차점을 찾습니다. + * @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 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; + 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) { - if (!closestHit || hit.t < closestHit.t) closestHit = hit; + if (hit && hit.t > len - 0.1) { // 원래 선분의 끝점(p1) 너머에서 교차하는지 확인 + if (!closestHit || hit.t < closestHit.t) { + closestHit = hit; + } } }; - skeletonLines.forEach((seg, i) => { - if (i !== excludeIndex) checkHit(getRayIntersectionWithSegment(p2, dir, seg.p1, seg.p2)); - }); + 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 (closestHit) return closestHit; // 스켈레톤 교차점 우선 - - baseLines.forEach(line => { - checkHit(getRayIntersectionWithSegment(p2, dir, { x: line.x1, y: line.y1 }, { x: line.x2, y: line.y2 })); - }); + 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; } /** - * raycast가 실패했을 때, 선의 방향을 유지하며 연장할 지점을 찾는 fallback 함수 - * @param {object} p_disconnect - 연결이 끊어진 점 - * @param {object} p_connect - 연결된 점 + * 연결이 끊어진 스켈레톤 라인들을 찾아 연장 정보를 계산합니다. + * @param {Array} skeletonLines - 스켈레톤 라인 배열 * @param {Array} baseLines - 외벽선 배열 - * @returns {{point: object}|null} 연장될 지점 또는 null + * @returns {object} 끊어진 라인 정보가 담긴 객체 */ -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, e = 0.1) => Math.abs(p1.x - p2.x) < e && Math.abs(p1.y - p2.y) < e; + 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(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 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) => { - 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; + 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: p1c, p2Connected: p2c }; + return { p1Connected, p2Connected }; }; skeletonLines.forEach((line, index) => { @@ -426,12 +413,36 @@ export const findDisconnectedSkeletonLines = (skeletonLines, baseLines) => { if (!p1Connected) { extendedLine = extendFromP2TowardP1(line.p1, line.p2, baseLines, skeletonLines, index); if (!extendedLine) { - extendedLine = getExtendedLineFallback(line.p1, line.p2, baseLines); + 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) { - extendedLine = getExtendedLineFallback(line.p2, line.p1, baseLines); + 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 }; } } @@ -441,17 +452,148 @@ 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)); +/** + * 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 +};