From 8d6c4a6504028650b16e2ba477e803b381daa5ac Mon Sep 17 00:00:00 2001 From: ysCha Date: Thu, 16 Apr 2026 13:18:39 +0900 Subject: [PATCH] =?UTF-8?q?[1806]=EC=84=9C=EA=B9=8C=EB=9E=98=20=EB=86=92?= =?UTF-8?q?=EC=9D=B4=20=EC=A1=B0=EC=A0=95=EC=97=90=20=EB=94=B0=EB=A5=B8=20?= =?UTF-8?q?=EC=A7=80=EB=B6=95=20=ED=98=95=EC=83=81=20=EB=B6=88=EC=9D=BC?= =?UTF-8?q?=EC=B9=98=20-=20=EB=9D=BC=EC=9D=B8=EC=98=A4=EB=B2=84=EC=B2=B4?= =?UTF-8?q?=ED=81=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/util/skeleton-utils.js | 211 +++++++++++++++++++++++++++++++++++-- 1 file changed, 204 insertions(+), 7 deletions(-) diff --git a/src/util/skeleton-utils.js b/src/util/skeleton-utils.js index 22eaea64..f713693b 100644 --- a/src/util/skeleton-utils.js +++ b/src/util/skeleton-utils.js @@ -559,21 +559,108 @@ export const skeletonBuilder = (roofId, canvas, textMode) => { console.log('[DEBUG] changRoofLinePoints(최종 SK입력):', changRoofLinePoints.map((p, i) => `[${i}] (${Math.round(p.x)},${Math.round(p.y)})`)) + // 좌표 유효성 검증 + const invalidPoints = changRoofLinePoints.filter((p, i) => + p == null || isNaN(p.x) || isNaN(p.y) || !isFinite(p.x) || !isFinite(p.y) + ) + if (invalidPoints.length > 0) { + console.error('[SK_ERROR] 유효하지 않은 좌표 발견:', invalidPoints) + console.error('[SK_ERROR] 전체 좌표:', JSON.stringify(changRoofLinePoints)) + } + + // 인접 중복점 검출 (거리 < 0.5) + for (let i = 0; i < changRoofLinePoints.length; i++) { + const curr = changRoofLinePoints[i] + const next = changRoofLinePoints[(i + 1) % changRoofLinePoints.length] + const dist = Math.hypot(next.x - curr.x, next.y - curr.y) + if (dist < 0.5) { + console.warn(`[SK_WARN] 인접 중복점: [${i}](${Math.round(curr.x)},${Math.round(curr.y)}) ↔ [${(i + 1) % changRoofLinePoints.length}](${Math.round(next.x)},${Math.round(next.y)}) dist=${dist.toFixed(3)}`) + } + } + + // 일직선(collinear) 점 검출 + for (let i = 0; i < changRoofLinePoints.length; i++) { + const prev = changRoofLinePoints[(i - 1 + changRoofLinePoints.length) % changRoofLinePoints.length] + const curr = changRoofLinePoints[i] + const next = changRoofLinePoints[(i + 1) % changRoofLinePoints.length] + const cross = (curr.x - prev.x) * (next.y - prev.y) - (curr.y - prev.y) * (next.x - prev.x) + if (Math.abs(cross) < 1.0) { + console.warn(`[SK_WARN] 일직선 점: [${i}](${Math.round(curr.x)},${Math.round(curr.y)}) cross=${cross.toFixed(3)}`) + } + } + + // 다각형 오버 검출: 원본 roof.points와 비교하여 꼭짓점 방향(cross product)이 뒤집혔는지 체크 + let isOverDetected = false + const origPoints = roof.points || [] + const n = changRoofLinePoints.length + console.log('[SK_OVER_DEBUG] origPoints:', origPoints.map((p, i) => `[${i}](${Math.round(p.x)},${Math.round(p.y)})`)) + console.log('[SK_OVER_DEBUG] changRoofLinePoints:', changRoofLinePoints.map((p, i) => `[${i}](${Math.round(p.x)},${Math.round(p.y)})`)) + console.log('[SK_OVER_DEBUG] 변경된 점:', changRoofLinePoints.map((p, i) => { + const o = origPoints[i] + if (!o) return null + const dist = Math.hypot(p.x - o.x, p.y - o.y) + return dist > 1 ? `[${i}] dx=${Math.round(p.x - o.x)} dy=${Math.round(p.y - o.y)}` : null + }).filter(Boolean)) + if (origPoints.length === n && n >= 3) { + for (let i = 0; i < n; i++) { + const prevOrig = origPoints[(i - 1 + n) % n] + const currOrig = origPoints[i] + const nextOrig = origPoints[(i + 1) % n] + const crossOrig = (currOrig.x - prevOrig.x) * (nextOrig.y - prevOrig.y) - (currOrig.y - prevOrig.y) * (nextOrig.x - prevOrig.x) + + const prevNew = changRoofLinePoints[(i - 1 + n) % n] + const currNew = changRoofLinePoints[i] + const nextNew = changRoofLinePoints[(i + 1) % n] + const crossNew = (currNew.x - prevNew.x) * (nextNew.y - prevNew.y) - (currNew.y - prevNew.y) * (nextNew.x - prevNew.x) + + if (crossOrig * crossNew < 0) { + isOverDetected = true + console.error(`[SK_OVER] 꼭짓점[${i}] 방향 뒤집힘! cross: ${crossOrig.toFixed(1)} → ${crossNew.toFixed(1)}`) + console.error(`[SK_OVER] 원본: [${(i-1+n)%n}](${Math.round(prevOrig.x)},${Math.round(prevOrig.y)}) → [${i}](${Math.round(currOrig.x)},${Math.round(currOrig.y)}) → [${(i+1)%n}](${Math.round(nextOrig.x)},${Math.round(nextOrig.y)})`) + console.error(`[SK_OVER] 변경: [${(i-1+n)%n}](${Math.round(prevNew.x)},${Math.round(prevNew.y)}) → [${i}](${Math.round(currNew.x)},${Math.round(currNew.y)}) → [${(i+1)%n}](${Math.round(nextNew.x)},${Math.round(nextNew.y)})`) + } + } + } + // changRoofLinePoints를 roof.skeletonPoints에 저장 (roof.points 원본은 유지) roof.skeletonPoints = changRoofLinePoints.map(p => ({ x: p.x, y: p.y })) - const perturbedPoints = perturbPolygonPoints(changRoofLinePoints) + // ────────────────────────────────────────────────────────────────── + // [분기] 스켈레톤 입력 포인트 결정 + // 정상: changRoofLinePoints 그대로 사용 (기존 로직 변경 없음) + // 오버: calcOverCorrectedPoints() 별도 함수로 보정 포인트 생성 + // ※ changRoofLinePoints 자체는 절대 수정하지 않음 + // ※ 오버 보정 실패 시 changRoofLinePoints 그대로 fallback + // ────────────────────────────────────────────────────────────────── + let skeletonInputPoints = changRoofLinePoints // 정상 경로 + + if (isOverDetected) { + // [예외] 오버 감지 시 → 별도 함수에서 스켈레톤 전용 보정 포인트 계산 + try { + const corrected = calcOverCorrectedPoints(changRoofLinePoints, origPoints) + if (corrected && corrected.length >= 3) { + skeletonInputPoints = corrected + console.log('[SK_OVER] 보정 포인트 적용:', skeletonInputPoints.map((p, i) => `[${i}](${Math.round(p.x)},${Math.round(p.y)})`)) + } + } catch (e) { + // 보정 실패 시 기존 changRoofLinePoints로 fallback → 기존 동작 보장 + console.error('[SK_OVER] 보정 실패 → fallback:', e) + } + } + + const perturbedPoints = perturbPolygonPoints(skeletonInputPoints) const geoJSONPolygon = toGeoJSON(perturbedPoints) try { // SkeletonBuilder는 닫히지 않은 폴리곤을 기대하므로 마지막 점 제거 geoJSONPolygon.pop() console.log('[SkeletonBuilder] geoJSONPolygon:', JSON.stringify(geoJSONPolygon, null, 2)) + console.log('[SkeletonBuilder] 꼭짓점 수:', geoJSONPolygon.length) const skeleton = SkeletonBuilder.BuildFromGeoJSON([[geoJSONPolygon]]) // 스켈레톤 데이터를 기반으로 내부선 생성 roof.innerLines = roof.innerLines || [] - roof.innerLines = createInnerLinesFromSkeleton(roofId, canvas, skeleton, textMode) + roof.innerLines = createInnerLinesFromSkeleton(roofId, canvas, skeleton, textMode, isOverDetected) //console.log("roofInnerLines:::", roof.innerLines); // 캔버스에 스켈레톤 상태 저장 if (!canvas.skeletonStates) { @@ -606,7 +693,9 @@ export const skeletonBuilder = (roofId, canvas, textMode) => { //console.log('skeleton rendered.', canvas) } catch (e) { - console.error('스켈레톤 생성 중 오류 발생:', e) + console.error('[SK_ERROR] 스켈레톤 생성 중 오류 발생:', e) + console.error('[SK_ERROR] 입력 좌표:', changRoofLinePoints.map((p, i) => `[${i}] (${p.x.toFixed(2)},${p.y.toFixed(2)})`)) + console.error('[SK_ERROR] geoJSON:', JSON.stringify(geoJSONPolygon)) if (canvas.skeletonStates) { canvas.skeletonStates[roofId] = false canvas.skeletonStates = {} @@ -624,7 +713,7 @@ export const skeletonBuilder = (roofId, canvas, textMode) => { * @param {string} textMode - 텍스트 표시 모드 ('plane', 'actual', 'none') * @param {Array} baseLines - 원본 외벽선 QLine 객체 배열 */ -const createInnerLinesFromSkeleton = (roofId, canvas, skeleton, textMode) => { +const createInnerLinesFromSkeleton = (roofId, canvas, skeleton, textMode, isOverDetected = false) => { if (!skeleton?.Edges) return [] const roof = canvas?.getObjects().find((object) => object.id === roofId) const wall = canvas.getObjects().find((obj) => obj.name === POLYGON_TYPE.WALL && obj.attributes.roofId === roofId) @@ -1083,8 +1172,6 @@ const createInnerLinesFromSkeleton = (roofId, canvas, skeleton, textMode) => { return } - - const movedStart = Math.abs(wallBaseLine.x1 - wallLine.x1) > EPSILON || Math.abs(wallBaseLine.y1 - wallLine.y1) > EPSILON const movedEnd = Math.abs(wallBaseLine.x2 - wallLine.x2) > EPSILON || Math.abs(wallBaseLine.y2 - wallLine.y2) > EPSILON @@ -1684,7 +1771,9 @@ const createInnerLinesFromSkeleton = (roofId, canvas, skeleton, textMode) => { canvas.renderAll() }) } - getMoveUpDownLine() + if (!isOverDetected) { + getMoveUpDownLine() + } } @@ -2000,6 +2089,114 @@ function processGableEdge(edgeResult, baseLines, skeletonLines, selectBaseLine, // --- Helper Functions --- +/** + * [오버 전용 예외 처리 함수] calcOverCorrectedPoints + * + * wallLine이 인접 roofLine을 넘어섰을 때(오버 감지 시)에만 호출된다. + * changRoofLinePoints를 직접 수정하지 않고 스켈레톤 전용 보정 포인트를 새로 만들어 반환한다. + * + * 보정 방법: + * 1. 이동된 점(moved) 판별 + * 2. 이동점이 인접 고정점을 넘어갔으면 → 이동점을 고정점 위치로 클램핑 + * 3. 클램핑된 점에서 연결된 이동점으로 값 전파 + * 4. 인접 중복점 제거 (dist < 0.5) + * 5. 일직선(collinear) 중간점 제거 + * + * @param {Array<{x,y}>} points - changRoofLinePoints (수정하지 않음, 읽기 전용) + * @param {Array<{x,y}>} origPoints - roof.points (원본 폴리곤) + * @returns {Array<{x,y}>} 보정된 스켈레톤 입력 포인트 (실패 시 원본 반환) + */ +const calcOverCorrectedPoints = (points, origPoints) => { + const n = points.length + const skPoints = points.map(p => ({ x: p.x, y: p.y })) + + // Step 1. 이동된 점 판별 (원본 대비 1px 초과 이동) + const moved = skPoints.map((p, i) => Math.hypot(p.x - origPoints[i].x, p.y - origPoints[i].y) > 1) + + // Step 2. 이동점이 인접 고정점을 넘어갔으면 → 고정점 위치로 클램핑 + const clamped = new Set() + const clampToFixed = (movedIdx, fixedIdx) => { + let didClamp = false + // 원래 수평 연결 (같은 y) → x 클램핑 + if (Math.abs(origPoints[movedIdx].y - origPoints[fixedIdx].y) < 1) { + const origDir = origPoints[movedIdx].x - origPoints[fixedIdx].x + const newDir = skPoints[movedIdx].x - skPoints[fixedIdx].x + if (origDir * newDir < 0) { + console.log(`[SK_OVER] 클램핑: [${movedIdx}].x ${Math.round(skPoints[movedIdx].x)} → ${Math.round(skPoints[fixedIdx].x)}`) + skPoints[movedIdx].x = skPoints[fixedIdx].x + didClamp = true + } + } + // 원래 수직 연결 (같은 x) → y 클램핑 + if (Math.abs(origPoints[movedIdx].x - origPoints[fixedIdx].x) < 1) { + const origDir = origPoints[movedIdx].y - origPoints[fixedIdx].y + const newDir = skPoints[movedIdx].y - skPoints[fixedIdx].y + if (origDir * newDir < 0) { + console.log(`[SK_OVER] 클램핑: [${movedIdx}].y ${Math.round(skPoints[movedIdx].y)} → ${Math.round(skPoints[fixedIdx].y)}`) + skPoints[movedIdx].y = skPoints[fixedIdx].y + didClamp = true + } + } + if (didClamp) clamped.add(movedIdx) + } + + for (let i = 0; i < n; i++) { + if (!moved[i]) continue + const nextIdx = (i + 1) % n + const prevIdx = (i - 1 + n) % n + if (!moved[nextIdx]) clampToFixed(i, nextIdx) + if (!moved[prevIdx]) clampToFixed(i, prevIdx) + } + + // Step 3. 클램핑된 점 → 연결된 미클램핑 이동점으로 값 전파 (반복) + let propagated = true + while (propagated) { + propagated = false + for (let i = 0; i < n; i++) { + if (!moved[i] || clamped.has(i)) continue + const prevIdx = (i - 1 + n) % n + const nextIdx = (i + 1) % n + if (clamped.has(prevIdx) && Math.abs(origPoints[prevIdx].x - origPoints[i].x) < 1) { + skPoints[i].x = skPoints[prevIdx].x; clamped.add(i); propagated = true + } + if (clamped.has(prevIdx) && Math.abs(origPoints[prevIdx].y - origPoints[i].y) < 1) { + skPoints[i].y = skPoints[prevIdx].y; clamped.add(i); propagated = true + } + if (clamped.has(nextIdx) && Math.abs(origPoints[nextIdx].x - origPoints[i].x) < 1) { + skPoints[i].x = skPoints[nextIdx].x; clamped.add(i); propagated = true + } + if (clamped.has(nextIdx) && Math.abs(origPoints[nextIdx].y - origPoints[i].y) < 1) { + skPoints[i].y = skPoints[nextIdx].y; clamped.add(i); propagated = true + } + } + } + + // Step 4. 인접 중복점 제거 (dist < 0.5) + const deduped = [] + for (let i = 0; i < skPoints.length; i++) { + const next = skPoints[(i + 1) % skPoints.length] + if (Math.hypot(next.x - skPoints[i].x, next.y - skPoints[i].y) > 0.5) { + deduped.push(skPoints[i]) + } + } + + // Step 5. 일직선(collinear) 중간점 제거 + const cleaned = [] + for (let i = 0; i < deduped.length; i++) { + const prev = deduped[(i - 1 + deduped.length) % deduped.length] + const curr = deduped[i] + const next = deduped[(i + 1) % deduped.length] + const cross = (curr.x - prev.x) * (next.y - prev.y) - (curr.y - prev.y) * (next.x - prev.x) + if (Math.abs(cross) > 1.0) cleaned.push(curr) + } + + // 보정 실패 시(꼭짓점 부족) 원본 반환 → 기존 동작 보장 + if (cleaned.length >= 3) return cleaned + if (deduped.length >= 3) return deduped + console.warn('[SK_OVER] calcOverCorrectedPoints: 보정 실패 → 원본 반환') + return points +} + /** * 두 점으로 이루어진 선분이 외벽선인지 확인합니다. * @param {object} p1 - 점1 {x, y}