Merge branch 'feature/sketon_cha' of https://git.hanasys.jp/qcast3/qcast-front into home_win_cha

# Conflicts:
#	src/util/skeleton-utils.js
This commit is contained in:
Cha 2025-09-24 21:13:51 +09:00
commit ef910385d3
3 changed files with 440 additions and 265 deletions

View File

@ -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}
/>
)}
</div>
@ -512,7 +529,7 @@ export const Orientation = forwardRef((props, ref) => {
</tbody>
</table>
</div>
{basicSetting && basicSetting.roofSizeSet == '3' && (
{basicSetting && basicSetting.roofSizeSet === '3' && (
<div className="outline-form mt15">
<span>{getMessage('modal.module.basic.setting.module.placement.area')}</span>
<div className="input-grid mr10" style={{ width: '60px' }}>
@ -523,7 +540,7 @@ export const Orientation = forwardRef((props, ref) => {
)}
</div>
{basicSetting && basicSetting.roofSizeSet != '3' && (
{basicSetting && basicSetting.roofSizeSet !== '3' && (
<div className="compas-table-box">
<div className="compas-grid-table">
<div className="outline-form">

View File

@ -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,

View File

@ -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<QLine>} 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<QLine>} 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<QLine>} baseLines - 외벽선 배열
* @param {fabric.Object} roof - 지붕 객체
* @param {fabric.Canvas} canvas - 캔버스 객체
* @param {string} textMode - 텍스트 표시 모드
* @param {Array<QLine>} skeletonLines - 스켈레톤 라인 배열 (함수 내에서 생성 수정됨)
* 스켈레톤 결과와 외벽선 정보를 바탕으로 내부선(용마루, 추녀)생성합니다.
* @param {object} skeleton - SkeletonBuilder로부터 반환된 스켈레톤 객체
* @param {Array<QLine>} baseLines - 원본 외벽선 QLine 객체 배열
* @param {fabric.Object} roof - 대상 지붕 객체
* @param {fabric.Canvas} canvas - Fabric.js 캔버스 객체
* @param {string} textMode - 텍스트 표시 모드 ('plane', 'actual', 'none')
* @param {Array<QLine>} skeletonLines - 스켈레톤 라인 배열 (수정 대상)
* @returns {Array<QLine>} 생성된 내부선(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<QLine>} 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<object>} 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<object>} initialPoints - 초기 폴리곤 좌표 배열
* @returns {Array<Array<number>>} 전처리된 좌표 배열 (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<QLine>} 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<QLine>} 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<Array<Object>>} 다각형 좌표 배열의 배열
*/
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<Object>} 교차점 배열
*/
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<Object>} 모든 포인트 배열
*/
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
};