5264 lines
222 KiB
JavaScript
5264 lines
222 KiB
JavaScript
import { LINE_TYPE, POLYGON_TYPE } from '@/common/common'
|
||
import { SkeletonBuilder } from '@/lib/skeletons'
|
||
import { calcLineActualSize, calcLineActualSize2, calcLinePlaneSize, toGeoJSON } from '@/util/qpolygon-utils'
|
||
import { QLine } from '@/components/fabric/QLine'
|
||
import { getDegreeByChon } from '@/util/canvas-util'
|
||
import Big from 'big.js'
|
||
import { QPolygon } from '@/components/fabric/QPolygon'
|
||
import wallLine from '@/components/floor-plan/modal/wallLineOffset/type/WallLine'
|
||
import { logger } from '@/util/logger'
|
||
|
||
/**
|
||
* 지붕 폴리곤의 스켈레톤(중심선)을 생성하고 캔버스에 그립니다.
|
||
* @param {string} roofId - 대상 지붕 객체의 ID
|
||
* @param {fabric.Canvas} canvas - Fabric.js 캔버스 객체
|
||
* @param {string} textMode - 텍스트 표시 모드
|
||
* @param pitch
|
||
*/
|
||
const EPSILON = 0.1
|
||
|
||
// [v1 helper 2026-04-29] B 경로: SK 입력은 그대로 두고, baseLine corner → roofLine corner 까지의
|
||
// 시각 보조 라인만 추가. processInBoth/processInStartEnd 등 working code 무손상.
|
||
// innerLines/skeletonLines 등 기존 collection 에 push 하지 않음 (canvas add 만).
|
||
// 사용자 리셋 명령 없이 리셋되지 않도록 누적 baseLines/roofLines 좌표 그대로 사용.
|
||
// 롤백: false 로 toggle.
|
||
const DRAW_BASELINE_TO_ROOFLINE_HELPER = true
|
||
const BASELINE_TO_ROOFLINE_HELPER_TYPE = 'sk_to_roofline_helper'
|
||
|
||
// [v1 architecture 2026-04-29] SK 입력 = wall.baseLines 직접 (offset 확장/movedPoints 사용 안 함).
|
||
// 사용자 요청: "마루이동/벽이동/offset 으로 wall.baseLines 가 변하면 SK 도 변해야 한다.
|
||
// SK 빌더에는 항상 wall.baseLines 를 넘겨야 함. 그 다음 확장선 그림".
|
||
// changRoofLinePoints = orderedBaseLinePoints (= createOrderedBasePoints(roof.points, wall.baseLines))
|
||
// 기존 45° 확장 / safeMovedPointsWithFallback 결과는 모두 무시.
|
||
// 이렇게 하면 wall.baseLines 의 모든 누적 변경(마루이동/벽이동/offset)이 SK 에 자동 반영.
|
||
// 확장선(B 경로 helper) 이 baseLine corner → roofLine 까지 시각화 담당.
|
||
// 롤백: false → 기존 HEAD 동작 (45° 확장 + movedPoints).
|
||
const SK_INPUT_USE_WALL_BASELINE_DIRECT = true
|
||
|
||
/**
|
||
* 오목(concave) 폴리곤인지 판별합니다.
|
||
* cross product의 부호가 혼재하면 오목 폴리곤입니다.
|
||
*/
|
||
const isConcavePolygon = (points) => {
|
||
if (!points || points.length < 4) return false
|
||
let positive = 0, negative = 0
|
||
for (let i = 0; i < points.length; i++) {
|
||
const prev = points[(i - 1 + points.length) % points.length]
|
||
const curr = points[i]
|
||
const next = points[(i + 1) % points.length]
|
||
const cross = (curr.x - prev.x) * (next.y - curr.y) - (curr.y - prev.y) * (next.x - curr.x)
|
||
if (cross > 0) positive++
|
||
else if (cross < 0) negative++
|
||
}
|
||
return positive > 0 && negative > 0
|
||
}
|
||
|
||
/**
|
||
* 오목 폴리곤에만 미세한 perturbation을 적용하여 동시 이벤트(MultiSplitEvent)를 방지합니다.
|
||
* 대칭적 변 길이(예: 45.5, 45.5)가 동일 거리의 이벤트를 만들어 LAV 불일치 오류를 유발하므로
|
||
* 각 꼭짓점에 인덱스 기반 미세 오프셋을 적용하면 이벤트가 개별 SplitEvent로 처리됩니다.
|
||
* 볼록 폴리곤은 이 문제가 없으므로 원본을 그대로 반환합니다.
|
||
*/
|
||
const perturbPolygonPoints = (points, eps = 1e-4) => {
|
||
if (!points || points.length < 3) return points
|
||
if (!isConcavePolygon(points)) return points
|
||
return points.map((p, i) => ({
|
||
x: p.x + (i + 1) * eps,
|
||
y: p.y + (i + 1) * eps
|
||
}))
|
||
}
|
||
|
||
export const drawSkeletonRidgeRoof = (roofId, canvas, textMode) => {
|
||
|
||
// 2. 스켈레톤 생성 및 그리기
|
||
skeletonBuilder(roofId, canvas, textMode)
|
||
}
|
||
|
||
|
||
const movingLineFromSkeleton = (roofId, canvas) => {
|
||
|
||
let roof = canvas?.getObjects().find((object) => object.id === roofId)
|
||
|
||
|
||
let moveDirection = roof.moveDirect;
|
||
let moveFlowLine = roof.moveFlowLine??0;
|
||
let moveUpDown = roof.moveUpDown??0;
|
||
const getSelectLine = () => roof.moveSelectLine;
|
||
const selectLine = getSelectLine();
|
||
let movePosition = roof.movePosition;
|
||
|
||
const startPoint = selectLine.startPoint
|
||
const endPoint = selectLine.endPoint
|
||
const orgRoofPoints = roof.points; // orgPoint를 orgPoints로 변경
|
||
const oldPoints = canvas?.skeleton.lastPoints ?? orgRoofPoints // 여기도 변경
|
||
// [LP-TRACE] movingLineFromSkeleton 진입 시점 oldPoints[7] 값 — lastPoints 유입값 확인용
|
||
try {
|
||
const _lp = canvas?.skeleton?.lastPoints
|
||
const _src = _lp ? 'lastPoints' : 'orgRoofPoints'
|
||
const _p7 = oldPoints?.[7]
|
||
// logger.log(`[LP-TRACE] movingLineFromSkeleton.in src=${_src} oldPoints[7]=(${_p7?.x?.toFixed(1)},${_p7?.y?.toFixed(1)}) pos=${movePosition} dir=${moveDirection}`)
|
||
} catch (_e) {}
|
||
const oppositeLine = findOppositeLine(canvas.skeleton.Edges, startPoint, endPoint, oldPoints);
|
||
|
||
const wall = canvas.getObjects().find((obj) => obj.name === POLYGON_TYPE.WALL && obj.attributes.roofId === roofId)
|
||
const baseLines = wall.baseLines
|
||
roof.basePoints = createOrderedBasePoints(roof.points, baseLines)
|
||
|
||
const skeletonPolygon = canvas.getObjects().filter((object) => object.skeletonType === 'polygon' && object.parentId === roofId)
|
||
const skeletonLines = canvas.getObjects().filter((object) => object.skeletonType === 'line' && object.parentId === roofId)
|
||
|
||
if (oppositeLine) {
|
||
// logger.log('Opposite line found:', oppositeLine);
|
||
} else {
|
||
// logger.log('No opposite line found');
|
||
}
|
||
|
||
if(moveFlowLine !== 0) {
|
||
return oldPoints.map((point, index) => {
|
||
// logger.log('Point:', point);
|
||
const newPoint = { ...point };
|
||
const absMove = Big(moveFlowLine).times(2).div(10);
|
||
|
||
// logger.log('skeletonBuilder moveDirection:', moveDirection);
|
||
|
||
switch (moveDirection) {
|
||
case 'left':
|
||
// Move left: decrease X
|
||
if (moveFlowLine !== 0) {
|
||
for (const line of oppositeLine) {
|
||
if (line.position === 'left') {
|
||
if (isSamePoint(newPoint, line.start)) {
|
||
newPoint.x = Big(line.start.x).plus(absMove).toNumber();
|
||
} else if (isSamePoint(newPoint, line.end)) {
|
||
newPoint.x = Big(line.end.x).plus(absMove).toNumber();
|
||
}
|
||
|
||
break;
|
||
|
||
}
|
||
|
||
}
|
||
} else if (moveUpDown !== 0) {
|
||
|
||
}
|
||
|
||
break;
|
||
case 'right':
|
||
for (const line of oppositeLine) {
|
||
if (line.position === 'right') {
|
||
if (isSamePoint(newPoint, line.start)) {
|
||
newPoint.x = Big(line.start.x).minus(absMove).toNumber();
|
||
} else if (isSamePoint(newPoint, line.end)) {
|
||
newPoint.x = Big(line.end.x).minus(absMove).toNumber();
|
||
}
|
||
break
|
||
|
||
}
|
||
|
||
}
|
||
|
||
break;
|
||
case 'up':
|
||
// Move up: decrease Y (toward top of screen)
|
||
|
||
for (const line of oppositeLine) {
|
||
if (line.position === 'top') {
|
||
if (isSamePoint(newPoint, line.start)) {
|
||
newPoint.y = Big(line.start.y).minus(absMove).toNumber();
|
||
} else if (isSamePoint(newPoint, line.end)) {
|
||
newPoint.y = Big(line.end.y).minus(absMove).toNumber();
|
||
}
|
||
break;
|
||
|
||
}
|
||
}
|
||
|
||
break;
|
||
case 'down':
|
||
// Move down: increase Y (toward bottom of screen)
|
||
for (const line of oppositeLine) {
|
||
|
||
if (line.position === 'bottom') {
|
||
|
||
// logger.log('oldPoint:', point);
|
||
|
||
if (isSamePoint(newPoint, line.start)) {
|
||
newPoint.y = Big(line.start.y).minus(absMove).toNumber();
|
||
|
||
} else if (isSamePoint(newPoint, line.end)) {
|
||
newPoint.y = Big(line.end.y).minus(absMove).toNumber();
|
||
}
|
||
|
||
|
||
break;
|
||
}
|
||
|
||
}
|
||
break;
|
||
default :
|
||
// 사용 예시
|
||
}
|
||
|
||
// logger.log('newPoint:', newPoint);
|
||
//baseline 변경
|
||
return newPoint;
|
||
})
|
||
} else if(moveUpDown !== 0) {
|
||
|
||
|
||
// const selectLine = getSelectLine();
|
||
//
|
||
// logger.log("wall::::", wall.points)
|
||
// logger.log("저장된 3333moveSelectLine:", roof.moveSelectLine);
|
||
// logger.log("저장된 3moveSelectLine:", selectLine);
|
||
// const result = getSelectLinePosition(wall, selectLine, {
|
||
// testDistance: 5, // 테스트 거리
|
||
// debug: true // 디버깅 로그 출력
|
||
// });
|
||
// logger.log("3333linePosition:::::", result.position);
|
||
|
||
const position = movePosition //result.position;
|
||
const moveUpDownLength = Big(moveUpDown).times(1).div(10);
|
||
const modifiedStartPoints = [];
|
||
// oldPoints를 복사해서 새로운 points 배열 생성
|
||
let newPoints = oldPoints.map(point => ({...point}));
|
||
|
||
// selectLine과 일치하는 baseLines 찾기
|
||
// tolerance=0.5 사용 이유: selectLine 은 UI 클릭 시점 좌표(마우스 hover 수치와
|
||
// 클릭 시점 값 사이에 미세 차이 발생), wall.baseLines 는 Big.js 누적 연산 결과.
|
||
// 2자리 소수 입력 + /10 + offset 연산 + wall 재생성 누적으로 실측 drift 상한 ~0.3~0.4.
|
||
// 기본 0.1 은 빡빡해 filter 0개 매칭으로 dy 미적용 → "못 미침" 발생.
|
||
// 0.5 는 드리프트 상한 여유 포용 + 이웃 edge 오매칭 위험 사실상 0 의 균형점.
|
||
const matchingLines = baseLines
|
||
.map((line, index) => ({ ...line, findIndex: index }))
|
||
.filter(line =>
|
||
(isSamePoint(line.startPoint, selectLine.startPoint, 0.5) &&
|
||
isSamePoint(line.endPoint, selectLine.endPoint, 0.5)) ||
|
||
(isSamePoint(line.startPoint, selectLine.endPoint, 0.5) &&
|
||
isSamePoint(line.endPoint, selectLine.startPoint, 0.5))
|
||
);
|
||
|
||
|
||
// 평행 이동 영향 꼭짓점 인덱스 집합 계산 (index-direct + Set dedup)
|
||
// baseLines[k] 는 polygon edge k (roof.points[k] → roof.points[(k+1)%n]) 에 대응하므로
|
||
// 매칭된 baseLine 의 인덱스 k 가 곧 이동 영향을 받는 두 꼭짓점 [k, (k+1)%n] 을 결정한다.
|
||
// 기존 구현의 좌표 기반 매칭(roof.basePoints[index] vs originalStart/End)은 wall 오프셋
|
||
// 붕괴로 degenerate baseLines 가 생기거나 basePoints[i] 가 선택된 edge 의 꼭짓점과 좌표
|
||
// 우연 일치하는 경우, 의도치 않은 꼭짓점에 dy 가 적용되어 축직교가 깨지고 SkeletonBuilder
|
||
// 가 추가 보조선을 생성하며 SK 붕괴로 이어지던 경로였음.
|
||
// matchingLines 가 2개 이상인 케이스(보강 라인 중복 push, 좌표 동일 edge 등)에서도
|
||
// Set 으로 합집합 처리해 같은 꼭짓점에 dy 가 2번 적용되는 것을 막는다.
|
||
const nPts = newPoints.length;
|
||
const affected = new Set();
|
||
matchingLines.forEach(line => {
|
||
const k = line.findIndex;
|
||
if (typeof k !== 'number' || k < 0) return;
|
||
affected.add(k);
|
||
affected.add((k + 1) % nPts);
|
||
});
|
||
// logger.log('absMove::', moveUpDownLength);
|
||
// [BR-TRACE local] movingLineFromSkeleton position/direction 분기
|
||
const __isLocalMS = process.env.NEXT_PUBLIC_RUN_MODE === 'local'
|
||
// if (__isLocalMS) logger.log(`[BR-TRACE] movingLineFromSkeleton position=${position} dir=${moveDirection} affected=${[...affected].join(',')}`)
|
||
affected.forEach((i) => {
|
||
const point = newPoints[i];
|
||
if (!point) return;
|
||
const __bx = point.x, __by = point.y;
|
||
if (position === 'bottom') {
|
||
if (moveDirection === 'in') point.y = Big(point.y).minus(moveUpDownLength).toNumber();
|
||
else if (moveDirection === 'out') point.y = Big(point.y).plus(moveUpDownLength).toNumber();
|
||
// if (__isLocalMS) logger.log(`[BR-TRACE] i=${i} bottom/${moveDirection} y ${__by}→${point.y}`)
|
||
} else if (position === 'top') {
|
||
if (moveDirection === 'in') point.y = Big(point.y).plus(moveUpDownLength).toNumber();
|
||
else if (moveDirection === 'out') point.y = Big(point.y).minus(moveUpDownLength).toNumber();
|
||
// if (__isLocalMS) logger.log(`[BR-TRACE] i=${i} top/${moveDirection} y ${__by}→${point.y}`)
|
||
} else if (position === 'left') {
|
||
if (moveDirection === 'in') point.x = Big(point.x).plus(moveUpDownLength).toNumber();
|
||
else if (moveDirection === 'out') point.x = Big(point.x).minus(moveUpDownLength).toNumber();
|
||
// if (__isLocalMS) logger.log(`[BR-TRACE] i=${i} left/${moveDirection} x ${__bx}→${point.x}`)
|
||
} else if (position === 'right') {
|
||
if (moveDirection === 'in') point.x = Big(point.x).minus(moveUpDownLength).toNumber();
|
||
else if (moveDirection === 'out') point.x = Big(point.x).plus(moveUpDownLength).toNumber();
|
||
// if (__isLocalMS) logger.log(`[BR-TRACE] i=${i} right/${moveDirection} x ${__bx}→${point.x}`)
|
||
} else if (__isLocalMS) {
|
||
logger.warn(`[BR-TRACE] i=${i} position=${position} 매칭 분기 없음 (이동 안 됨)`)
|
||
}
|
||
});
|
||
|
||
/**
|
||
* 직선다각형을 이루지 못하는 좌표를 삭제합니다.
|
||
* @param {Array<object>} points - 폴리곤 좌표 배열
|
||
* @returns {Array<object>} 정리된 좌표 배열
|
||
*/
|
||
function removeNonOrthogonalPoints(points) {
|
||
if (!points || points.length < 3) return points;
|
||
|
||
const EPSILON = 1.0;
|
||
|
||
const isOrthogonal = (p1, p2) =>
|
||
Math.abs(p1.x - p2.x) < EPSILON || Math.abs(p1.y - p2.y) < EPSILON;
|
||
|
||
let current = [...points];
|
||
let changed = true;
|
||
|
||
// 1. 대각선을 만드는 점 제거
|
||
while (changed && current.length >= 3) {
|
||
changed = false;
|
||
for (let i = 0; i < current.length; i++) {
|
||
const pPrev = current[(i - 1 + current.length) % current.length];
|
||
const pCurr = current[i];
|
||
const pNext = current[(i + 1) % current.length];
|
||
|
||
// 현재 점(pCurr)을 기준으로 앞뒤 연결이 모두 직교하지 않거나,
|
||
// 현재 점을 제거했을 때 앞뒤 점(pPrev, pNext)이 직교하게 된다면 현재 점이 불필요한 "꺾임"일 수 있음.
|
||
if (!isOrthogonal(pPrev, pCurr) || !isOrthogonal(pCurr, pNext)) {
|
||
if (isOrthogonal(pPrev, pNext)) {
|
||
current.splice(i, 1);
|
||
changed = true;
|
||
break;
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
// 2. 일직선상의 중간 점 제거 (수평 또는 수직선상에 세 점이 있는 경우)
|
||
changed = true;
|
||
while (changed && current.length >= 3) {
|
||
changed = false;
|
||
for (let i = 0; i < current.length; i++) {
|
||
const p1 = current[i];
|
||
const p2 = current[(i + 1) % current.length];
|
||
const p3 = current[(i + 2) % current.length];
|
||
|
||
if ((Math.abs(p1.x - p2.x) < EPSILON && Math.abs(p2.x - p3.x) < EPSILON) ||
|
||
(Math.abs(p1.y - p2.y) < EPSILON && Math.abs(p2.y - p3.y) < EPSILON)) {
|
||
current.splice((i + 1) % current.length, 1);
|
||
changed = true;
|
||
break;
|
||
}
|
||
}
|
||
}
|
||
|
||
return current;
|
||
}
|
||
|
||
|
||
const cleaned = removeNonOrthogonalPoints(newPoints);
|
||
// logger.log(cleaned)
|
||
|
||
return cleaned;
|
||
}
|
||
}
|
||
|
||
|
||
/**
|
||
* movingLineFromSkeleton 결과를 안전하게 얻기 위한 래퍼.
|
||
* - 정상이면 movedPoints 반환
|
||
* - 골짜기 라인 out 등으로 옆 라인을 넘어가 붕괴/자기교차가 감지되면 null 반환
|
||
* (호출부에서 null이면 skeletonBuilder 전체를 bail out 시킬 것)
|
||
*
|
||
* 기존 movingLineFromSkeleton 로직은 건드리지 않는다.
|
||
*
|
||
* @param {string} roofId
|
||
* @param {fabric.Canvas} canvas
|
||
* @returns {Array<{x:number,y:number}> | null}
|
||
*/
|
||
/**
|
||
* canvas.skeleton.lastPoints 에 저장할 "축소되지 않은(raw) 풀 길이(roof.points.length) 8점 버전" 을 생성.
|
||
*
|
||
* 문제:
|
||
* movingLineFromSkeleton 은 마지막에 removeNonOrthogonalPoints 를 거치며 collinear/중복 점을
|
||
* 제거하므로, 반환 길이가 roof.points.length 보다 작을 수 있다 (예: 8 → 6).
|
||
* 이 축소된 값을 lastPoints 에 저장하면, 다음 이동 때 인덱스 기반 매칭이
|
||
* 전부 깨져서 1차 이동 결과가 사라진다.
|
||
*
|
||
* 처리:
|
||
* movingLineFromSkeleton 의 "shift 부분" 만 재현하여 removeNonOrthogonalPoints 생략.
|
||
* roof.points.length 길이를 정확히 유지한 "원시 이동 결과" 를 리턴.
|
||
* 실패/미지원 시 null 리턴 → 호출부는 기존 roofLineContactPoints 사용 (기존 동작 보장).
|
||
*
|
||
* 기존 movingLineFromSkeleton 은 수정하지 않는다.
|
||
*
|
||
* @param {string} roofId
|
||
* @param {fabric.Canvas} canvas
|
||
* @returns {Array<{x:number,y:number}> | null}
|
||
*/
|
||
const buildRawMovedPoints = (roofId, canvas) => {
|
||
try {
|
||
const roof = canvas?.getObjects().find((o) => o.id === roofId)
|
||
if (!roof || !Array.isArray(roof.points)) return null
|
||
|
||
const moveFlowLine = roof.moveFlowLine ?? 0
|
||
const moveUpDown = roof.moveUpDown ?? 0
|
||
if (moveFlowLine === 0 && moveUpDown === 0) return null
|
||
|
||
// moveFlowLine 의 경우 기존 movingLineFromSkeleton 이 길이 축소를 하지 않으므로
|
||
// 여기서 raw 재구성 불필요 → null (fallback 으로 기존 값 사용).
|
||
if (moveFlowLine !== 0 && moveUpDown === 0) return null
|
||
|
||
const orgRoofPoints = roof.points
|
||
const prevLast = canvas?.skeleton?.lastPoints
|
||
|
||
// 풀 길이(roof.points.length) oldPoints 구성
|
||
// prevLast 가 더 짧으면(이미 축소되었던 상태) 부족분은 orgRoofPoints 로 채움.
|
||
const oldPoints = []
|
||
for (let i = 0; i < orgRoofPoints.length; i++) {
|
||
const src = (prevLast && prevLast[i]) ? prevLast[i] : orgRoofPoints[i]
|
||
oldPoints.push({ x: src.x, y: src.y })
|
||
}
|
||
// [LP-TRACE] buildRawMovedPoints 진입 — prevLast 유무 및 [7] 현재 값
|
||
try {
|
||
const _pl7 = prevLast?.[7]
|
||
// logger.log(`[LP-TRACE] buildRaw.in prevLast=${prevLast ? 'Y' : 'N'} prevLast[7]=(${_pl7?.x?.toFixed(1)},${_pl7?.y?.toFixed(1)}) oldPoints[7]=(${oldPoints[7]?.x?.toFixed(1)},${oldPoints[7]?.y?.toFixed(1)})`)
|
||
} catch (_e) {}
|
||
|
||
const selectLine = roof.moveSelectLine
|
||
if (!selectLine) return oldPoints
|
||
|
||
const wall = canvas.getObjects().find((o) => o.name === POLYGON_TYPE.WALL && o.attributes.roofId === roofId)
|
||
if (!wall || !Array.isArray(wall.baseLines)) return oldPoints
|
||
const baseLines = wall.baseLines
|
||
const basePoints = createOrderedBasePoints(roof.points, baseLines)
|
||
|
||
const newPoints = oldPoints.map((p) => ({ x: p.x, y: p.y }))
|
||
|
||
const moveUpDownLength = Big(moveUpDown).times(1).div(10)
|
||
const position = roof.movePosition
|
||
const moveDirection = roof.moveDirect
|
||
|
||
// matchingLines 는 LP-TRACE 로그 / 외부 참조용으로 count 유지.
|
||
// 실제 dy 적용은 인덱스 직접 매핑 + Set 합집합 (movingLineFromSkeleton 과 동일 규칙)으로
|
||
// baseLines[k] = edge k = roof.points[k] → roof.points[(k+1)%n]
|
||
// 좌표 우연 일치 / degenerate baseLines 로부터의 오염을 차단.
|
||
// tolerance=0.5 : movingLineFromSkeleton 과 동일. UI 클릭 drift + Big.js 누적 연산
|
||
// drift 포용. 두 경로(save/verify)가 동일 임계값을 사용해야 보정 결과가 일관됨.
|
||
const matchingLines = baseLines
|
||
.map((line, idx) => ({ line, idx }))
|
||
.filter(({ line }) =>
|
||
(isSamePoint(line.startPoint, selectLine.startPoint, 0.5) && isSamePoint(line.endPoint, selectLine.endPoint, 0.5)) ||
|
||
(isSamePoint(line.startPoint, selectLine.endPoint, 0.5) && isSamePoint(line.endPoint, selectLine.startPoint, 0.5))
|
||
)
|
||
|
||
const nPts = newPoints.length
|
||
const affected = new Set()
|
||
matchingLines.forEach(({ idx }) => {
|
||
affected.add(idx)
|
||
affected.add((idx + 1) % nPts)
|
||
})
|
||
// [BR-TRACE local] buildRawMovedPoints position/direction 분기
|
||
const __isLocalBR = process.env.NEXT_PUBLIC_RUN_MODE === 'local'
|
||
// if (__isLocalBR) logger.log(`[BR-TRACE] buildRawMovedPoints position=${position} dir=${moveDirection} affected=${[...affected].join(',')}`)
|
||
affected.forEach((i) => {
|
||
const point = newPoints[i]
|
||
if (!point) return
|
||
const __bx = point.x, __by = point.y
|
||
if (position === 'bottom') {
|
||
if (moveDirection === 'out') point.y = Big(point.y).plus(moveUpDownLength).toNumber()
|
||
else if (moveDirection === 'in') point.y = Big(point.y).minus(moveUpDownLength).toNumber()
|
||
// if (__isLocalBR) logger.log(`[BR-TRACE] i=${i} bottom/${moveDirection} y ${__by}→${point.y}`)
|
||
} else if (position === 'top') {
|
||
if (moveDirection === 'out') point.y = Big(point.y).minus(moveUpDownLength).toNumber()
|
||
else if (moveDirection === 'in') point.y = Big(point.y).plus(moveUpDownLength).toNumber()
|
||
// if (__isLocalBR) logger.log(`[BR-TRACE] i=${i} top/${moveDirection} y ${__by}→${point.y}`)
|
||
} else if (position === 'left') {
|
||
if (moveDirection === 'out') point.x = Big(point.x).minus(moveUpDownLength).toNumber()
|
||
else if (moveDirection === 'in') point.x = Big(point.x).plus(moveUpDownLength).toNumber()
|
||
// if (__isLocalBR) logger.log(`[BR-TRACE] i=${i} left/${moveDirection} x ${__bx}→${point.x}`)
|
||
} else if (position === 'right') {
|
||
if (moveDirection === 'out') point.x = Big(point.x).plus(moveUpDownLength).toNumber()
|
||
else if (moveDirection === 'in') point.x = Big(point.x).minus(moveUpDownLength).toNumber()
|
||
// if (__isLocalBR) logger.log(`[BR-TRACE] i=${i} right/${moveDirection} x ${__bx}→${point.x}`)
|
||
} else if (__isLocalBR) {
|
||
logger.warn(`[BR-TRACE] i=${i} position=${position} 매칭 분기 없음 (이동 안 됨)`)
|
||
}
|
||
})
|
||
|
||
// [LP-TRACE] buildRawMovedPoints 결과 — matchingLines 수와 결과 [7] 값
|
||
try {
|
||
const _sel = selectLine
|
||
const _sp = _sel?.startPoint
|
||
const _ep = _sel?.endPoint
|
||
// logger.log(`[LP-TRACE] buildRaw.out matchingLines=${matchingLines.length} selSP=(${_sp?.x?.toFixed(1)},${_sp?.y?.toFixed(1)}) selEP=(${_ep?.x?.toFixed(1)},${_ep?.y?.toFixed(1)}) bp7=(${basePoints[7]?.x?.toFixed(1)},${basePoints[7]?.y?.toFixed(1)}) newPoints[7]=(${newPoints[7]?.x?.toFixed(1)},${newPoints[7]?.y?.toFixed(1)})`)
|
||
} catch (_e) {}
|
||
|
||
return newPoints
|
||
} catch (e) {
|
||
logger.warn('[buildRawMovedPoints] 실패 → null fallback:', e)
|
||
return null
|
||
}
|
||
}
|
||
|
||
|
||
/**
|
||
* 이번 이동이 "경계" 상태에 대해 어떤 위치에 있는지 판정.
|
||
*
|
||
* 'ok' : 골짜기(valley) 유지 — 정상 이동
|
||
* 'on-boundary' : 골짜기가 정확히 외곽과 평행/일치 상태 — 허용되는 마지막 상태
|
||
* 'crossed' : 골짜기가 볼록으로 뒤집힘 — 옆 라인을 넘어감 (거부 대상)
|
||
* 'unknown' : 판정 불가 (데이터 부족 등) — 기존 흐름 그대로 진행
|
||
*
|
||
* 재료:
|
||
* - 기존 getTurnDirection() (CCW 외적) 재사용
|
||
* - 이번 이동 후 가상 폴리곤은 buildRawMovedPoints() 로 획득
|
||
* - 원본 roof.points 의 cross 부호와 비교
|
||
*
|
||
* 기존 함수(getTurnDirection, buildRawMovedPoints 등) 는 수정하지 않는다.
|
||
*
|
||
* @param {string} roofId
|
||
* @param {fabric.Canvas} canvas
|
||
* @returns {'ok'|'on-boundary'|'crossed'|'unknown'}
|
||
*/
|
||
export const verifyMoveBoundary = (roofId, canvas) => {
|
||
try {
|
||
const roof = canvas?.getObjects().find((o) => o.id === roofId)
|
||
if (!roof || !Array.isArray(roof.points)) {
|
||
// logger.log('[verifyMoveBoundary] roof/points 없음 → unknown')
|
||
return 'unknown'
|
||
}
|
||
|
||
const moveFlowLine = roof.moveFlowLine ?? 0
|
||
const moveUpDown = roof.moveUpDown ?? 0
|
||
const position = roof.movePosition
|
||
const direction = roof.moveDirect
|
||
// logger.log(
|
||
// `[verifyMoveBoundary] 진입 position=${position} direction=${direction} moveUpDown=${moveUpDown} moveFlowLine=${moveFlowLine}`
|
||
// )
|
||
if (moveFlowLine === 0 && moveUpDown === 0) {
|
||
// logger.log('[verifyMoveBoundary] 이동 없음 → ok')
|
||
return 'ok'
|
||
}
|
||
|
||
const proposed = buildRawMovedPoints(roofId, canvas)
|
||
const orig = roof.points
|
||
if (!Array.isArray(proposed) || proposed.length !== orig.length) {
|
||
// logger.log(
|
||
// `[verifyMoveBoundary] proposed 비정상 (len=${proposed?.length}, orig.len=${orig.length}) → unknown`
|
||
// )
|
||
return 'unknown'
|
||
}
|
||
|
||
const n = orig.length
|
||
if (n < 3) return 'unknown'
|
||
|
||
const posTol = 0.5
|
||
let worst = 'ok'
|
||
const movedIdx = []
|
||
|
||
for (let i = 0; i < n; i++) {
|
||
const moved =
|
||
Math.abs(proposed[i].x - orig[i].x) > posTol || Math.abs(proposed[i].y - orig[i].y) > posTol
|
||
if (!moved) continue
|
||
movedIdx.push(i)
|
||
|
||
const prev = (i - 1 + n) % n
|
||
const next = (i + 1) % n
|
||
|
||
const crossOrig = getTurnDirection(orig[prev], orig[i], orig[next])
|
||
const crossNew = getTurnDirection(proposed[prev], proposed[i], proposed[next])
|
||
|
||
// logger.log(
|
||
// `[verifyMoveBoundary] [${i}] orig=(${Math.round(orig[i].x)},${Math.round(orig[i].y)}) → proposed=(${Math.round(proposed[i].x)},${Math.round(proposed[i].y)}) crossOrig=${crossOrig.toFixed(1)} crossNew=${crossNew.toFixed(1)}`
|
||
// )
|
||
|
||
// 원래 골짜기(>0) 였던 꼭짓점만 경계 판정 대상
|
||
if (crossOrig > 0) {
|
||
// 상대 허용오차: 원본 cross 크기 5% 이내면 "0 근처" 로 간주 (on-boundary)
|
||
const boundaryTol = Math.max(1.0, Math.abs(crossOrig) * 0.05)
|
||
|
||
if (crossNew < -boundaryTol) {
|
||
logger.warn(
|
||
`[verifyMoveBoundary] 꼭짓점[${i}] 골짜기 → 볼록 뒤집힘 (CROSSED): ${crossOrig.toFixed(1)} → ${crossNew.toFixed(1)}`
|
||
)
|
||
return 'crossed'
|
||
}
|
||
if (Math.abs(crossNew) <= boundaryTol) {
|
||
// logger.log(
|
||
// `[verifyMoveBoundary] 꼭짓점[${i}] 경계 도달 (on-boundary): cross ≈ 0 (${crossNew.toFixed(1)})`
|
||
// )
|
||
if (worst === 'ok') worst = 'on-boundary'
|
||
}
|
||
}
|
||
}
|
||
|
||
if (movedIdx.length === 0) {
|
||
// logger.log('[verifyMoveBoundary] 이동된 꼭짓점 0개 (buildRawMovedPoints 매칭 실패 가능성)')
|
||
}
|
||
// logger.log(`[verifyMoveBoundary] 최종 판정: ${worst} (moved indices=[${movedIdx.join(',')}])`)
|
||
return worst
|
||
} catch (e) {
|
||
logger.warn('[verifyMoveBoundary] 판정 실패 → unknown:', e)
|
||
return 'unknown'
|
||
}
|
||
}
|
||
|
||
|
||
const safeMovedPointsWithFallback = (roofId, canvas) => {
|
||
const roof = canvas?.getObjects().find((object) => object.id === roofId)
|
||
const orgRoofPoints = roof?.points ?? []
|
||
const lastPoints = canvas?.skeleton?.lastPoints ?? null
|
||
const oldPoints = lastPoints ?? orgRoofPoints
|
||
|
||
const movedPoints = movingLineFromSkeleton(roofId, canvas)
|
||
|
||
if (!Array.isArray(movedPoints) || movedPoints.length === 0) {
|
||
logger.warn('[safeMovedPointsWithFallback] movedPoints 비정상 → bail out')
|
||
return null
|
||
}
|
||
|
||
const tolerance = 0.5
|
||
|
||
// 1) 다각형 붕괴/자기교차 감지
|
||
// (a) zero-length edge (연속된 점이 동일 좌표)
|
||
// (b) 점 개수가 oldPoints 대비 절반 이하로 급감
|
||
// (c) 세그먼트 자기교차 (옆 라인을 넘어가는 케이스)
|
||
let zeroLenEdges = 0
|
||
for (let i = 0; i < movedPoints.length; i++) {
|
||
const a = movedPoints[i]
|
||
const b = movedPoints[(i + 1) % movedPoints.length]
|
||
if (!a || !b) continue
|
||
if (Math.abs(a.x - b.x) < tolerance && Math.abs(a.y - b.y) < tolerance) {
|
||
zeroLenEdges++
|
||
}
|
||
}
|
||
|
||
const severeReduction =
|
||
oldPoints.length >= 6 && movedPoints.length > 0 && movedPoints.length <= Math.floor(oldPoints.length / 2)
|
||
|
||
// 자기교차: 인접하지 않은 두 세그먼트가 교차하는지 검사
|
||
const segIntersect = (p1, p2, p3, p4) => {
|
||
const d = (p2.x - p1.x) * (p4.y - p3.y) - (p2.y - p1.y) * (p4.x - p3.x)
|
||
if (Math.abs(d) < 1e-9) return false
|
||
const t = ((p3.x - p1.x) * (p4.y - p3.y) - (p3.y - p1.y) * (p4.x - p3.x)) / d
|
||
const u = ((p3.x - p1.x) * (p2.y - p1.y) - (p3.y - p1.y) * (p2.x - p1.x)) / d
|
||
return t > 1e-6 && t < 1 - 1e-6 && u > 1e-6 && u < 1 - 1e-6
|
||
}
|
||
let selfIntersect = false
|
||
const n = movedPoints.length
|
||
outer: for (let i = 0; i < n; i++) {
|
||
const a1 = movedPoints[i]
|
||
const a2 = movedPoints[(i + 1) % n]
|
||
for (let j = i + 2; j < n; j++) {
|
||
// 마지막-첫번째 인접 세그먼트는 스킵
|
||
if (i === 0 && j === n - 1) continue
|
||
const b1 = movedPoints[j]
|
||
const b2 = movedPoints[(j + 1) % n]
|
||
if (!a1 || !a2 || !b1 || !b2) continue
|
||
if (segIntersect(a1, a2, b1, b2)) {
|
||
selfIntersect = true
|
||
break outer
|
||
}
|
||
}
|
||
}
|
||
|
||
if (zeroLenEdges > 0 || severeReduction || selfIntersect) {
|
||
logger.warn('[safeMovedPointsWithFallback] 붕괴/교차 감지 (로그만)', {
|
||
movedLen: movedPoints.length,
|
||
oldLen: oldPoints.length,
|
||
zeroLenEdges,
|
||
severeReduction,
|
||
selfIntersect,
|
||
})
|
||
}
|
||
|
||
return movedPoints
|
||
}
|
||
|
||
|
||
/**
|
||
* SkeletonBuilder를 사용하여 스켈레톤을 생성하고 내부선을 그립니다.
|
||
* @param {string} roofId - 지붕 ID
|
||
* @param {fabric.Canvas} canvas - 캔버스 객체
|
||
* @param {string} textMode - 텍스트 모드
|
||
* @param {fabric.Object} roof - 지붕 객체
|
||
* @param baseLines
|
||
*/
|
||
// [v1 helper 2026-04-29] 각 baseLine corner 에서 정확히 45° 대각 방향으로 ray cast →
|
||
// 먼저 만나는 roofLine 세그먼트와의 교점까지 라인 그리기.
|
||
// - 방향 = (signDx, signDy)/√2, 즉 4사분면 정확 대각.
|
||
// signDx = sign(roofLinePoints[i].x - baseLinePoints[i].x) ← HEAD 의 contactData 와 동일 식
|
||
// signDy = sign(roofLinePoints[i].y - baseLinePoints[i].y)
|
||
// 0 폴백 = centroid 기준 (HEAD line 852~853 동일).
|
||
// - outward bisector(변 normal 합) 식은 비대각 (= "90° 꺾임" 으로 보임) → 사용 안 함.
|
||
// - canvas.add 만 수행 (innerLines/skeletonLines 영향 0).
|
||
// - 누적 보존: 인자 baseLines/roofLines 그대로, 리셋 없음.
|
||
// - 롤백: DRAW_BASELINE_TO_ROOFLINE_HELPER = false.
|
||
const drawBaselineToRooflineHelpers = (roof, canvas, baseLinePoints, roofLinePoints) => {
|
||
if (!DRAW_BASELINE_TO_ROOFLINE_HELPER) return
|
||
if (!Array.isArray(baseLinePoints) || baseLinePoints.length < 3) return
|
||
if (!Array.isArray(roofLinePoints) || roofLinePoints.length < 3) return
|
||
if (!Array.isArray(roof.innerLines) || roof.innerLines.length === 0) return
|
||
const m = roofLinePoints.length
|
||
|
||
// ray (P, dir) ∩ segment (A, B) → t (>0). 없으면 Infinity.
|
||
const rayHitSegment = (P, dir, A, B) => {
|
||
const sx = B.x - A.x, sy = B.y - A.y
|
||
const denom = dir.x * sy - dir.y * sx
|
||
if (Math.abs(denom) < 1e-9) return Infinity
|
||
const ax = A.x - P.x, ay = A.y - P.y
|
||
const t = (ax * sy - ay * sx) / denom
|
||
const s = (ax * dir.y - ay * dir.x) / denom
|
||
if (t > 1e-6 && s >= -1e-6 && s <= 1 + 1e-6) return t
|
||
return Infinity
|
||
}
|
||
|
||
// 점-선분 거리. P 가 polygon edge 위(또는 매우 가까이)인지 검사용.
|
||
const pointSegDist = (P, A, B) => {
|
||
const sx = B.x - A.x, sy = B.y - A.y
|
||
const lenSq = sx * sx + sy * sy
|
||
if (lenSq < 1e-9) return Math.hypot(P.x - A.x, P.y - A.y)
|
||
let t = ((P.x - A.x) * sx + (P.y - A.y) * sy) / lenSq
|
||
t = Math.max(0, Math.min(1, t))
|
||
return Math.hypot(P.x - (A.x + t * sx), P.y - (A.y + t * sy))
|
||
}
|
||
const isPointOnRoofLine = (P, eps = 1.0) => {
|
||
for (let i = 0; i < m; i++) {
|
||
const d = pointSegDist(P, roofLinePoints[i], roofLinePoints[(i + 1) % m])
|
||
if (d < eps) return true
|
||
}
|
||
return false
|
||
}
|
||
|
||
// baseLine corner 중 P 와 가장 가까운 거리.
|
||
const minDistToBaseLine = (P) => {
|
||
let min = Infinity
|
||
for (const c of baseLinePoints) {
|
||
const d = Math.hypot(P.x - c.x, P.y - c.y)
|
||
if (d < min) min = d
|
||
}
|
||
return min
|
||
}
|
||
|
||
// [v1 ext 2026-04-29] HIP 라인만 추출.
|
||
// ext = SK(hip) 의 연장선이자 처마 라인이므로 항상 대각선.
|
||
// corner 갯수 무관, hip 갯수 기반 → 평행오버 후 잔여 corner 가 만들어내던 헛 ext 제거.
|
||
const hipLines = roof.innerLines.filter((il) => {
|
||
if (!il || !isFinite(il.x1) || !isFinite(il.y1) || !isFinite(il.x2) || !isFinite(il.y2)) return false
|
||
return il.lineName === 'hip' || il.name === 'hip'
|
||
})
|
||
// logger.log(`[v1 ext] HIP 추출 ${hipLines.length}개 / 전체 innerLines ${roof.innerLines.length}`)
|
||
|
||
// 절삭 대상: roofLine seg + canvas 의 다른 보조라인 (eaveHelpLine 등).
|
||
// 기존 helper 자체(BASELINE_TO_ROOFLINE_HELPER_TYPE) / wall/roof 본체 / 텍스트 등은 제외.
|
||
const segs = []
|
||
for (let i = 0; i < m; i++) {
|
||
segs.push({ A: roofLinePoints[i], B: roofLinePoints[(i + 1) % m] })
|
||
}
|
||
const excludedNames = new Set([
|
||
'wall', 'roof', 'WALL', 'ROOF',
|
||
'lengthText', 'outerLine', 'baseLine', 'outerLinePoint',
|
||
BASELINE_TO_ROOFLINE_HELPER_TYPE,
|
||
])
|
||
const canvasObjs = (typeof canvas?.getObjects === 'function') ? canvas.getObjects() : []
|
||
for (const obj of canvasObjs) {
|
||
if (!obj) continue
|
||
if (obj.parentId !== roof.id) continue
|
||
if (excludedNames.has(obj.name) || excludedNames.has(obj.lineName)) continue
|
||
if (!isFinite(obj.x1) || !isFinite(obj.y1) || !isFinite(obj.x2) || !isFinite(obj.y2)) continue
|
||
if (Math.hypot(obj.x2 - obj.x1, obj.y2 - obj.y1) < 0.5) continue
|
||
segs.push({ A: { x: obj.x1, y: obj.y1 }, B: { x: obj.x2, y: obj.y2 }, __name: obj.name || obj.lineName })
|
||
}
|
||
|
||
// [BR-SEGS] local only — segs 에 들어간 aux 라인 목록 (eaveHelpLine 등 보조선 포함 여부 진단)
|
||
if (process.env.NEXT_PUBLIC_RUN_MODE === 'local') {
|
||
const auxList = []
|
||
for (let k = m; k < segs.length; k++) {
|
||
const s = segs[k]
|
||
auxList.push(`${s.__name || '?'}=(${Math.round(s.A.x)},${Math.round(s.A.y)})→(${Math.round(s.B.x)},${Math.round(s.B.y)}) len=${Math.round(Math.hypot(s.B.x - s.A.x, s.B.y - s.A.y))}`)
|
||
}
|
||
// logger.log(`[BR-SEGS] roofLine=${m} aux=${segs.length - m}`, auxList)
|
||
}
|
||
|
||
// [v1 ext 정정] ext 는 wallbaseLine 의 꼭지점(corner) 에 outer 끝점이 일치하는 hip 만 대상.
|
||
// 내부에서 끝나는 hip(ridge 쪽으로만 향하는 hip) 은 ext 대상 아님.
|
||
const CORNER_EPS = 1.0
|
||
for (const hip of hipLines) {
|
||
const p1 = { x: hip.x1, y: hip.y1 }
|
||
const p2 = { x: hip.x2, y: hip.y2 }
|
||
|
||
// outerEnd = baseLine corner 와 가까운 끝. 반대는 inner(ridge 쪽).
|
||
const d1 = minDistToBaseLine(p1)
|
||
const d2 = minDistToBaseLine(p2)
|
||
const outerEnd = d1 < d2 ? p1 : p2
|
||
const innerEnd = d1 < d2 ? p2 : p1
|
||
const outerCornerDist = Math.min(d1, d2)
|
||
|
||
// outer 끝이 wallbaseLine corner 에 일치하지 않으면 ext 대상 아님.
|
||
if (outerCornerDist > CORNER_EPS) {
|
||
// logger.log(
|
||
// `[v1 ext] hip outer=(${outerEnd.x.toFixed(1)},${outerEnd.y.toFixed(1)}) ` +
|
||
// `corner 거리=${outerCornerDist.toFixed(2)} > ${CORNER_EPS} → 내부 hip skip`
|
||
// )
|
||
continue
|
||
}
|
||
|
||
// 이미 처마(roofLine) 위에 있으면 ext 불필요.
|
||
if (isPointOnRoofLine(outerEnd, 1.0)) {
|
||
// logger.log(`[v1 ext] hip outer=(${outerEnd.x.toFixed(1)},${outerEnd.y.toFixed(1)}) 이미 roofLine 위 → skip`)
|
||
continue
|
||
}
|
||
|
||
// 방향 = innerEnd → outerEnd 의 unit vec (hip 자체 진행 방향 그대로).
|
||
const dx = outerEnd.x - innerEnd.x
|
||
const dy = outerEnd.y - innerEnd.y
|
||
const len = Math.hypot(dx, dy)
|
||
if (len < 1e-6) continue
|
||
const ux = dx / len, uy = dy / len
|
||
|
||
// outerEnd 에서 unit 방향으로 ray cast → 가장 먼저 만나는 segment 까지.
|
||
let bestT = Infinity
|
||
let bestSrc = ''
|
||
let bestSegName = ''
|
||
const __isLocalRayLog = process.env.NEXT_PUBLIC_RUN_MODE === 'local'
|
||
for (let k = 0; k < segs.length; k++) {
|
||
const { A, B } = segs[k]
|
||
const t = rayHitSegment(outerEnd, { x: ux, y: uy }, A, B)
|
||
// [BR-RAY] local only — finite t 인 모든 후보 hit 찍기 (어떤 seg 가 가장 빠른지 비교용)
|
||
if (__isLocalRayLog && isFinite(t)) {
|
||
const __label = (k < m) ? `roofLine#${k}` : (segs[k].__name || `aux#${k - m}`)
|
||
// logger.log(`[BR-RAY] hip outer=(${Math.round(outerEnd.x)},${Math.round(outerEnd.y)}) ${__label} t=${t.toFixed(2)} seg=(${Math.round(A.x)},${Math.round(A.y)})→(${Math.round(B.x)},${Math.round(B.y)})`)
|
||
}
|
||
if (t < bestT) {
|
||
bestT = t
|
||
bestSrc = (k < m) ? 'roofLine' : 'aux'
|
||
bestSegName = (k < m) ? 'roofLine' : (segs[k].__name || 'aux')
|
||
}
|
||
}
|
||
if (!isFinite(bestT) || bestT < 0.5) {
|
||
// logger.log(`[v1 ext] hip outer=(${outerEnd.x.toFixed(1)},${outerEnd.y.toFixed(1)}) NO_HIT or 너무 가까움`)
|
||
continue
|
||
}
|
||
|
||
// [2026-04-30] 승자가 eaveHelpLine 이면 ext skip.
|
||
// ext 의 목적은 hip 을 outer roofLine(처마) 까지 연장하는 것.
|
||
// eaveHelpLine 이 ray 경로를 가로막으면 이미 eave/aux 영역에 도달한 것이므로 더 그릴 필요 없음.
|
||
// roofLine 승자만 진짜 "처마 도달" 로 인정 → ext 그림.
|
||
if (bestSegName === 'eaveHelpLine') {
|
||
// logger.log(`[v1 ext] hip outer=(${outerEnd.x.toFixed(1)},${outerEnd.y.toFixed(1)}) 승자=eaveHelpLine bestT=${bestT.toFixed(2)} → skip`)
|
||
continue
|
||
}
|
||
|
||
const hitX = outerEnd.x + ux * bestT
|
||
const hitY = outerEnd.y + uy * bestT
|
||
|
||
// logger.log(
|
||
// `[v1 ext] hip outer=(${outerEnd.x.toFixed(1)},${outerEnd.y.toFixed(1)}) ` +
|
||
// `dir=(${ux.toFixed(3)},${uy.toFixed(3)}) bestT=${bestT.toFixed(2)} src=${bestSrc} ` +
|
||
// `→ ext=(${outerEnd.x.toFixed(1)},${outerEnd.y.toFixed(1)})→(${hitX.toFixed(1)},${hitY.toFixed(1)})`
|
||
// )
|
||
|
||
// ─────────────────────────────────────────────────────────────────────────
|
||
// [hip 좌표 연장 2026-04-30]
|
||
// ext 가 invisible 로 숨겨지면서 baseLine corner ↔ roof corner 시각 갭 발생.
|
||
// sk(hip) 의 outer 끝점을 ext 끝점(hitX/hitY) 까지 연장해서 한 줄의 대각선 라인으로.
|
||
// ext 객체는 그대로 만들어 canvas 에 남김 (invisible 데이터, 디버그/참조용).
|
||
// integrate 의 ext+sk merge 가 sk 좌표를 되돌리지 않게 __extended 플래그 부착.
|
||
// ─────────────────────────────────────────────────────────────────────────
|
||
const oldLen = Math.hypot(hip.x2 - hip.x1, hip.y2 - hip.y1)
|
||
const extLen = bestT // outerEnd → hitPoint 거리
|
||
const ratio = oldLen > 0 ? (oldLen + extLen) / oldLen : 1
|
||
|
||
// outer 가 p1 (d1<d2) 인지 p2 (d2<=d1) 인지에 따라 해당 끝만 갱신.
|
||
if (d1 < d2) {
|
||
hip.set({ x1: hitX, y1: hitY })
|
||
} else {
|
||
hip.set({ x2: hitX, y2: hitY })
|
||
}
|
||
|
||
// attributes (planeSize/actualSize) 0.01 정밀도 유지 (integrateExtensionLines 와 동일 규칙).
|
||
if (!hip.attributes) hip.attributes = {}
|
||
const oldPlane = hip.attributes.planeSize ?? 0
|
||
const oldActual = hip.attributes.actualSize ?? 0
|
||
hip.attributes.planeSize = Math.round(oldPlane * ratio * 100) / 100
|
||
hip.attributes.actualSize = Math.round(oldActual * ratio * 100) / 100
|
||
|
||
// integrateExtensionLines 가 이 hip 의 좌표를 되돌리지 않게 가드.
|
||
// [save/load 보존 2026-05-13] runtime 플래그(__extended) 외에 attributes.extended 도 동기화.
|
||
// attributes 는 SAVE_KEY 에 포함 → JSON 저장/로드 후에도 살아남음.
|
||
// 저장 후 재로드 시 sk.__extended 가 사라져 INTEGRATE merge 가 다시 실행되어
|
||
// 확장된 hip 끝점이 옛 좌표로 되돌아가는 회귀 차단.
|
||
hip.__extended = true
|
||
hip.attributes.extended = true
|
||
|
||
// Fabric bounding/length/text 갱신.
|
||
if (typeof hip.setCoords === 'function') hip.setCoords()
|
||
if (typeof hip.setLength === 'function') hip.setLength()
|
||
if (typeof hip.addLengthText === 'function') hip.addLengthText()
|
||
|
||
// logger.log(
|
||
// `[v1 ext] hip 연장 oldLen=${oldLen.toFixed(1)} +extLen=${extLen.toFixed(1)} ratio=${ratio.toFixed(3)} ` +
|
||
// `(plane ${oldPlane}→${hip.attributes.planeSize}, actual ${oldActual}→${hip.attributes.actualSize})`
|
||
// )
|
||
// ─────────────────────────────────────────────────────────────────────────
|
||
|
||
// [확장선 스타일] local: 빨강 점선(디버그 가시화), dev/prd: innerLine 동일색·실선(연결된 것처럼).
|
||
const __isLocalExt = process.env.NEXT_PUBLIC_RUN_MODE === 'local'
|
||
const __extStroke = __isLocalExt ? '#FF0000' : '#1083E3'
|
||
const __extDash = __isLocalExt ? [6, 4] : null
|
||
const line = new QLine([outerEnd.x, outerEnd.y, hitX, hitY], {
|
||
parentId: roof.id,
|
||
stroke: __extStroke,
|
||
strokeWidth: 2,
|
||
strokeDashArray: __extDash,
|
||
// lineName='extensionLine' → useRoofAllocationSetting.apply() 가 canvas 에서
|
||
// 자동 수집 후 roofBase.lines 에 추가.
|
||
name: BASELINE_TO_ROOFLINE_HELPER_TYPE,
|
||
lineName: 'extensionLine',
|
||
// [2026-04-30] 시각적으로 숨김. canvas 데이터(좌표/lineName/roofId)는 유지하여
|
||
// integrate 픽업 및 디버그 조회(canvas.getObjects().filter)에 영향 없음.
|
||
visible: false,
|
||
roofId: roof.id,
|
||
selectable: false,
|
||
hoverCursor: 'default',
|
||
attributes: {
|
||
type: BASELINE_TO_ROOFLINE_HELPER_TYPE,
|
||
},
|
||
})
|
||
canvas.add(line)
|
||
line.bringToFront()
|
||
}
|
||
canvas.renderAll()
|
||
}
|
||
|
||
export const skeletonBuilder = (roofId, canvas, textMode) => {
|
||
//처마
|
||
let roof = canvas?.getObjects().find((object) => object.id === roofId)
|
||
|
||
// [추가] wall 객체를 찾아 roof.lines에 wallId를 직접 주입 (초기화)
|
||
// 지붕은 벽을 기반으로 생성되므로 라인의 순서(Index)가 동일합니다.
|
||
const wallObj = canvas.getObjects().find((object) => object.name === POLYGON_TYPE.WALL && object.attributes.roofId === roofId)
|
||
|
||
if (roof && wallObj && roof.lines && wallObj.lines) {
|
||
// 개선된 코드 (기하학적 매칭)
|
||
// or use some other unique properties
|
||
|
||
|
||
roof.lines.forEach((rLine, index) => {
|
||
// 벽 라인 중에서 시작점과 끝점이 일치하는 라인 찾기
|
||
const wLine = wallObj.lines[index]
|
||
if (wLine) {
|
||
// 안정적인 ID 생성
|
||
rLine.attributes.wallLine = wLine.id; // Use the stable ID
|
||
|
||
// ...
|
||
}
|
||
})
|
||
}
|
||
|
||
const eavesType = [LINE_TYPE.WALLLINE.EAVES, LINE_TYPE.WALLLINE.HIPANDGABLE]
|
||
const gableType = [LINE_TYPE.WALLLINE.GABLE, LINE_TYPE.WALLLINE.JERKINHEAD]
|
||
|
||
/** 외벽선 */
|
||
const wall = canvas.getObjects().find((object) => object.name === POLYGON_TYPE.WALL && object.attributes.roofId === roofId)
|
||
const baseLines = wall.baseLines.filter((line) => line.attributes.planeSize > 0)
|
||
|
||
// // 디버그: baseLines의 offset/type/좌표 확인
|
||
// baseLines.forEach((bl, i) => {
|
||
// logger.log(`[skeleton] baseLine[${i}] type=${bl.attributes?.type} offset=${bl.attributes?.offset} (${Math.round(bl.x1)},${Math.round(bl.y1)})→(${Math.round(bl.x2)},${Math.round(bl.y2)})`)
|
||
// })
|
||
// roof.points.forEach((p, i) => {
|
||
// logger.log(`[skeleton] roof.points[${i}] (${Math.round(p.x)},${Math.round(p.y)})`)
|
||
// })
|
||
|
||
// roof.points 순서와 맞춰야 offset 매핑이 정확함
|
||
const baseLinePoints = createOrderedBasePoints(roof.points, baseLines)
|
||
// baseLinePoints.forEach((p, i) => {
|
||
// logger.log(`[skeleton] orderedBase[${i}] (${Math.round(p.x)},${Math.round(p.y)})`)
|
||
// })
|
||
|
||
|
||
const outerLines = canvas.getObjects().filter((object) => object.name === 'outerLinePoint') || []
|
||
const outerLinePoints = outerLines.map((line) => ({ x: line.left, y: line.top }))
|
||
|
||
const hipLines = canvas.getObjects().filter((object) => object.name === 'hip' && object.parentId === roofId) || []
|
||
const ridgeLines = canvas.getObjects().filter((object) => object.name === 'ridge' && object.parentId === roofId) || []
|
||
|
||
// 1. 지붕 폴리곤 유효성 검사
|
||
const coordinates = preprocessPolygonCoordinates(roof.points)
|
||
if (coordinates.length < 3) {
|
||
logger.warn('Polygon has less than 3 unique points. Cannot generate skeleton.')
|
||
return
|
||
}
|
||
|
||
const moveFlowLine = roof.moveFlowLine || 0
|
||
const moveUpDown = roof.moveUpDown || 0
|
||
|
||
// 디버그: offset 변경 + moveLine 상태 확인
|
||
// logger.log('[DEBUG] moveFlowLine:', moveFlowLine, 'moveUpDown:', moveUpDown)
|
||
// logger.log('[DEBUG] wall.lines:', wall.lines.map((l, i) => `[${i}] (${Math.round(l.x1)},${Math.round(l.y1)})→(${Math.round(l.x2)},${Math.round(l.y2)})`))
|
||
// logger.log('[DEBUG] wall.baseLines:', wall.baseLines.map((l, i) => `[${i}] (${Math.round(l.x1)},${Math.round(l.y1)})→(${Math.round(l.x2)},${Math.round(l.y2)})`))
|
||
// logger.log('[DEBUG] roof.points:', roof.points.map((p, i) => `[${i}] (${Math.round(p.x)},${Math.round(p.y)})`))
|
||
|
||
// 닫힌 폴리곤(첫점 = 끝점)인 경우 마지막 중복 점 제거
|
||
const isClosedPolygon = (points) =>
|
||
points.length > 1 && isSamePoint(points[0], points[points.length - 1])
|
||
|
||
// roofLinePoints : drawRoofPolygon에서 baseLine + 각 변의 offset으로 계산된 실제 처마 외곽선
|
||
// orderedBaseLinePoints: 외벽선(baseLine) 꼭짓점을 연결 순서대로 정렬
|
||
const roofLinePoints = isClosedPolygon(roof.points) ? roof.points.slice(0, -1) : roof.points
|
||
const orderedBaseLinePoints = isClosedPolygon(baseLinePoints) ? baseLinePoints.slice(0, -1) : baseLinePoints
|
||
|
||
// 2. 각 baseLine 꼭짓점 → 대응하는 roofLine 꼭짓점 방향벡터 계산
|
||
// 각 변의 offset이 달라도 방향은 항상 baseLine → roofLine (바깥쪽)
|
||
const contactData = orderedBaseLinePoints.map((point, index) => {
|
||
const roofPoint = roofLinePoints[index]
|
||
if (!roofPoint) return { dx: 0, dy: 0, signDx: 0, signDy: 0 }
|
||
|
||
const dx = roofPoint.x - point.x
|
||
const dy = roofPoint.y - point.y
|
||
return { dx, dy, signDx: Math.sign(dx), signDy: Math.sign(dy) }
|
||
})
|
||
|
||
// 3. maxStep: 모든 꼭짓점 중 가장 큰 45도 대각 step 값
|
||
// - 각 꼭짓점의 min(|dx|, |dy|) = 해당 꼭짓점에서 45도 방향으로 확장 가능한 거리
|
||
// - 전체에서 max를 취해 SkeletonBuilder 입력 다각형이 충분히 확장되도록 보장
|
||
// - 모든 꼭짓점에 동일한 maxStep을 적용하므로 각 변의 offset이 달라도
|
||
// SkeletonBuilder 입력은 항상 대칭 다각형이 되어 스켈레톤이 안정적으로 생성됨
|
||
const maxStep = contactData.reduce((max, data) => {
|
||
return Math.max(max, Math.min(Math.abs(data.dx), Math.abs(data.dy)))
|
||
}, 0)
|
||
|
||
// baseLine 폴리곤 중심점 (dx 또는 dy가 0인 꼭짓점의 확장 방향 보정에 사용)
|
||
const centroid = orderedBaseLinePoints.reduce((acc, p) => {
|
||
acc.x += p.x / orderedBaseLinePoints.length
|
||
acc.y += p.y / orderedBaseLinePoints.length
|
||
return acc
|
||
}, { x: 0, y: 0 })
|
||
|
||
// 4. 모든 꼭짓점을 동일한 maxStep으로 45도 대각 방향 확장
|
||
// - dx 또는 dy가 0인 경우(한 축 이동만 있는 꼭짓점): centroid 기준 바깥 방향으로 보정
|
||
let roofLineContactPoints = orderedBaseLinePoints.map((point, index) => {
|
||
const data = contactData[index]
|
||
|
||
let signDx = data.signDx
|
||
let signDy = data.signDy
|
||
if (signDx === 0) signDx = point.x >= centroid.x ? 1 : -1
|
||
if (signDy === 0) signDy = point.y >= centroid.y ? 1 : -1
|
||
|
||
return {
|
||
x: point.x + signDx * maxStep,
|
||
y: point.y + signDy * maxStep
|
||
}
|
||
})
|
||
|
||
// 5. changRoofLinePoints: SkeletonBuilder에 입력할 최종 다각형
|
||
// - roofLineContactPoints 방향으로 maxContactDistance(= √2 × maxStep) 만큼 확장
|
||
// - 45도 대각 방향(signDx, signDy 각 ±1)이므로 실제 x·y 이동량은 각 축으로 maxStep
|
||
const maxContactDistance = Math.hypot(maxStep, maxStep)
|
||
|
||
// logger.log("orderedBaseLinePoints",orderedBaseLinePoints)
|
||
// logger.log("contactData",contactData)
|
||
// logger.log("roofLineContactPoints",roofLineContactPoints)
|
||
// logger.log("maxContactDistance",maxContactDistance)
|
||
|
||
let changRoofLinePoints = orderedBaseLinePoints.map((point, index) => {
|
||
const contactPoint = roofLineContactPoints[index]
|
||
if (!contactPoint) return point
|
||
|
||
const dx = contactPoint.x - point.x
|
||
const dy = contactPoint.y - point.y
|
||
const len = Math.hypot(dx, dy)
|
||
if (len === 0 || maxContactDistance === 0) return point
|
||
|
||
return {
|
||
x: point.x + (dx / len) * maxContactDistance,
|
||
y: point.y + (dy / len) * maxContactDistance
|
||
}
|
||
})
|
||
|
||
// logger.log("changRoofLinePoints1:::", changRoofLinePoints)
|
||
// 6. 마루이동(용마루 위치 수동 조정)이 설정된 경우 movingLineFromSkeleton 결과로 대체
|
||
|
||
if (moveFlowLine !== 0 || moveUpDown !== 0) {
|
||
// [정책] 오버 이동(verifyMoveBoundary='crossed') 처리:
|
||
// - 과거: 'crossed' 시 재빌드 스킵하여 기존 SK 유지 (silent skip).
|
||
// - 현재: 사용자가 재작도 회피용 단축 수단으로 오버를 의도 사용 → 오버된 wall.baseLines 좌표 기반 재빌드 수행.
|
||
// - 경고 로그만 남기고 기존 흐름 그대로 진행.
|
||
try {
|
||
const __moveVerdict = verifyMoveBoundary(roofId, canvas)
|
||
if (__moveVerdict === 'crossed') {
|
||
logger.warn('[skeleton] 경계 넘음(crossed) → 오버된 wall.baseLine 좌표 기반 재빌드 진행')
|
||
}
|
||
} catch (e) {
|
||
logger.warn('[skeleton] verifyMoveBoundary 예외 → 기존 흐름 진행:', e)
|
||
}
|
||
|
||
const movedPoints = safeMovedPointsWithFallback(roofId, canvas)
|
||
// logger.log("movedPoints:::", movedPoints);
|
||
|
||
// movedPoints를 roof 경계로 확장
|
||
// 이번 이동에서 실제로 변경된 포인트만 구분하여, 변경되지 않은 축만 roof 경계로 확장
|
||
/*
|
||
* [DEAD CODE — 비활성 보존]
|
||
* 의도: "이번 이동에서 바뀌지 않은 축은 roof 경계로 교정" 하려던 블록.
|
||
* 비활성화 이유: correctedPoints 결과가 아래 대입(roofLineContactPoints/changRoofLinePoints)에서
|
||
* movedPoints로 대체되어 실사용되지 않음. 또한 누적 이동 상태에서 "이번에 안 움직인 y축"을
|
||
* roof 경계로 되돌려 이전 이동(예: 1차 top)의 y값을 지우는 부작용 위험이 있어 이전 작업자가
|
||
* 대입을 주석 처리한 것으로 보임. 구조/의도 흔적만 남겨 두고 실행은 하지 않는다.
|
||
*
|
||
* const oldPoints = canvas?.skeleton?.lastPoints ?? roofLinePoints
|
||
* const tolerance = 0.1
|
||
* const correctedPoints = movedPoints.map((mp, i) => {
|
||
* const rp = roofLinePoints[i]
|
||
* const op = oldPoints[i]
|
||
* if (!rp || !op) return mp
|
||
* const xMatch = Math.abs(mp.x - rp.x) < tolerance
|
||
* const yMatch = Math.abs(mp.y - rp.y) < tolerance
|
||
* if (xMatch && yMatch) return mp
|
||
* const xMoved = Math.abs(mp.x - op.x) >= tolerance
|
||
* const yMoved = Math.abs(mp.y - op.y) >= tolerance
|
||
* let newX = mp.x
|
||
* let newY = mp.y
|
||
* if (!xMoved && !xMatch) newX = rp.x
|
||
* if (!yMoved && !yMatch) newY = rp.y
|
||
* return { x: newX, y: newY }
|
||
* })
|
||
* roofLineContactPoints = correctedPoints
|
||
* changRoofLinePoints = correctedPoints
|
||
*/
|
||
|
||
roofLineContactPoints = movedPoints
|
||
changRoofLinePoints = movedPoints
|
||
}
|
||
|
||
// [v1 architecture 2026-04-29] SK 입력 강제 = wall.baseLines 직접.
|
||
// 위에서 계산된 changRoofLinePoints (45° 확장 또는 movedPoints) 를 모두 무시하고
|
||
// orderedBaseLinePoints (= wall.baseLines corner) 그대로 사용.
|
||
// wall.baseLines 는 마루이동/벽이동/offset 의 모든 누적 변경 반영 → SK 자동 동기화.
|
||
// 확장선은 drawBaselineToRooflineHelpers (45° to first roofLine) 가 별도 그림.
|
||
if (SK_INPUT_USE_WALL_BASELINE_DIRECT) {
|
||
changRoofLinePoints = orderedBaseLinePoints.map((p) => ({ x: p.x, y: p.y }))
|
||
roofLineContactPoints = orderedBaseLinePoints.map((p) => ({ x: p.x, y: p.y }))
|
||
// logger.log('[v1 SK_INPUT] wall.baseLines 직접 사용 (45° 확장/movedPoints 무시):',
|
||
// changRoofLinePoints.map((p, i) => `[${i}](${Math.round(p.x)},${Math.round(p.y)})`).join(' '))
|
||
|
||
// [v1 평행오버 corner 흡수 2026-04-29]
|
||
// 평행오버 시 OVER_GUARD 가 인접 baseLine 길이를 OVER_EPS(0.5) 만큼만 남김.
|
||
// 그 corner 는 사실상 평행 — 모서리가 없어진 상태이므로 SK 빌더 입력에서 제외해야
|
||
// 헛 hip / 헛 ext 가 만들어지지 않는다.
|
||
// 조건: 인접점 거리 < DUP_EPS 또는 (prev,curr,next) cross < COLLINEAR_EPS
|
||
// 여러 점 동시 흡수 가능 → 다중 패스.
|
||
const DUP_EPS = 1.0
|
||
const COLLINEAR_EPS = 50.0 // |cross| 단위는 면적의 2배. baseLine 한쪽이 1px 미만이면 cross 도 매우 작음.
|
||
let kept = changRoofLinePoints.slice()
|
||
const absorbedAll = []
|
||
for (let pass = 0; pass < 5 && kept.length > 3; pass++) {
|
||
const next = []
|
||
const absorbedPass = []
|
||
for (let i = 0; i < kept.length; i++) {
|
||
const prev = kept[(i - 1 + kept.length) % kept.length]
|
||
const curr = kept[i]
|
||
const nxt = kept[(i + 1) % kept.length]
|
||
const dPrev = Math.hypot(curr.x - prev.x, curr.y - prev.y)
|
||
const dNext = Math.hypot(nxt.x - curr.x, nxt.y - curr.y)
|
||
const cross = (curr.x - prev.x) * (nxt.y - prev.y) - (curr.y - prev.y) * (nxt.x - prev.x)
|
||
if (dPrev < DUP_EPS || dNext < DUP_EPS || Math.abs(cross) < COLLINEAR_EPS) {
|
||
absorbedPass.push(`[${i}](${Math.round(curr.x)},${Math.round(curr.y)}) dPrev=${dPrev.toFixed(2)} dNext=${dNext.toFixed(2)} cross=${cross.toFixed(2)}`)
|
||
continue
|
||
}
|
||
next.push(curr)
|
||
}
|
||
if (absorbedPass.length === 0) break
|
||
absorbedAll.push(...absorbedPass)
|
||
kept = next
|
||
}
|
||
if (absorbedAll.length > 0 && kept.length >= 3) {
|
||
// logger.log(`[v1 SK_INPUT 평행흡수] ${absorbedAll.length}개 corner 제거:\n ${absorbedAll.join('\n ')}`)
|
||
// logger.log(`[v1 SK_INPUT 평행흡수] 최종 ${kept.length}개 corner:`,
|
||
// kept.map((p, i) => `[${i}](${Math.round(p.x)},${Math.round(p.y)})`).join(' '))
|
||
changRoofLinePoints = kept
|
||
roofLineContactPoints = kept.map((p) => ({ x: p.x, y: p.y }))
|
||
}
|
||
}
|
||
|
||
// logger.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) {
|
||
logger.error('[SK_ERROR] 유효하지 않은 좌표 발견:', invalidPoints)
|
||
logger.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) {
|
||
logger.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) {
|
||
logger.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
|
||
// logger.log('[SK_OVER_DEBUG] origPoints:', origPoints.map((p, i) => `[${i}](${Math.round(p.x)},${Math.round(p.y)})`))
|
||
// logger.log('[SK_OVER_DEBUG] changRoofLinePoints:', changRoofLinePoints.map((p, i) => `[${i}](${Math.round(p.x)},${Math.round(p.y)})`))
|
||
// logger.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
|
||
logger.error(`[SK_OVER] 꼭짓점[${i}] 방향 뒤집힘! cross: ${crossOrig.toFixed(1)} → ${crossNew.toFixed(1)}`)
|
||
logger.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)})`)
|
||
logger.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 }))
|
||
|
||
// ──────────────────────────────────────────────────────────────────
|
||
// [분기] 스켈레톤 입력 포인트 결정
|
||
// 정상: changRoofLinePoints 그대로 사용
|
||
// 오버(일반 경로로 들어온 에지 케이스): calcOverCorrectedPoints() 로 보정 포인트 생성
|
||
// ※ verifyMoveBoundary === 'crossed' 케이스는 drawHelpLine 에서 drawSkeletonRidgeRoofFromBaseLines
|
||
// 로 사전 분기하므로, 여기 isOverDetected 분기는 일반 경로의 안전망 용도로만 작동.
|
||
// ※ changRoofLinePoints 자체는 절대 수정하지 않음. 실패 시 그대로 fallback.
|
||
// ──────────────────────────────────────────────────────────────────
|
||
let skeletonInputPoints = changRoofLinePoints
|
||
|
||
if (isOverDetected) {
|
||
try {
|
||
const corrected = calcOverCorrectedPointsSafe(
|
||
changRoofLinePoints,
|
||
origPoints,
|
||
canvas?.skeleton?.lastPoints ?? null
|
||
)
|
||
if (corrected && corrected.length >= 3) {
|
||
skeletonInputPoints = corrected
|
||
// logger.log('[SK_OVER] 보정 포인트 적용:', skeletonInputPoints.map((p, i) => `[${i}](${Math.round(p.x)},${Math.round(p.y)})`))
|
||
}
|
||
} catch (e) {
|
||
logger.error('[SK_OVER] 보정 실패 → fallback:', e)
|
||
}
|
||
}
|
||
|
||
const perturbedPoints = perturbPolygonPoints(skeletonInputPoints)
|
||
const geoJSONPolygon = toGeoJSON(perturbedPoints)
|
||
|
||
try {
|
||
// SkeletonBuilder는 닫히지 않은 폴리곤을 기대하므로 마지막 점 제거
|
||
geoJSONPolygon.pop()
|
||
// logger.log('[SkeletonBuilder] geoJSONPolygon:', JSON.stringify(geoJSONPolygon, null, 2))
|
||
// logger.log('[SkeletonBuilder] 꼭짓점 수:', geoJSONPolygon.length)
|
||
const skeleton = SkeletonBuilder.BuildFromGeoJSON([[geoJSONPolygon]])
|
||
|
||
// 스켈레톤 데이터를 기반으로 내부선 생성
|
||
roof.innerLines = roof.innerLines || []
|
||
roof.innerLines = createInnerLinesFromSkeleton(roofId, canvas, skeleton, textMode, isOverDetected, changRoofLinePoints)
|
||
//logger.log("roofInnerLines:::", roof.innerLines);
|
||
|
||
// [v1 helper 2026-04-29 B 경로] SK 입력 (offset 확장 baseLine 또는 movedPoints) 과 roofLinePoints 간
|
||
// gap 을 시각화. innerLines 와 별개 collection. 기존 working code (processInBoth 등) 무영향.
|
||
drawBaselineToRooflineHelpers(roof, canvas, changRoofLinePoints, roofLinePoints)
|
||
|
||
// 캔버스에 스켈레톤 상태 저장
|
||
if (!canvas.skeletonStates) {
|
||
canvas.skeletonStates = {}
|
||
canvas.skeletonLines = []
|
||
}
|
||
canvas.skeletonStates[roofId] = true
|
||
canvas.skeletonLines = []
|
||
canvas.skeletonLines.push(...roof.innerLines)
|
||
roof.skeletonLines = canvas.skeletonLines
|
||
|
||
const cleanSkeleton = {
|
||
Edges: skeleton.Edges.map((edge) => ({
|
||
X1: edge.Edge.Begin.X,
|
||
Y1: edge.Edge.Begin.Y,
|
||
X2: edge.Edge.End.X,
|
||
Y2: edge.Edge.End.Y,
|
||
Polygon: edge.Polygon,
|
||
|
||
// Add other necessary properties, but skip circular references
|
||
})),
|
||
roofId: roofId,
|
||
// Add other necessary top-level properties
|
||
}
|
||
// [누적상태 보존]
|
||
// canvas.skeleton = cleanSkeleton 으로 교체하면 기존 lastPoints 가 사라져,
|
||
// 직후 호출되는 buildRawMovedPoints 의 prevLast 가 undefined 로 떨어진다.
|
||
// 그 결과 save 경로가 매번 "orig + 이번 세션 delta" 만 기록하여
|
||
// 이전 세션들의 누적 이동(y-shift 등)이 drop → 3~4차 이동에서 SK 붕괴.
|
||
// 교체 전에 lastPoints 를 캡처해 새 skeleton 에 이어붙여 누적을 유지한다.
|
||
const __preservedLastPoints = canvas.skeleton?.lastPoints ?? null
|
||
canvas.skeleton = []
|
||
canvas.skeleton = cleanSkeleton
|
||
if (__preservedLastPoints) canvas.skeleton.lastPoints = __preservedLastPoints
|
||
// lastPoints 는 축소되지 않은 풀 길이(roof.points.length) 버전으로 저장해야
|
||
// 다음 이동 시 인덱스 기반 매칭이 유지됨. raw 재구성 실패 시에만 기존 값 사용.
|
||
const rawMovedFull = buildRawMovedPoints(roofId, canvas)
|
||
if (rawMovedFull && rawMovedFull.length === (roof.points?.length ?? 0)) {
|
||
canvas.skeleton.lastPoints = rawMovedFull
|
||
// [LP-TRACE] 실제 저장되는 lastPoints[7] — 이 값이 다음 이동의 prevLast[7]
|
||
try {
|
||
const _p7 = rawMovedFull[7]
|
||
// logger.log(`[LP-TRACE] lastPoints.save [7]=(${_p7?.x?.toFixed(1)},${_p7?.y?.toFixed(1)}) len=${rawMovedFull.length}`)
|
||
} catch (_e) {}
|
||
// logger.log('[skeleton] lastPoints 저장: raw 풀 길이', rawMovedFull.length, '점')
|
||
} else {
|
||
canvas.skeleton.lastPoints = roofLineContactPoints
|
||
}
|
||
canvas.set('skeleton', cleanSkeleton)
|
||
canvas.renderAll()
|
||
|
||
//logger.log('skeleton rendered.', canvas)
|
||
} catch (e) {
|
||
logger.error('[SK_ERROR] 스켈레톤 생성 중 오류 발생:', e)
|
||
logger.error('[SK_ERROR] 입력 좌표:', changRoofLinePoints.map((p, i) => `[${i}] (${p.x.toFixed(2)},${p.y.toFixed(2)})`))
|
||
logger.error('[SK_ERROR] geoJSON:', JSON.stringify(geoJSONPolygon))
|
||
// [보호] 예외 시 상태 플래그만 복구하고, 기존 SK/innerLines는 유지한다.
|
||
// → 사용자가 힘들게 추가한 라인들이 순간적으로 사라지는 현상 방지.
|
||
if (canvas.skeletonStates) {
|
||
canvas.skeletonStates[roofId] = false
|
||
}
|
||
// 주의: canvas.skeletonLines 는 의도적으로 비우지 않음 (이전 성공 상태 보존)
|
||
}
|
||
}
|
||
|
||
/**
|
||
* 오버 이동(verifyMoveBoundary='crossed') 전용 경로.
|
||
*
|
||
* 일반 경로(drawSkeletonRidgeRoof) 는 wall.baseLines → 45도 대각 확장 → roof 경계까지 늘린 polygon 을
|
||
* SkeletonBuilder 에 입력한다. 오버 이동 상태에선 이 확장/클램핑이 폴리곤 자가교차나 roof 경계로의
|
||
* 되돌림을 야기해 엉뚱한 SK 를 만든다.
|
||
*
|
||
* 본 함수는 **wall.baseLines 의 끝점을 확장/offset 없이 그대로** polygon 으로 넘겨 SK 를 빌드한다.
|
||
* - roof.points / roof.lines / outerLine / lengthText 는 건드리지 않음.
|
||
* - SHOULDER_ABSORBED (planeSize < 1) baseLines 는 스킵.
|
||
* - innerLines 는 기존 createInnerLinesFromSkeleton 으로 생성 (isOverDetected=true 마킹).
|
||
* - canvas.skeleton.lastPoints 는 기존 값을 보존 (오버 상태에서 새로 계산하지 않음).
|
||
*/
|
||
export const drawSkeletonRidgeRoofFromBaseLines = (roofId, canvas, textMode) => {
|
||
const roof = canvas?.getObjects().find((o) => o.id === roofId)
|
||
if (!roof) {
|
||
logger.warn('[SK_OVER_FN] roof 없음 → 중단')
|
||
return
|
||
}
|
||
const wall = canvas.getObjects().find((obj) => obj.name === POLYGON_TYPE.WALL && obj.attributes?.roofId === roofId)
|
||
if (!wall || !wall.baseLines?.length) {
|
||
logger.warn('[SK_OVER_FN] wall/baseLines 없음 → 중단')
|
||
return
|
||
}
|
||
|
||
// wall.baseLines 순차 끝점(x1,y1) 수집. zero-length(SHOULDER_ABSORBED) 항목 스킵.
|
||
const rawPoints = []
|
||
for (let i = 0; i < wall.baseLines.length; i++) {
|
||
const bl = wall.baseLines[i]
|
||
const planeSize = bl?.attributes?.planeSize ?? Infinity
|
||
if (planeSize < 1) {
|
||
// logger.log(`[SK_OVER_FN] baseLines[${i}] SHOULDER_ABSORBED skip (planeSize=${planeSize})`)
|
||
continue
|
||
}
|
||
if (bl == null || !isFinite(bl.x1) || !isFinite(bl.y1)) {
|
||
logger.warn(`[SK_OVER_FN] baseLines[${i}] invalid coord → skip`)
|
||
continue
|
||
}
|
||
rawPoints.push({ x: bl.x1, y: bl.y1 })
|
||
}
|
||
|
||
if (rawPoints.length < 3) {
|
||
logger.warn(`[SK_OVER_FN] 유효 baseLines 끝점 ${rawPoints.length} < 3 → 중단`)
|
||
return
|
||
}
|
||
|
||
// logger.log('[SK_OVER_FN] wall.baseLines polygon:', rawPoints.map((p, i) => `[${i}](${Math.round(p.x)},${Math.round(p.y)})`))
|
||
|
||
// skeletonPoints 마킹 (오버 좌표 그대로)
|
||
roof.skeletonPoints = rawPoints.map((p) => ({ x: p.x, y: p.y }))
|
||
|
||
const perturbed = perturbPolygonPoints(rawPoints)
|
||
const geoJSONPolygon = toGeoJSON(perturbed)
|
||
|
||
try {
|
||
geoJSONPolygon.pop()
|
||
// logger.log('[SK_OVER_FN] geoJSONPolygon:', JSON.stringify(geoJSONPolygon, null, 2))
|
||
// logger.log('[SK_OVER_FN] 꼭짓점 수:', geoJSONPolygon.length)
|
||
const skeleton = SkeletonBuilder.BuildFromGeoJSON([[geoJSONPolygon]])
|
||
|
||
roof.innerLines = roof.innerLines || []
|
||
roof.innerLines = createInnerLinesFromSkeleton(roofId, canvas, skeleton, textMode, /* isOverDetected */ true, rawPoints)
|
||
|
||
if (!canvas.skeletonStates) {
|
||
canvas.skeletonStates = {}
|
||
canvas.skeletonLines = []
|
||
}
|
||
canvas.skeletonStates[roofId] = true
|
||
canvas.skeletonLines = []
|
||
canvas.skeletonLines.push(...roof.innerLines)
|
||
roof.skeletonLines = canvas.skeletonLines
|
||
|
||
const cleanSkeleton = {
|
||
Edges: skeleton.Edges.map((edge) => ({
|
||
X1: edge.Edge.Begin.X,
|
||
Y1: edge.Edge.Begin.Y,
|
||
X2: edge.Edge.End.X,
|
||
Y2: edge.Edge.End.Y,
|
||
Polygon: edge.Polygon,
|
||
})),
|
||
roofId: roofId,
|
||
}
|
||
// lastPoints 는 기존 값 유지 (오버 상태에서 새 누적 기준점을 만들지 않음)
|
||
const __preservedLastPoints = canvas.skeleton?.lastPoints ?? null
|
||
canvas.skeleton = cleanSkeleton
|
||
if (__preservedLastPoints) canvas.skeleton.lastPoints = __preservedLastPoints
|
||
|
||
canvas.set('skeleton', cleanSkeleton)
|
||
canvas.renderAll()
|
||
} catch (e) {
|
||
logger.error('[SK_OVER_FN] 스켈레톤 생성 오류:', e)
|
||
logger.error('[SK_OVER_FN] 입력 좌표:', rawPoints.map((p, i) => `[${i}] (${p.x.toFixed(2)},${p.y.toFixed(2)})`))
|
||
if (canvas.skeletonStates) canvas.skeletonStates[roofId] = false
|
||
}
|
||
}
|
||
|
||
/**
|
||
* 스켈레톤 결과와 외벽선 정보를 바탕으로 내부선(용마루, 추녀)을 생성합니다.
|
||
* @param {object} skeleton - SkeletonBuilder로부터 반환된 스켈레톤 객체
|
||
|
||
* @param {fabric.Object} roof - 대상 지붕 객체
|
||
* @param {fabric.Canvas} canvas - Fabric.js 캔버스 객체
|
||
* @param {string} textMode - 텍스트 표시 모드 ('plane', 'actual', 'none')
|
||
* @param {Array<QLine>} baseLines - 원본 외벽선 QLine 객체 배열
|
||
*/
|
||
const createInnerLinesFromSkeleton = (roofId, canvas, skeleton, textMode, isOverDetected = false, skPolygonPoints = []) => {
|
||
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)
|
||
let skeletonLines = []
|
||
let findPoints = [];
|
||
|
||
const processedInnerEdges = new Set()
|
||
|
||
const textElements = {};
|
||
|
||
const coordinateText = (line) => {
|
||
// Generate a stable ID for this line
|
||
const lineKey = `${line.x1},${line.y1},${line.x2},${line.y2}`;
|
||
|
||
// Remove existing text elements for this line
|
||
if (textElements[lineKey]) {
|
||
textElements[lineKey].forEach(text => {
|
||
if (canvas.getObjects().includes(text)) {
|
||
canvas.remove(text);
|
||
}
|
||
});
|
||
}
|
||
|
||
// Create start point text
|
||
const startText = new fabric.Text(`(${Math.round(line.x1)}, ${Math.round(line.y1)})`, {
|
||
left: line.x1 + 5,
|
||
top: line.y1 - 20,
|
||
fontSize: 10,
|
||
fill: 'magenta',
|
||
fontFamily: 'Arial',
|
||
selectable: false,
|
||
hasControls: false,
|
||
hasBorders: false
|
||
});
|
||
|
||
// Create end point text
|
||
const endText = new fabric.Text(`(${Math.round(line.x2)}, ${Math.round(line.y2)})`, {
|
||
left: line.x2 + 5,
|
||
top: line.y2 - 20,
|
||
fontSize: 10,
|
||
fill: 'orange',
|
||
fontFamily: 'Arial',
|
||
selectable: false,
|
||
hasControls: false,
|
||
hasBorders: false
|
||
});
|
||
|
||
// Add to canvas
|
||
canvas.add(startText, endText);
|
||
|
||
// Store references
|
||
textElements[lineKey] = [startText, endText];
|
||
|
||
// Bring lines to front
|
||
canvas.bringToFront(startText);
|
||
canvas.bringToFront(endText);
|
||
};
|
||
// 1. 모든 Edge를 순회하며 기본 스켈레톤 선(용마루)을 수집합니다.
|
||
|
||
skeleton.Edges.forEach((edgeResult, index) => {
|
||
|
||
processEavesEdge(roofId, canvas, skeleton, edgeResult, skeletonLines);
|
||
});
|
||
|
||
// 1-1. dead end(막다른 점) 라인 분석 (로그만, 삭제 안함)
|
||
logDeadEndLines(skeletonLines, roof);
|
||
|
||
// 2. 케라바(Gable) 속성을 가진 외벽선에 해당하는 스켈레톤을 후처리합니다.
|
||
skeleton.Edges.forEach(edgeResult => {
|
||
|
||
const { Begin, End } = edgeResult.Edge;
|
||
const gableBaseLine = roof.lines.find(line =>
|
||
line.attributes.type === 'gable' && isSameLine(Begin.X, Begin.Y, End.X, End.Y, line)
|
||
);
|
||
|
||
if (gableBaseLine) {
|
||
// Store current state before processing - avoid circular refs by only picking needed data
|
||
const beforeGableProcessing = skeletonLines.map(line => ({
|
||
p1: { x: line.p1.x, y: line.p1.y },
|
||
p2: { x: line.p2.x, y: line.p2.y },
|
||
attributes: { ...line.attributes },
|
||
lineStyle: { ...line.lineStyle }
|
||
}));
|
||
|
||
// if(canvas.skeletonLines.length > 0){
|
||
// skeletonLines = canvas.skeletonLines;
|
||
// }
|
||
|
||
// Process gable edge with both current and previous states
|
||
const processedLines = processGableEdge(
|
||
edgeResult,
|
||
baseLines,
|
||
[...skeletonLines], // Current state
|
||
gableBaseLine,
|
||
beforeGableProcessing // Previous state
|
||
);
|
||
|
||
// Update canvas with processed lines
|
||
canvas.skeletonLines = processedLines;
|
||
skeletonLines = processedLines;
|
||
|
||
}
|
||
|
||
});
|
||
|
||
|
||
//2. 연결이 끊어진 라인이 있을경우 찾아서 추가한다(동 이동일때)
|
||
|
||
// 3. 최종적으로 정리된 스켈레톤 선들을 QLine 객체로 변환하여 캔버스에 추가합니다.
|
||
const innerLines = [];
|
||
const addLines = []
|
||
const existingLines = new Set(); // 이미 추가된 라인을 추적하기 위한 Set
|
||
|
||
//처마라인
|
||
const roofLines = roof.lines
|
||
//벽라인 — wall.baseLines 의 회전 기준에 맞춰 wall.lines 정렬 보정 (createOrderedBasePoints 사상)
|
||
let wallLines = wall.lines
|
||
if (wall.baseLines && wall.baseLines.length > 0 && wallLines && wallLines.length === wall.baseLines.length) {
|
||
const refStart = wall.baseLines[0].startPoint
|
||
if (refStart) {
|
||
let rotIdx = wallLines.findIndex((wl) => wl.startPoint && isSamePoint(wl.startPoint, refStart, 1.0))
|
||
if (rotIdx > 0) {
|
||
wallLines = [...wallLines.slice(rotIdx), ...wallLines.slice(0, rotIdx)]
|
||
if (process.env.NEXT_PUBLIC_RUN_MODE === 'local') {
|
||
// logger.log('[WL-ROT-FIX] wall.lines rotated by', -rotIdx, 'to align with wall.baseLines[0].startPoint')
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
skeletonLines.forEach((sktLine, skIndex) => {
|
||
let { p1, p2, attributes, lineStyle } = sktLine;
|
||
|
||
// [dedup fix 2026-04-30] 중복방지 키 — drift 흡수 + endpoint 순서 무관.
|
||
// 기존: [p1.x,p1.y].sort() — 한 점의 x/y 를 정렬하던 의도불명 코드 +
|
||
// exact string 매치 → SK 빌더 face A/B 가 같은 inner edge 를 1e-7
|
||
// drift 로 emit 하면 dedup 실패 → 가짜 RG-N (dead-end) 생성.
|
||
// 수정: 1696라인 _keyOfEdge 와 동일 패턴 — 0.1 단위 round 후 점 쌍 정렬.
|
||
const _ptKey = (p) => `${Math.round(p.x * 10) / 10},${Math.round(p.y * 10) / 10}`
|
||
const _a = _ptKey(p1)
|
||
const _b = _ptKey(p2)
|
||
const lineKey = _a < _b ? `${_a}|${_b}` : `${_b}|${_a}`
|
||
|
||
// 이미 추가된 라인인지 확인
|
||
if (existingLines.has(lineKey)) {
|
||
return; // 이미 있는 라인이면 스킵
|
||
}
|
||
|
||
const direction = getLineDirection(
|
||
{ x: sktLine.p1.x, y: sktLine.p1.y },
|
||
{ x: sktLine.p2.x, y: sktLine.p2.y }
|
||
);
|
||
|
||
|
||
|
||
const skeletonLine = new QLine([p1.x, p1.y, p2.x, p2.y], {
|
||
parentId: roof.id,
|
||
fontSize: roof.fontSize,
|
||
stroke: (sktLine.attributes.isOuterEdge)?'orange':lineStyle.color,
|
||
strokeWidth: lineStyle.width,
|
||
name: (sktLine.attributes.isOuterEdge)?'eaves': attributes.type,
|
||
attributes: {
|
||
...attributes,
|
||
|
||
},
|
||
direction: direction,
|
||
isBaseLine: sktLine.attributes.isOuterEdge,
|
||
lineName: (sktLine.attributes.isOuterEdge)?'roofLine': attributes.type,
|
||
selectable:(!sktLine.attributes.isOuterEdge),
|
||
//visible: (!sktLine.attributes.isOuterEdge),
|
||
});
|
||
|
||
//coordinateText(skeletonLine)
|
||
canvas.add(skeletonLine);
|
||
skeletonLine.bringToFront();
|
||
existingLines.add(lineKey); // 추가된 라인을 추적
|
||
|
||
|
||
|
||
//skeleton 라인에서 처마선은 삭제
|
||
if(skeletonLine.lineName === 'roofLine'){
|
||
|
||
skeletonLine.set('visible', false); //임시
|
||
roof.set({
|
||
//stroke: 'black',
|
||
strokeWidth: 4
|
||
});
|
||
|
||
|
||
}else{
|
||
|
||
|
||
}
|
||
|
||
innerLines.push(skeletonLine)
|
||
canvas.renderAll();
|
||
});
|
||
|
||
if (Math.abs(roof.moveUpDown ?? 0) > 0 || Math.abs(roof.moveFlowLine ?? 0) > 0) {
|
||
const getMoveUpDownLine = () => {
|
||
// 같은 라인이 없으므로 새 다각형 라인 생성
|
||
//라인 편집
|
||
// let i = 0
|
||
const currentRoofLines = canvas.getObjects().filter((obj) => obj.lineName === 'roofLine' && obj.attributes.roofId === roofId)
|
||
let roofLineRects = canvas.getObjects().filter((obj) => obj.name === 'roofLineRect' && obj.roofId === roofId)
|
||
|
||
roofLineRects.forEach((roofLineRect) => {
|
||
canvas.remove(roofLineRect)
|
||
canvas.renderAll()
|
||
})
|
||
|
||
let helpLines = canvas.getObjects().filter((obj) => obj.lineName === 'helpLine' && obj.roofId === roofId)
|
||
helpLines.forEach((helpLine) => {
|
||
canvas.remove(helpLine)
|
||
canvas.renderAll()
|
||
})
|
||
|
||
function sortCurrentRoofLines(lines) {
|
||
return [...lines].sort((a, b) => {
|
||
// Get all coordinates in a consistent order
|
||
const getCoords = (line) => {
|
||
const x1 = line.x1 ?? line.get('x1')
|
||
const y1 = line.y1 ?? line.get('y1')
|
||
const x2 = line.x2 ?? line.get('x2')
|
||
const y2 = line.y2 ?? line.get('y2')
|
||
|
||
// Sort points left-to-right, then top-to-bottom
|
||
return x1 < x2 || (x1 === x2 && y1 < y2) ? [x1, y1, x2, y2] : [x2, y2, x1, y1]
|
||
}
|
||
|
||
const aCoords = getCoords(a)
|
||
const bCoords = getCoords(b)
|
||
|
||
// Compare each coordinate in order
|
||
for (let i = 0; i < 4; i++) {
|
||
if (Math.abs(aCoords[i] - bCoords[i]) > 0.1) {
|
||
return aCoords[i] - bCoords[i]
|
||
}
|
||
}
|
||
return 0
|
||
})
|
||
}
|
||
|
||
// [정렬 정책 — 인덱스가 생명] (2026-04-27)
|
||
// 1) master = roofLines. roofLine 은 외곽(처마) 경계라 사용자 mutation 영향 없음 → 시작점 안정.
|
||
// 2) ensureCounterClockwiseLines 가 master 의 leftTop(min-Y → min-X) 꼭짓점에서 시작해 CCW 정렬.
|
||
// 3) wall.lines, wall.baseLines 는 raw 인덱스 1:1 페어링 유지된다는 전제로
|
||
// master 의 sorted 순서 → 원 인덱스 → wall.lines[idx] / wall.baseLines[idx] 매핑.
|
||
// wall 쪽을 master 로 쓰던 구현은 OVER 흡수/usePropertiesSetting 등에서 wall.lines 가
|
||
// 재할당되며 시작점이 어긋나 페어 인덱스가 회전되던 문제(2026-04-27 ㅗ 좌측오버 케이스) 가 있어 변경.
|
||
const _keyOfEdge = (l) => {
|
||
const a = `${Math.round(l.x1 * 10) / 10},${Math.round(l.y1 * 10) / 10}`
|
||
const b = `${Math.round(l.x2 * 10) / 10},${Math.round(l.y2 * 10) / 10}`
|
||
return a < b ? `${a}|${b}` : `${b}|${a}`
|
||
}
|
||
const _rawIdxByKey = new Map()
|
||
roofLines.forEach((l, i) => _rawIdxByKey.set(_keyOfEdge(l), i))
|
||
const sortRoofLines = ensureCounterClockwiseLines(roofLines)
|
||
const sortWallLines = sortRoofLines.map((sl) => {
|
||
const origIdx = _rawIdxByKey.get(_keyOfEdge(sl))
|
||
return origIdx != null && wallLines[origIdx] ? wallLines[origIdx] : sl
|
||
})
|
||
const sortWallBaseLines = sortRoofLines.map((sl) => {
|
||
const origIdx = _rawIdxByKey.get(_keyOfEdge(sl))
|
||
return origIdx != null && wall.baseLines[origIdx] ? wall.baseLines[origIdx] : sl
|
||
})
|
||
|
||
// [PAIR-DIAG] local only — top in 시 페어 인덱스 어긋남 진단 (2026-04-30)
|
||
// 1) 길이 미스매치 / 2) _keyOfEdge 충돌 / 3) raw 단계 1:1 무너짐 가려내기 위함
|
||
if (process.env.NEXT_PUBLIC_RUN_MODE === 'local') {
|
||
// logger.log('[PAIR-DIAG] lengths', {
|
||
// roofLines: roofLines.length,
|
||
// wallLines: wallLines.length,
|
||
// wallBaseLines: wall.baseLines.length,
|
||
// })
|
||
const _seen = new Map()
|
||
roofLines.forEach((l, i) => {
|
||
const k = _keyOfEdge(l)
|
||
if (_seen.has(k)) logger.warn(`[PAIR-DIAG] key collision: idx ${_seen.get(k)} vs ${i} key=${k}`)
|
||
else _seen.set(k, i)
|
||
})
|
||
roofLines.forEach((rl, i) => {
|
||
const wl = wallLines[i]
|
||
const wb = wall.baseLines[i]
|
||
// logger.log(`[PAIR-DIAG] raw#${i}`,
|
||
// `roof=(${Math.round(rl.x1)},${Math.round(rl.y1)})→(${Math.round(rl.x2)},${Math.round(rl.y2)})`,
|
||
// wl ? `wall=(${Math.round(wl.x1)},${Math.round(wl.y1)})→(${Math.round(wl.x2)},${Math.round(wl.y2)})` : 'wall=∅',
|
||
// wb ? `base=(${Math.round(wb.x1)},${Math.round(wb.y1)})→(${Math.round(wb.x2)},${Math.round(wb.y2)})` : 'base=∅')
|
||
})
|
||
}
|
||
|
||
// [MOVE-TRACE] 진입 스냅샷 (필요 시 주석 해제)
|
||
// logger.log('[MOVE-TRACE] === getMoveUpDownLine entry ===',
|
||
// 'moveUpDown=', roof.moveUpDown,
|
||
// 'moveFlowLine=', roof.moveFlowLine)
|
||
// sortWallBaseLines.forEach((bl, i) => {
|
||
// logger.log(`[MOVE-TRACE] sortWallBaseLines[${i}]`,
|
||
// `(${Math.round(bl?.x1)},${Math.round(bl?.y1)})→(${Math.round(bl?.x2)},${Math.round(bl?.y2)})`,
|
||
// 'vs sortWallLines',
|
||
// `(${Math.round(sortWallLines[i]?.x1)},${Math.round(sortWallLines[i]?.y1)})→(${Math.round(sortWallLines[i]?.x2)},${Math.round(sortWallLines[i]?.y2)})`,
|
||
// 'vs sortRoofLines',
|
||
// `(${Math.round(sortRoofLines[i]?.x1)},${Math.round(sortRoofLines[i]?.y1)})→(${Math.round(sortRoofLines[i]?.x2)},${Math.round(sortRoofLines[i]?.y2)})`)
|
||
// })
|
||
|
||
// ===== 공통 헬퍼 함수 =====
|
||
// axis config: vertical(left/right) → moveAxis='x', lineAxis='y'
|
||
// horizontal(top/bottom) → moveAxis='y', lineAxis='x'
|
||
|
||
// _in + start 또는 end 처리
|
||
const processInStartEnd = ({
|
||
condition, isStart, roofLine, wallLine, wallBaseLine, index,
|
||
newPStart, newPEnd, getAddLine, sortRoofLines, findPoints,
|
||
moveAxis, lineAxis, inSign
|
||
}) => {
|
||
// start: 1번 좌표 사용, end: 2번 좌표 사용
|
||
const k = isStart ? '1' : '2'
|
||
const otherK = isStart ? '2' : '1'
|
||
const m = `${moveAxis}${k}` // x1 or y1
|
||
const l = `${lineAxis}${k}` // y1 or x1
|
||
const otherL = `${lineAxis}${otherK}` // y2 or x2
|
||
|
||
// logger.log(`${condition}::::isStartEnd:::::`)
|
||
|
||
// [MOVE-TRACE] index 0 전용 (필요 시 주석 해제)
|
||
// if (index === 0) {
|
||
// logger.log('[MOVE-TRACE] processInStartEnd idx=0',
|
||
// 'condition=', condition,
|
||
// 'isStart=', isStart,
|
||
// 'inSign=', inSign,
|
||
// 'moveAxis=', moveAxis,
|
||
// 'lineAxis=', lineAxis,
|
||
// 'wallLine=', `(${Math.round(wallLine.x1)},${Math.round(wallLine.y1)})→(${Math.round(wallLine.x2)},${Math.round(wallLine.y2)})`,
|
||
// 'wallBaseLine=', `(${Math.round(wallBaseLine.x1)},${Math.round(wallBaseLine.y1)})→(${Math.round(wallBaseLine.x2)},${Math.round(wallBaseLine.y2)})`,
|
||
// 'roofLine=', `(${Math.round(roofLine.x1)},${Math.round(roofLine.y1)})→(${Math.round(roofLine.x2)},${Math.round(roofLine.y2)})`)
|
||
// }
|
||
|
||
// 고정 끝점 설정
|
||
if (isStart) {
|
||
newPEnd[lineAxis] = roofLine[`${lineAxis}2`]
|
||
newPEnd[moveAxis] = roofLine[`${moveAxis}2`]
|
||
} else {
|
||
newPStart[lineAxis] = roofLine[`${lineAxis}1`]
|
||
newPStart[moveAxis] = roofLine[`${moveAxis}1`]
|
||
}
|
||
|
||
const moveDist = Big(wallBaseLine[m]).minus(wallLine[m]).abs().toNumber()
|
||
const ePoint = { [moveAxis]: wallBaseLine[m], [lineAxis]: wallBaseLine[l] }
|
||
if (isStart) {
|
||
newPStart[lineAxis] = wallBaseLine[l]
|
||
} else {
|
||
newPEnd[lineAxis] = wallBaseLine[l]
|
||
}
|
||
|
||
findPoints.push({ ...ePoint, position: `${condition}_${isStart ? 'start' : 'end'}` })
|
||
|
||
let newPointM = Big(roofLine[`${moveAxis}1`])
|
||
[inSign > 0 ? 'plus' : 'minus'](moveDist)
|
||
.toNumber()
|
||
const pLineL = roofLine[l]
|
||
|
||
const prevIndex = (index - 1 + sortRoofLines.length) % sortRoofLines.length
|
||
const nextIndex = (index + 1) % sortRoofLines.length
|
||
const adjIndex = isStart ? prevIndex : nextIndex
|
||
const pLineM = sortRoofLines[adjIndex][m]
|
||
|
||
// blue 라인
|
||
const blueP = isStart ? newPStart : newPEnd
|
||
getAddLine({ [moveAxis]: blueP[moveAxis], [lineAxis]: blueP[lineAxis] }, ePoint, 'blue')
|
||
|
||
// green, pink 라인 (같은 lineAxis 좌표 근처일 때)
|
||
const wbL = wallBaseLine[l]
|
||
const wL = wallLine[l]
|
||
if (Math.abs(wbL - wL) < 0.1) {
|
||
logger.warn(`⚠️ [${condition.toUpperCase()}_${isStart ? 'START' : 'END'}] Line crossing detected! newPoint:`, newPointM, 'pLine:', pLineM)
|
||
if (moveAxis === 'x') {
|
||
getAddLine({ x: pLineM, y: pLineL }, { x: newPointM, y: pLineL }, 'green')
|
||
getAddLine({ x: newPointM, y: pLineL }, ePoint, 'pink')
|
||
} else {
|
||
getAddLine({ x: pLineL, y: pLineM }, { x: pLineL, y: newPointM }, 'green')
|
||
getAddLine({ x: pLineL, y: newPointM }, ePoint, 'pink')
|
||
}
|
||
}
|
||
|
||
getAddLine(newPStart, newPEnd, 'red')
|
||
}
|
||
|
||
// _in + 양쪽 이동 (HIP extensionLine) 처리
|
||
const processInBoth = ({
|
||
condition, roofLine, wallBaseLine, index,
|
||
getAddLine, sortRoofLines, findPoints, innerLines,
|
||
moveAxis, lineAxis, inSign
|
||
}) => {
|
||
// [SHOULDER-ADJACENT] 인접 pair 가 SHOULDER_ABSORBED 된 경우 HIP 빗변은 orphan 이 됨.
|
||
// 이유: processInBoth 는 roofLine(원본 roof.points 기반) 꼭짓점을 HIP 끝점으로 씀.
|
||
// 인접 pair 흡수 = 해당 꼭짓점이 현재 wallBaseLine 토폴로지에 없음 → 공중에 뜬 대각선.
|
||
// SHOULDER_ABSORBED 가드(line 1608 인근)와 같은 원인·패턴의 후속 증상.
|
||
// 임계 = 10: SHOULDER_ABSORBED skip 임계와 동일하게 맞춤. (OVER_EPS=0.5 → planeSize=5)
|
||
// <1 로 두면 pair#3 흡수(planeSize=5) 인접인 pair#4 가 가드 통과 → phantom HIP 생성.
|
||
{
|
||
const n = sortWallBaseLines.length
|
||
const prevBL = sortWallBaseLines[(index - 1 + n) % n]
|
||
const nextBL = sortWallBaseLines[(index + 1) % n]
|
||
const prevAbsorbed = (prevBL?.attributes?.planeSize ?? Infinity) < 10
|
||
const nextAbsorbed = (nextBL?.attributes?.planeSize ?? Infinity) < 10
|
||
if (prevAbsorbed || nextAbsorbed) {
|
||
// logger.log(`⏭️ [pair#${index}] ${condition} HIP_SKIP adj_absorbed prev=${prevAbsorbed} next=${nextAbsorbed}`)
|
||
return
|
||
}
|
||
}
|
||
|
||
// logger.log(`${condition}::::isStartEnd (both):::::`)
|
||
|
||
// 원본 기준: moveDistY = abs(roofLine.y - wallBaseLine.y), moveDistX = abs(roofLine.x - wallBaseLine.x)
|
||
const moveDistY1 = Math.abs(Big(roofLine.y1).minus(wallBaseLine.y1).toNumber())
|
||
const moveDistX1 = Math.abs(Big(roofLine.x1).minus(wallBaseLine.x1).toNumber())
|
||
const moveDistX2 = Math.abs(Big(roofLine.x2).minus(wallBaseLine.x2).toNumber())
|
||
const moveDistY2 = Math.abs(Big(roofLine.y2).minus(wallBaseLine.y2).toNumber())
|
||
|
||
const sPoint = { x: wallBaseLine.x1, y: wallBaseLine.y1 }
|
||
const ePoint = { x: wallBaseLine.x2, y: wallBaseLine.y2 }
|
||
|
||
// 체크 기준: vertical(left/right)→moveDistY, horizontal(top/bottom)→moveDistX
|
||
// 즉 항상 lineAxis 방향 거리로 체크
|
||
const checkDist1 = moveAxis === 'x' ? moveDistY1 : moveDistX1
|
||
const checkDist2 = moveAxis === 'x' ? moveDistY2 : moveDistX2
|
||
|
||
// HIP 방향 부호:
|
||
// vertical(left/right): ySign=+1/-1(start/end), xSign=inSign
|
||
// horizontal(top/bottom): ySign=inSign, xSign=-inSign/inSign(start/end)
|
||
const ySignStart = moveAxis === 'x' ? 1 : inSign
|
||
const xSignStart = moveAxis === 'x' ? inSign : -inSign
|
||
const ySignEnd = moveAxis === 'x' ? -1 : inSign
|
||
const xSignEnd = inSign
|
||
|
||
const createHipLine = (fromPoint, toPoint) => {
|
||
const createdLine = getAddLine(fromPoint, toPoint, 'orange', 'extensionLine')
|
||
// logger.log('extensionLine created - length:', createdLine.length, 'coords:', { x1: createdLine.x1, y1: createdLine.y1, x2: createdLine.x2, y2: createdLine.y2 })
|
||
createdLine.set({ name: LINE_TYPE.SUBLINE.HIP, lineName: LINE_TYPE.SUBLINE.HIP, stroke: '#FF0000', strokeWidth: 2 })
|
||
createdLine.attributes = { ...createdLine.attributes, type: LINE_TYPE.SUBLINE.HIP }
|
||
innerLines.push(createdLine)
|
||
}
|
||
|
||
// [SK-VERTEX-PROXIMITY] HIP target 은 새 SK 폴리곤(changRoofLinePoints) 의 vertex 근처여야 함.
|
||
// roofLine 은 OLD roof.points 기반 → 인접 흡수로 코너가 dissolve 되면 target 이 SK 어디에도 없는 phantom 좌표가 됨.
|
||
// pair#0 의 prev/next 인접이 아닌 2-hop 흡수 (ex. left_out 흡수 후 인접 pair#7 stretched, pair#0 end 가 옛 코너 참조) 도 차단.
|
||
const __isNearSkVertex = (pt, tol = 5) => {
|
||
if (!Array.isArray(skPolygonPoints) || skPolygonPoints.length === 0) return true
|
||
return skPolygonPoints.some((v) => Math.hypot(v.x - pt.x, v.y - pt.y) < tol)
|
||
}
|
||
|
||
if (checkDist1 > 0) {
|
||
// findPoints 불필요: wallBaseLine 좌표가 ridge 라인 위에 없음 (스켈레톤이 이동된 폴리곤 기준으로 재계산되므로)
|
||
// HIP extension 라인만 생성
|
||
let target
|
||
if (moveDistY1 > moveDistX1) {
|
||
const dist = moveDistY1 - moveDistX1
|
||
target = { x: roofLine.x1, y: roofLine.y1 + ySignStart * dist }
|
||
} else {
|
||
const dist = moveDistX1 - moveDistY1
|
||
target = { x: roofLine.x1 + xSignStart * dist, y: roofLine.y1 }
|
||
}
|
||
if (!__isNearSkVertex(target)) {
|
||
// logger.log(`⏭️ [pair#${index}] ${condition} HIP_SKIP_VERTEX_MISS start target=(${target.x.toFixed(1)},${target.y.toFixed(1)})`)
|
||
} else {
|
||
createHipLine(sPoint, target)
|
||
}
|
||
}
|
||
|
||
if (checkDist2 > 0) {
|
||
let target
|
||
if (moveDistY2 > moveDistX2) {
|
||
const dist = moveDistY2 - moveDistX2
|
||
target = { x: roofLine.x2, y: roofLine.y2 + ySignEnd * dist }
|
||
} else {
|
||
const dist = moveDistX2 - moveDistY2
|
||
target = { x: roofLine.x2 + xSignEnd * dist, y: roofLine.y2 }
|
||
}
|
||
if (!__isNearSkVertex(target)) {
|
||
// logger.log(`⏭️ [pair#${index}] ${condition} HIP_SKIP_VERTEX_MISS end target=(${target.x.toFixed(1)},${target.y.toFixed(1)})`)
|
||
} else {
|
||
createHipLine(ePoint, target)
|
||
}
|
||
}
|
||
}
|
||
|
||
// _out 처리는 포지션별로 직접 처리 (아래 각 분기에서 인라인 처리)
|
||
|
||
|
||
// roofLines의 방향에 맞춰 currentRoofLines의 방향을 조정
|
||
// const alignLineDirection = (sourceLines, targetLines) => {
|
||
// return sourceLines.map((sourceLine) => {
|
||
// // 가장 가까운 targetLine 찾기
|
||
// const nearestTarget = targetLines.reduce((nearest, targetLine) => {
|
||
// const sourceCenter = {
|
||
// x: (sourceLine.x1 + sourceLine.x2) / 2,
|
||
// y: (sourceLine.y1 + sourceLine.y2) / 2,
|
||
// }
|
||
// const targetCenter = {
|
||
// x: (targetLine.x1 + targetLine.x2) / 2,
|
||
// y: (targetLine.y1 + targetLine.y2) / 2,
|
||
// }
|
||
// const distance = Math.hypot(sourceCenter.x - targetCenter.x, sourceCenter.y - targetCenter.y)
|
||
//
|
||
// return !nearest || distance < nearest.distance ? { line: targetLine, distance } : nearest
|
||
// }, null)?.line
|
||
//
|
||
// if (!nearestTarget) return sourceLine
|
||
//
|
||
// // 방향이 반대인지 확인 (벡터 내적을 사용)
|
||
// const sourceVec = {
|
||
// x: sourceLine.x2 - sourceLine.x1,
|
||
// y: sourceLine.y2 - sourceLine.y1,
|
||
// }
|
||
// const targetVec = {
|
||
// x: nearestTarget.x2 - nearestTarget.x1,
|
||
// y: nearestTarget.y2 - nearestTarget.y1,
|
||
// }
|
||
//
|
||
// const dotProduct = sourceVec.x * targetVec.x + sourceVec.y * targetVec.y
|
||
//
|
||
// // 내적이 음수이면 방향이 반대이므로 뒤집기
|
||
// if (dotProduct < 0) {
|
||
// return {
|
||
// ...sourceLine,
|
||
// x1: sourceLine.x2,
|
||
// y1: sourceLine.y2,
|
||
// x2: sourceLine.x1,
|
||
// y2: sourceLine.y1,
|
||
// }
|
||
// }
|
||
//
|
||
// return sourceLine
|
||
// })
|
||
// }
|
||
|
||
// logger.log('wallBaseLines', wall.baseLines)
|
||
|
||
//wall.baseLine은 움직인라인
|
||
let movedLines = []
|
||
|
||
// 조건에 맞는 라인들만 필터링
|
||
const validWallLines = [...wallLines].sort((a, b) => a.idx - b.idx).filter((wallLine, index) => wallLine.idx - 1 === index)
|
||
|
||
// logger.log('', sortRoofLines, sortWallLines, sortWallBaseLines);
|
||
|
||
(sortWallLines.length === sortWallBaseLines.length && sortWallBaseLines.length > 3) &&
|
||
sortWallLines.forEach((wallLine, index) => {
|
||
|
||
const roofLine = sortRoofLines[index]
|
||
const wallBaseLine = sortWallBaseLines[index]
|
||
|
||
// [SHOULDER-ABSORBED] wall.baseLines[k] 가 이동 흡수/OVER_EPS 클램핑으로 거의 zero-length 이면
|
||
// 해당 edge 는 흡수된 상태. pair 생성 자체가 불필요(DIR_MISMATCH + eaveHelpLine 노이즈 유발).
|
||
// 임계 = 10: useMovementSetting OVER_EPS=0.5 (canvas unit) 시 잔여 길이=0.5 → planeSize=5 (calcLinePlaneSize × 10).
|
||
// 안전마진 두고 < 10 으로 차단. 정상 baseLine 은 보통 50+ 이므로 false positive 없음.
|
||
if (!wallBaseLine || (wallBaseLine.attributes?.planeSize ?? Infinity) < 10) {
|
||
// logger.log(`⏭️ [pair#${index}] SHOULDER_ABSORBED skip: wb=(${Math.round(wallBaseLine?.x1)},${Math.round(wallBaseLine?.y1)})→(${Math.round(wallBaseLine?.x2)},${Math.round(wallBaseLine?.y2)}) planeSize=${wallBaseLine?.attributes?.planeSize}`)
|
||
return
|
||
}
|
||
|
||
// [진단] sortWallLines ↔ sortWallBaseLines 페어링 검증
|
||
// collapse 시 ensureCounterClockwiseLines 시작점이 달라지면 인덱스가 어긋남
|
||
// 정상: 같은 물리 벽 → 적어도 한쪽 끝점 공유 또는 방향 동일(수직/수평)
|
||
{
|
||
const wlDir = Math.abs(wallLine.x2 - wallLine.x1) < 0.5 ? 'V' : (Math.abs(wallLine.y2 - wallLine.y1) < 0.5 ? 'H' : 'D')
|
||
const wbDir = Math.abs(wallBaseLine.x2 - wallBaseLine.x1) < 0.5 ? 'V' : (Math.abs(wallBaseLine.y2 - wallBaseLine.y1) < 0.5 ? 'H' : 'D')
|
||
const wallIdWl = wallLine.attributes?.wallId ?? wallLine.attributes?.idx ?? '?'
|
||
const wallIdWb = wallBaseLine.attributes?.wallId ?? wallBaseLine.attributes?.idx ?? '?'
|
||
const dirMatch = wlDir === wbDir
|
||
// logger.log(`🔗 [pair#${index}] wallId wl=${wallIdWl} wb=${wallIdWb} ${dirMatch ? '' : '⚠️DIR_MISMATCH '}wlDir=${wlDir} wbDir=${wbDir} wl=(${Math.round(wallLine.x1)},${Math.round(wallLine.y1)})→(${Math.round(wallLine.x2)},${Math.round(wallLine.y2)}) wb=(${Math.round(wallBaseLine.x1)},${Math.round(wallBaseLine.y1)})→(${Math.round(wallBaseLine.x2)},${Math.round(wallBaseLine.y2)})`)
|
||
}
|
||
|
||
//roofline 외곽선 설정
|
||
|
||
// logger.log('index::::', index)
|
||
// logger.log('roofLine:', roofLine.x1, roofLine.y1, roofLine.x2, roofLine.y2)
|
||
// logger.log('wallLine:', wallLine.x1, wallLine.y1, wallLine.x2, wallLine.y2)
|
||
// logger.log('wallBaseLine:', wallBaseLine.x1, wallBaseLine.y1, wallBaseLine.x2, wallBaseLine.y2)
|
||
// logger.log('isSamePoint result:', isSameLine2(wallBaseLine, wallLine))
|
||
|
||
const isCollinear = (l1, l2, tolerance = 0.1) => {
|
||
const slope1 = Math.abs(l1.x2 - l1.x1) < tolerance ? Infinity : (l1.y2 - l1.y1) / (l1.x2 - l1.x1)
|
||
const slope2 = Math.abs(l2.x2 - l2.x1) < tolerance ? Infinity : (l2.y2 - l2.y1) / (l2.x2 - l2.x1)
|
||
|
||
if (slope1 === Infinity && slope2 === Infinity) {
|
||
return Math.abs(l1.x1 - l2.x1) < tolerance
|
||
}
|
||
|
||
if (Math.abs(slope1 - slope2) > tolerance) return false
|
||
|
||
const yIntercept1 = l1.y1 - slope1 * l1.x1
|
||
const yIntercept2 = l2.y1 - slope2 * l2.x1
|
||
|
||
return Math.abs(yIntercept1 - yIntercept2) < tolerance
|
||
}
|
||
|
||
if (isCollinear(wallBaseLine, wallLine)) {
|
||
// [v1 corner-gap eaveHelpLine 2026-04-30] collinear 라도 한쪽 끝점이 어긋난 corner shift 라면
|
||
// gap segment 를 eaveHelpLine 으로 추가. 미적용 시 SK ext 가 다른 짧은 보조선에 잘못 hit.
|
||
// tolerance=1.0 (메모리: UI/Big.js drift 고려).
|
||
const __cgStartSame = Math.abs(wallLine.x1 - wallBaseLine.x1) < 1.0 && Math.abs(wallLine.y1 - wallBaseLine.y1) < 1.0
|
||
const __cgEndSame = Math.abs(wallLine.x2 - wallBaseLine.x2) < 1.0 && Math.abs(wallLine.y2 - wallBaseLine.y2) < 1.0
|
||
const __cgPairs = []
|
||
if (__cgStartSame && !__cgEndSame) {
|
||
__cgPairs.push([{ x: wallLine.x2, y: wallLine.y2 }, { x: wallBaseLine.x2, y: wallBaseLine.y2 }])
|
||
} else if (!__cgStartSame && __cgEndSame) {
|
||
__cgPairs.push([{ x: wallLine.x1, y: wallLine.y1 }, { x: wallBaseLine.x1, y: wallBaseLine.y1 }])
|
||
} else if (!__cgStartSame && !__cgEndSame) {
|
||
__cgPairs.push([{ x: wallLine.x1, y: wallLine.y1 }, { x: wallBaseLine.x1, y: wallBaseLine.y1 }])
|
||
__cgPairs.push([{ x: wallLine.x2, y: wallLine.y2 }, { x: wallBaseLine.x2, y: wallBaseLine.y2 }])
|
||
}
|
||
for (const [p1, p2] of __cgPairs) {
|
||
if (Math.hypot(p2.x - p1.x, p2.y - p1.y) < 0.5) continue
|
||
// logger.log(`🎯 [corner-gap eaveHelpLine] index=${index} p1=(${Math.round(p1.x)},${Math.round(p1.y)})→p2=(${Math.round(p2.x)},${Math.round(p2.y)}) wallLine=(${Math.round(wallLine.x1)},${Math.round(wallLine.y1)})→(${Math.round(wallLine.x2)},${Math.round(wallLine.y2)}) wallBaseLine=(${Math.round(wallBaseLine.x1)},${Math.round(wallBaseLine.y1)})→(${Math.round(wallBaseLine.x2)},${Math.round(wallBaseLine.y2)})`)
|
||
const __cgLine = new QLine([p1.x, p1.y, p2.x, p2.y], {
|
||
parentId: roof.id,
|
||
fontSize: roof.fontSize,
|
||
stroke: '',
|
||
strokeWidth: 4,
|
||
name: 'eaveHelpLine',
|
||
lineName: 'eaveHelpLine',
|
||
visible: true,
|
||
roofId: roofId,
|
||
selectable: true,
|
||
hoverCursor: 'pointer',
|
||
attributes: {
|
||
type: 'eaveHelpLine',
|
||
isStart: true,
|
||
pitch: wallLine.attributes?.pitch,
|
||
planeSize: calcLinePlaneSize({ x1: p1.x, y1: p1.y, x2: p2.x, y2: p2.y }),
|
||
actualSize: calcLinePlaneSize({ x1: p1.x, y1: p1.y, x2: p2.x, y2: p2.y }),
|
||
},
|
||
})
|
||
canvas.add(__cgLine)
|
||
__cgLine.bringToFront()
|
||
}
|
||
if (__cgPairs.length > 0) canvas.renderAll()
|
||
return
|
||
}
|
||
|
||
if (isSameLine2(wallBaseLine, wallLine)) {
|
||
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
|
||
|
||
const fullyMoved = movedStart && movedEnd
|
||
|
||
//반시계 방향
|
||
let newPStart //= {x:roofLine.x1, y:roofLine.y1}
|
||
let newPEnd //= {x:movedLines.x2, y:movedLines.y2}
|
||
|
||
//현재 roof는 무조건 시계방향
|
||
|
||
const getAddLine = (p1, p2, stroke = '', lineType = 'eaveHelpLine') => {
|
||
movedLines.push({ index, p1, p2 })
|
||
|
||
// [진단] eaveHelpLine 생성 추적: 어느 index/어느 wallBaseLine-wallLine 쌍에서 만들어지는지
|
||
// logger.log('🎯 [getAddLine]', {
|
||
// index,
|
||
// lineType,
|
||
// p1: { x: Math.round(p1.x), y: Math.round(p1.y) },
|
||
// p2: { x: Math.round(p2.x), y: Math.round(p2.y) },
|
||
// wallLine: `(${Math.round(wallLine.x1)},${Math.round(wallLine.y1)})→(${Math.round(wallLine.x2)},${Math.round(wallLine.y2)})`,
|
||
// wallBaseLine: `(${Math.round(wallBaseLine.x1)},${Math.round(wallBaseLine.y1)})→(${Math.round(wallBaseLine.x2)},${Math.round(wallBaseLine.y2)})`,
|
||
// wallBaseLen: Math.round(Math.hypot(wallBaseLine.x2 - wallBaseLine.x1, wallBaseLine.y2 - wallBaseLine.y1)),
|
||
// })
|
||
|
||
const dx = Math.abs(p2.x - p1.x);
|
||
const dy = Math.abs(p2.y - p1.y);
|
||
const isDiagonal = dx > 0.5 && dy > 0.5; // x, y 변화가 모두 있으면 대각선
|
||
|
||
//logger.log("mergeLines:::::::", mergeLines);
|
||
const line = new QLine([p1.x, p1.y, p2.x, p2.y], {
|
||
parentId: roof.id,
|
||
fontSize: roof.fontSize,
|
||
stroke: 'black',
|
||
strokeWidth: 4,
|
||
name: lineType,
|
||
lineName: lineType,
|
||
visible: true,
|
||
roofId: roofId,
|
||
selectable: true,
|
||
hoverCursor: 'pointer',
|
||
attributes: {
|
||
type: lineType,
|
||
isStart: true,
|
||
pitch: wallLine.attributes.pitch,
|
||
planeSize: calcLinePlaneSize({ x1: p1.x, y1: p1.y, x2: p2.x, y2: p2.y }),
|
||
actualSize: (isDiagonal) ? calcLineActualSize2(
|
||
{
|
||
x1: p1.x,
|
||
y1: p1.y,
|
||
x2: p2.x,
|
||
y2: p2.y
|
||
},
|
||
getDegreeByChon(wallLine.attributes.pitch)
|
||
) : calcLinePlaneSize({ x1: p1.x, y1: p1.y, x2: p2.x, y2: p2.y }),
|
||
},
|
||
})
|
||
|
||
//coordinateText(line)
|
||
canvas.add(line)
|
||
line.bringToFront()
|
||
|
||
// extensionLine은 innerLines에 추가
|
||
// if (lineType === 'extensionLine') {
|
||
// innerLines.push(line)
|
||
// }
|
||
|
||
canvas.renderAll()
|
||
return line
|
||
}
|
||
|
||
//getAddLine(roofLine.startPoint, roofLine.endPoint, ) //외곽선을 그린다
|
||
|
||
newPStart = { x: roofLine.x1, y: roofLine.y1 }
|
||
newPEnd = { x: roofLine.x2, y: roofLine.y2 }
|
||
|
||
const getInnerLines = (lines, point) => {}
|
||
let isIn = false
|
||
let isOut = false
|
||
|
||
//두 포인트가 변경된 라인인
|
||
if (fullyMoved) {
|
||
//반시계방향향
|
||
|
||
const mLine = getSelectLinePosition(wall, wallBaseLine)
|
||
|
||
// [진단] 왜 bottom/top/left/right로 잡혔는지 추적
|
||
// - wallBaseLine이 실제로는 어떤 방향인지, midpoint, 위/아래 안팎 결과 확인
|
||
{
|
||
const midX = (wallBaseLine.x1 + wallBaseLine.x2) / 2
|
||
const midY = (wallBaseLine.y1 + wallBaseLine.y2) / 2
|
||
const isH = Math.abs(wallBaseLine.y1 - wallBaseLine.y2) < 0.5
|
||
const isV = Math.abs(wallBaseLine.x1 - wallBaseLine.x2) < 0.5
|
||
// logger.log(`🧭 [getSelectLinePosition 결과] idx=${index} position=${mLine.position} orient=${mLine.orientation} mid=(${Math.round(midX)},${Math.round(midY)}) isH=${isH} isV=${isV} wallLine=(${Math.round(wallLine.x1)},${Math.round(wallLine.y1)})→(${Math.round(wallLine.x2)},${Math.round(wallLine.y2)}) wallBaseLine=(${Math.round(wallBaseLine.x1)},${Math.round(wallBaseLine.y1)})→(${Math.round(wallBaseLine.x2)},${Math.round(wallBaseLine.y2)})`)
|
||
}
|
||
|
||
if (getOrientation(roofLine) === 'vertical') {
|
||
if (['left', 'right'].includes(mLine.position)) {
|
||
if (Math.abs(wallLine.x1 - wallBaseLine.x1) < 0.1 || Math.abs(wallLine.x2 - wallBaseLine.x2) < 0.1) {
|
||
return false
|
||
}
|
||
const isLeftPosition = mLine.position === 'left'
|
||
const isRightPosition = mLine.position === 'right'
|
||
const isInPosition =
|
||
(isLeftPosition && wallLine.x1 < wallBaseLine.x1) ||
|
||
(isRightPosition && wallLine.x1 > wallBaseLine.x1) ||
|
||
(isLeftPosition && wallLine.x2 < wallBaseLine.x2) ||
|
||
(isRightPosition && wallLine.x2 > wallBaseLine.x2)
|
||
|
||
const positionType = isInPosition ? 'in' : 'out'
|
||
|
||
const condition = `${mLine.position}_${positionType}`
|
||
let isStartEnd = findInteriorPoint(wallBaseLine, sortWallBaseLines)
|
||
let sPoint, ePoint
|
||
|
||
// 공통 파라미터: vertical → moveAxis='x', lineAxis='y'
|
||
const commonParams = {
|
||
roofLine, wallLine, wallBaseLine, index,
|
||
newPStart, newPEnd, getAddLine, sortRoofLines, findPoints, innerLines,
|
||
moveAxis: 'x', lineAxis: 'y'
|
||
}
|
||
|
||
// left: inSign=+1(안으로 가면 x 증가), right: inSign=-1
|
||
// left_out: start=-1, end=+1 / right_out: start=+1, end=-1
|
||
const inSign = mLine.position === 'left' ? 1 : -1
|
||
|
||
if (positionType === 'in') {
|
||
if (mLine.position === 'left') isIn = true
|
||
|
||
if (isStartEnd.start) {
|
||
processInStartEnd({ ...commonParams, condition, isStart: true, inSign })
|
||
}
|
||
if (isStartEnd.end) {
|
||
processInStartEnd({ ...commonParams, condition, isStart: false, inSign })
|
||
}
|
||
if (!isStartEnd.start && !isStartEnd.end) {
|
||
processInBoth({ ...commonParams, condition, inSign })
|
||
}
|
||
} else if (condition === 'left_out') {
|
||
|
||
// [SHOULDER-ADJACENT _out] 인접 pair 흡수 시 현재 pair 의 끝점은 새 SK 토폴로지에서 dissolve 됨.
|
||
// roof.points 옛 코너 참조 → updateAndAddLine NOT FOUND + phantom eaveHelpLine 외부 출현.
|
||
// processInBoth 의 동일 패턴 가드(line 1471-1481) 와 동일 임계.
|
||
{
|
||
const n = sortWallBaseLines.length
|
||
const prevBL = sortWallBaseLines[(index - 1 + n) % n]
|
||
const nextBL = sortWallBaseLines[(index + 1) % n]
|
||
const prevAbsorbed = (prevBL?.attributes?.planeSize ?? Infinity) < 10
|
||
const nextAbsorbed = (nextBL?.attributes?.planeSize ?? Infinity) < 10
|
||
if (prevAbsorbed || nextAbsorbed) {
|
||
// logger.log(`⏭️ [pair#${index}] ${condition} OUT_SKIP adj_absorbed prev=${prevAbsorbed} next=${nextAbsorbed}`)
|
||
return
|
||
}
|
||
}
|
||
|
||
if (isStartEnd.start) {
|
||
const moveDist = Big(wallLine.x1).minus(wallBaseLine.x1).abs().toNumber()
|
||
const aStartY = Big(roofLine.y1).minus(moveDist).toNumber()
|
||
const bStartY = Big(wallLine.y1).minus(moveDist).toNumber()
|
||
const inLine = findLineContainingPoint(innerLines, { y: aStartY, x: roofLine.x2 })
|
||
|
||
const eLineY = Big(bStartY).minus(wallLine.y1).toNumber()
|
||
newPStart.y = aStartY
|
||
newPEnd.y = roofLine.y2
|
||
|
||
const prevIndex = (index - 1 + sortRoofLines.length) % sortRoofLines.length
|
||
const nextIndex = (index + 1) % sortRoofLines.length
|
||
const newLine = sortRoofLines[nextIndex]
|
||
|
||
if (Math.abs(wallBaseLine.y1 - wallLine.y1) < 0.1) {
|
||
if (inLine) {
|
||
if (inLine.x1 < inLine.x2) {
|
||
getAddLine({ y: bStartY, x: wallLine.x2 }, { y: inLine.y2, x: inLine.x2 }, 'pink')
|
||
} else {
|
||
getAddLine({ y: inLine.y2, x: inLine.x2 }, { y: bStartY, x: wallLine.x2 }, 'pink')
|
||
}
|
||
getAddLine({ y: bStartY, x: wallLine.x2 }, { y: roofLine.y1, x: wallLine.x1 }, 'magenta')
|
||
getAddLine({ y: newLine.y1, x: newLine.x1 }, { y: newLine.y2, x: wallLine.x2 }, 'Gray')
|
||
findPoints.push({ y: aStartY, x: newPStart.x, position: 'left_out_start' })
|
||
} else {
|
||
newPStart.y = roofLine.y1
|
||
}
|
||
} else {
|
||
const cLineY = Big(wallBaseLine.x1).minus(wallLine.x1).toNumber()
|
||
newPStart.y = Big(newPStart.y).minus(cLineY).toNumber()
|
||
const inLine = findLineContainingPoint(innerLines, { y: newPStart.y, x: newPStart.x })
|
||
if (inLine) {
|
||
if (inLine.x1 < inLine.x2) {
|
||
getAddLine({ y: newPStart.y, x: newPStart.x }, { y: inLine.y2, x: inLine.x2 }, 'purple')
|
||
} else {
|
||
getAddLine({ y: inLine.y1, x: inLine.x1 }, { y: newPStart.y, x: newPStart.x }, 'purple')
|
||
}
|
||
} else {
|
||
const rLineM = Big(wallBaseLine.x2).minus(roofLine.x2).abs().toNumber()
|
||
newPStart.y = Big(wallBaseLine.y1).minus(rLineM).toNumber()
|
||
const inLine = findLineContainingPoint(innerLines, { y: newPStart.y, x: newPStart.x })
|
||
if (inLine) {
|
||
if (inLine.x2 > inLine.x1) {
|
||
getAddLine({ y: newPStart.y, x: newPStart.x }, { y: inLine.y2, x: inLine.x2 }, 'purple')
|
||
} else {
|
||
getAddLine({ y: inLine.y1, x: inLine.x1 }, { y: newPEnd.y, x: newPEnd.x }, 'purple')
|
||
}
|
||
}
|
||
}
|
||
}
|
||
getAddLine(newPStart, newPEnd, 'red')
|
||
}
|
||
|
||
if (isStartEnd.end) {
|
||
// logger.log('left_out::::isStartEnd:::::', isStartEnd)
|
||
const moveDist = Big(wallLine.x1).minus(wallBaseLine.x1).abs().toNumber()
|
||
const aStartY = Big(roofLine.y2).plus(moveDist).toNumber()
|
||
const bStartY = Big(wallLine.y2).plus(moveDist).toNumber()
|
||
const inLine = findLineContainingPoint(innerLines, { y: aStartY, x: roofLine.x1 })
|
||
const eLineY = Big(bStartY).minus(wallLine.y2).abs().toNumber()
|
||
newPEnd.y = aStartY
|
||
newPStart.y = roofLine.y1
|
||
|
||
const prevIndex = (index - 1 + sortRoofLines.length) % sortRoofLines.length;
|
||
const nextIndex = (index + 1) % sortRoofLines.length;
|
||
const newLine = sortRoofLines[prevIndex]
|
||
|
||
if (Math.abs(wallBaseLine.y2 - wallLine.y2) < 0.1) {
|
||
if (inLine) {
|
||
if (inLine.x1 < inLine.x2) {
|
||
getAddLine({ y: bStartY, x: wallLine.x1 }, { y: inLine.y2, x: inLine.x2 }, 'pink')
|
||
} else {
|
||
getAddLine({ y: inLine.y1, x: inLine.x1 }, { y: bStartY, x: wallLine.x1 }, 'pink')
|
||
}
|
||
getAddLine({ y: bStartY, x: wallLine.x1 }, { y: roofLine.y2, x: wallLine.x2 }, 'magenta')
|
||
getAddLine({ y: newLine.y2, x: newLine.x2 }, { y: newLine.y1, x: wallLine.x1 }, 'Gray')
|
||
findPoints.push({ y: aStartY, x: newPEnd.x, position: 'left_out_end' })
|
||
} else {
|
||
newPEnd.y = roofLine.y2
|
||
}
|
||
} else {
|
||
const cLineY = Big(wallBaseLine.x2).minus(wallLine.x2).abs().toNumber()
|
||
newPEnd.y = Big(newPEnd.y).plus(cLineY).toNumber()
|
||
const inLine = findLineContainingPoint(innerLines, { y: newPEnd.y, x: newPEnd.x })
|
||
if (inLine) {
|
||
if (inLine.x1 < inLine.x2) {
|
||
getAddLine({ y: newPEnd.y, x: newPEnd.x }, { y: inLine.y2, x: inLine.x2 }, 'purple')
|
||
} else {
|
||
getAddLine({ y: inLine.y1, x: inLine.x1 }, { y: newPEnd.y, x: newPEnd.x }, 'purple')
|
||
}
|
||
} else {
|
||
const rLineM = Big(wallBaseLine.x2).minus(roofLine.x2).abs().toNumber()
|
||
newPEnd.y = Big(wallBaseLine.y2).plus(rLineM).toNumber()
|
||
const inLine = findLineContainingPoint(innerLines, { y: newPEnd.y, x: newPEnd.x })
|
||
if (inLine) {
|
||
if (inLine.x2 > inLine.x1) {
|
||
getAddLine({ y: newPEnd.y, x: newPEnd.x }, { y: inLine.y2, x: inLine.x2 }, 'purple')
|
||
} else {
|
||
getAddLine({ y: inLine.y1, x: inLine.x1 }, { y: newPEnd.y, x: newPEnd.x }, 'purple')
|
||
}
|
||
}
|
||
}
|
||
}
|
||
findPoints.push({ y: newPStart.y, x: newPEnd.x, position: 'left_out_end' })
|
||
getAddLine(newPStart, newPEnd, 'red')
|
||
}
|
||
|
||
} else if (condition === 'right_out') {
|
||
|
||
// [SHOULDER-ADJACENT _out] 인접 pair 흡수 시 현재 pair 의 끝점은 새 SK 토폴로지에서 dissolve 됨.
|
||
// roof.points 옛 코너 참조 → updateAndAddLine NOT FOUND + phantom eaveHelpLine 외부 출현.
|
||
// processInBoth 의 동일 패턴 가드(line 1471-1481) 와 동일 임계.
|
||
{
|
||
const n = sortWallBaseLines.length
|
||
const prevBL = sortWallBaseLines[(index - 1 + n) % n]
|
||
const nextBL = sortWallBaseLines[(index + 1) % n]
|
||
const prevAbsorbed = (prevBL?.attributes?.planeSize ?? Infinity) < 10
|
||
const nextAbsorbed = (nextBL?.attributes?.planeSize ?? Infinity) < 10
|
||
if (prevAbsorbed || nextAbsorbed) {
|
||
// logger.log(`⏭️ [pair#${index}] ${condition} OUT_SKIP adj_absorbed prev=${prevAbsorbed} next=${nextAbsorbed}`)
|
||
return
|
||
}
|
||
}
|
||
|
||
if (isStartEnd.start) {
|
||
// logger.log('right_out::::isStartEnd:::::', isStartEnd)
|
||
const moveDist = Big(wallLine.x1).minus(wallBaseLine.x1).abs().toNumber()
|
||
const aStartY = Big(roofLine.y1).plus(moveDist).toNumber()
|
||
const bStartY = Big(wallLine.y1).plus(moveDist).toNumber()
|
||
const inLine = findLineContainingPoint(innerLines, { y: aStartY, x: roofLine.x1 })
|
||
const eLineY = Big(bStartY).minus(wallLine.y1).toNumber()
|
||
newPStart.y = aStartY
|
||
newPEnd.y = roofLine.y2
|
||
|
||
const prevIndex = (index - 1 + sortRoofLines.length) % sortRoofLines.length
|
||
const nextIndex = (index + 1) % sortRoofLines.length
|
||
const newLine = sortRoofLines[nextIndex]
|
||
|
||
if (Math.abs(wallBaseLine.y1 - wallLine.y1) < 0.1) {
|
||
if (inLine) {
|
||
if (inLine.x2 < inLine.x1) {
|
||
getAddLine({ y: bStartY, x: wallLine.x2 }, { y: inLine.y2, x: inLine.x2 }, 'pink')
|
||
} else {
|
||
getAddLine({ y: inLine.y1, x: inLine.x1 }, { y: bStartY, x: wallLine.x2 }, 'pink')
|
||
}
|
||
getAddLine({ y: bStartY, x: wallLine.x2 }, { y: roofLine.y1, x: wallLine.x1 }, 'magenta')
|
||
getAddLine({ y: newLine.y1, x: newLine.x1 }, { y: newLine.y2, x: wallLine.x2 }, 'Gray')
|
||
findPoints.push({ y: aStartY, x: newPEnd.x, position: 'right_out_start' })
|
||
} else {
|
||
newPStart.y = roofLine.y1
|
||
}
|
||
} else {
|
||
const cLineY = Big(wallBaseLine.x1).minus(wallLine.x1).abs().toNumber()
|
||
newPStart.y = Big(newPStart.y).plus(cLineY).toNumber()
|
||
const inLine = findLineContainingPoint(innerLines, { y: newPStart.y, x: newPStart.x })
|
||
if (inLine) {
|
||
if (inLine.x2 < inLine.x1) {
|
||
getAddLine({ y: newPStart.y, x: newPStart.x }, { y: inLine.y2, x: inLine.x2 }, 'purple')
|
||
} else {
|
||
getAddLine({ y: inLine.y1, x: inLine.x1 }, { y: newPStart.y, x: newPStart.x }, 'purple')
|
||
}
|
||
} else {
|
||
const rLineM = Big(wallBaseLine.x1).minus(roofLine.x1).abs().toNumber()
|
||
newPStart.y = Big(wallBaseLine.y1).minus(rLineM).toNumber()
|
||
const inLine = findLineContainingPoint(innerLines, { y: newPStart.y, x: newPStart.x })
|
||
if (inLine) {
|
||
if (inLine.x2 > inLine.x1) {
|
||
getAddLine({ y: newPStart.y, x: newPStart.x }, { y: inLine.y1, x: inLine.x1 }, 'purple')
|
||
} else {
|
||
getAddLine({ y: inLine.y2, x: inLine.x2 }, { y: newPStart.y, x: newPStart.x }, 'purple')
|
||
}
|
||
}
|
||
}
|
||
}
|
||
getAddLine(newPStart, newPEnd, 'red')
|
||
}
|
||
|
||
if (isStartEnd.end) {
|
||
const moveDist = Big(wallLine.x1).minus(wallBaseLine.x1).abs().toNumber()
|
||
const aStartY = Big(roofLine.y2).minus(moveDist).toNumber()
|
||
const bStartY = Big(wallLine.y2).minus(moveDist).toNumber()
|
||
const inLine = findLineContainingPoint(innerLines, { y: aStartY, x: roofLine.x1 })
|
||
const eLineY = Big(bStartY).minus(wallLine.y2).abs().toNumber()
|
||
newPEnd.y = aStartY
|
||
newPStart.y = roofLine.y1
|
||
|
||
const prevIndex = (index - 1 + sortRoofLines.length) % sortRoofLines.length;
|
||
const nextIndex = (index + 1) % sortRoofLines.length;
|
||
const newLine = sortRoofLines[prevIndex]
|
||
|
||
if (inLine) {
|
||
if (inLine.x2 < inLine.x1) {
|
||
getAddLine({ y: bStartY, x: wallLine.x1 }, { y: inLine.y2, x: inLine.x2 }, 'pink')
|
||
} else {
|
||
getAddLine({ y: inLine.y1, x: inLine.x1 }, { y: bStartY, x: wallLine.x1 }, 'pink')
|
||
}
|
||
}
|
||
if (Math.abs(wallBaseLine.y2 - wallLine.y2) < 0.1) {
|
||
getAddLine({ y: bStartY, x: wallLine.x1 }, { y: roofLine.y2, x: wallLine.x2 }, 'magenta')
|
||
getAddLine({ y: newLine.y2, x: newLine.x2 }, { y: newLine.y1, x: wallLine.x1 }, 'Gray')
|
||
findPoints.push({ y: aStartY, x: newPEnd.x, position: 'right_out_end' })
|
||
} else {
|
||
const cLineY = Big(wallBaseLine.x2).minus(wallLine.x2).abs().toNumber()
|
||
newPEnd.y = Big(newPEnd.y).minus(cLineY).toNumber()
|
||
const inLine = findLineContainingPoint(innerLines, { y: newPEnd.y, x: newPEnd.x })
|
||
if (inLine) {
|
||
if (inLine.x2 < inLine.x1) {
|
||
getAddLine({ y: newPEnd.y, x: newPEnd.x }, { y: inLine.y2, x: inLine.x2 }, 'purple')
|
||
} else {
|
||
getAddLine({ y: inLine.y1, x: inLine.x1 }, { y: newPEnd.y, x: newPEnd.x }, 'purple')
|
||
}
|
||
} else {
|
||
const rLineM = Big(wallBaseLine.x2).minus(roofLine.x2).abs().toNumber()
|
||
newPEnd.y = Big(wallBaseLine.y2).minus(rLineM).toNumber()
|
||
const inLine = findLineContainingPoint(innerLines, { y: newPEnd.y, x: newPEnd.x })
|
||
if (inLine) {
|
||
if (inLine.x2 > inLine.x1) {
|
||
getAddLine({ y: newPEnd.y, x: newPEnd.x }, { y: inLine.y1, x: inLine.x1 }, 'purple')
|
||
} else {
|
||
getAddLine({ y: inLine.y2, x: inLine.x2 }, { y: newPEnd.y, x: newPEnd.x }, 'purple')
|
||
}
|
||
}
|
||
}
|
||
}
|
||
getAddLine(newPStart, newPEnd, 'red')
|
||
}
|
||
}
|
||
}
|
||
} else if (getOrientation(roofLine) === 'horizontal') {
|
||
|
||
if (['top', 'bottom'].includes(mLine.position)) {
|
||
if (Math.abs(wallLine.y1 - wallBaseLine.y1) < 0.1 || Math.abs(wallLine.y2 - wallBaseLine.y2) < 0.1) {
|
||
return false
|
||
}
|
||
const isTopPosition = mLine.position === 'top'
|
||
const isBottomPosition = mLine.position === 'bottom'
|
||
const isInPosition =
|
||
(isTopPosition && wallLine.y1 < wallBaseLine.y1) ||
|
||
(isBottomPosition && wallLine.y1 > wallBaseLine.y1) ||
|
||
(isTopPosition && wallLine.y2 < wallBaseLine.y2) ||
|
||
(isBottomPosition && wallLine.y2 > wallBaseLine.y2)
|
||
|
||
const positionType = isInPosition ? 'in' : 'out'
|
||
const condition = `${mLine.position}_${positionType}`
|
||
let isStartEnd = findInteriorPoint(wallBaseLine, sortWallBaseLines)
|
||
|
||
let sPoint, ePoint
|
||
|
||
// 공통 파라미터: horizontal → moveAxis='y', lineAxis='x'
|
||
const commonParams = {
|
||
roofLine, wallLine, wallBaseLine, index,
|
||
newPStart, newPEnd, getAddLine, sortRoofLines, findPoints, innerLines,
|
||
moveAxis: 'y', lineAxis: 'x'
|
||
}
|
||
|
||
// top: inSign=+1(안으로 가면 y 증가), bottom: inSign=-1
|
||
const inSign = mLine.position === 'top' ? 1 : -1
|
||
|
||
if (positionType === 'in') {
|
||
if (isStartEnd.start) {
|
||
processInStartEnd({ ...commonParams, condition, isStart: true, inSign })
|
||
}
|
||
if (isStartEnd.end) {
|
||
processInStartEnd({ ...commonParams, condition, isStart: false, inSign })
|
||
}
|
||
if (!isStartEnd.start && !isStartEnd.end) {
|
||
processInBoth({ ...commonParams, condition, inSign })
|
||
}
|
||
} else if (condition === 'top_out') {
|
||
|
||
// [SHOULDER-ADJACENT _out] 인접 pair 흡수 시 현재 pair 의 끝점은 새 SK 토폴로지에서 dissolve 됨.
|
||
// roof.points 옛 코너 참조 → updateAndAddLine NOT FOUND + phantom eaveHelpLine 외부 출현.
|
||
// processInBoth 의 동일 패턴 가드(line 1471-1481) 와 동일 임계.
|
||
{
|
||
const n = sortWallBaseLines.length
|
||
const prevBL = sortWallBaseLines[(index - 1 + n) % n]
|
||
const nextBL = sortWallBaseLines[(index + 1) % n]
|
||
const prevAbsorbed = (prevBL?.attributes?.planeSize ?? Infinity) < 10
|
||
const nextAbsorbed = (nextBL?.attributes?.planeSize ?? Infinity) < 10
|
||
if (prevAbsorbed || nextAbsorbed) {
|
||
// logger.log(`⏭️ [pair#${index}] ${condition} OUT_SKIP adj_absorbed prev=${prevAbsorbed} next=${nextAbsorbed}`)
|
||
return
|
||
}
|
||
}
|
||
|
||
if (isStartEnd.start) {
|
||
// logger.log('top_out isStartEnd:::::::', isStartEnd)
|
||
const moveDist = Big(wallLine.y1).minus(wallBaseLine.y1).abs().toNumber()
|
||
const aStartX = Big(roofLine.x1).plus(moveDist).toNumber()
|
||
const bStartX = Big(wallLine.x1).plus(moveDist).toNumber()
|
||
const inLine = findLineContainingPoint(innerLines, { x: aStartX, y: newPEnd.y })
|
||
|
||
const eLineX = Big(bStartX).minus(wallLine.x1).abs().toNumber()
|
||
newPEnd.x = roofLine.x2
|
||
newPStart.x = aStartX
|
||
|
||
const prevIndex = (index - 1 + sortRoofLines.length) % sortRoofLines.length;
|
||
const nextIndex = (index + 1) % sortRoofLines.length;
|
||
const newLine = sortRoofLines[nextIndex]
|
||
|
||
if (Math.abs(wallBaseLine.x1 - wallLine.x1) < 0.1) {
|
||
if (inLine) {
|
||
if (inLine.y2 > inLine.y1) {
|
||
getAddLine({ x: bStartX, y: wallLine.y1 }, { x: inLine.x2, y: inLine.y2 }, 'pink')
|
||
} else {
|
||
getAddLine({ x: inLine.x1, y: inLine.y1 }, { x: bStartX, y: wallLine.y1 }, 'pink')
|
||
}
|
||
getAddLine({ x: bStartX, y: wallLine.y1 }, { x: roofLine.x1, y: wallLine.y1 }, 'magenta')
|
||
getAddLine({ x: newLine.x1, y: newLine.y1 }, { x: newLine.x1, y: wallLine.y1 }, 'Gray')
|
||
findPoints.push({ x: aStartX, y: newPEnd.y, position: 'top_out_start' })
|
||
} else {
|
||
newPStart.x = roofLine.x1
|
||
}
|
||
} else {
|
||
const cLineX = Big(wallBaseLine.y1).minus(wallLine.y1).abs().toNumber()
|
||
newPStart.x = Big(newPStart.x).plus(cLineX).toNumber()
|
||
const inLine = findLineContainingPoint(innerLines, { y: newPStart.y, x: newPStart.x })
|
||
if (inLine) {
|
||
if (inLine.y2 > inLine.y1) {
|
||
getAddLine({ y: newPStart.y, x: newPStart.x }, { y: inLine.y2, x: inLine.x2 }, 'purple')
|
||
} else {
|
||
getAddLine({ y: inLine.y1, x: inLine.x1 }, { y: newPStart.y, x: newPStart.x }, 'purple')
|
||
}
|
||
} else {
|
||
const rLineM = Big(wallBaseLine.y1).minus(roofLine.y1).abs().toNumber()
|
||
newPStart.x = Big(wallBaseLine.x1).plus(rLineM).toNumber()
|
||
const inLine = findLineContainingPoint(innerLines, { y: newPStart.y, x: newPStart.x })
|
||
if (inLine) {
|
||
if (inLine.y2 > inLine.y1) {
|
||
getAddLine({ y: newPStart.y, x: newPStart.x }, { y: inLine.y2, x: inLine.x2 }, 'purple')
|
||
} else {
|
||
getAddLine({ y: inLine.y1, x: inLine.x1 }, { y: newPStart.y, x: newPStart.x }, 'purple')
|
||
}
|
||
}
|
||
}
|
||
}
|
||
getAddLine(newPStart, newPEnd, 'red')
|
||
}
|
||
if (isStartEnd.end) {
|
||
// logger.log('isStartEnd:::::', isStartEnd)
|
||
const moveDist = Big(wallLine.y1).minus(wallBaseLine.y1).abs().toNumber()
|
||
const aStartX = Big(roofLine.x2).minus(moveDist).toNumber()
|
||
const bStartX = Big(wallLine.x2).minus(moveDist).toNumber()
|
||
const inLine = findLineContainingPoint(innerLines, { x: aStartX, y: newPEnd.y })
|
||
const eLineX = Big(bStartX).minus(wallLine.x2).toNumber()
|
||
newPStart.x = roofLine.x1
|
||
newPEnd.x = aStartX
|
||
|
||
const prevIndex = (index - 1 + sortRoofLines.length) % sortRoofLines.length;
|
||
const nextIndex = (index + 1) % sortRoofLines.length;
|
||
const newLine = sortRoofLines[prevIndex]
|
||
|
||
if (Math.abs(wallBaseLine.x2 - wallLine.x2) < 0.1) {
|
||
if (inLine) {
|
||
if (inLine.y2 > inLine.y1) {
|
||
getAddLine({ x: bStartX, y: wallLine.y1 }, { x: inLine.x2, y: inLine.y2 }, 'pink')
|
||
} else {
|
||
getAddLine({ x: inLine.x1, y: inLine.y1 }, { x: bStartX, y: wallLine.y1 }, 'pink')
|
||
}
|
||
getAddLine({ x: bStartX, y: wallLine.y1 }, { x: roofLine.x2, y: wallLine.y2 }, 'magenta')
|
||
getAddLine({ x: newLine.x2, y: newLine.y2 }, { x: newLine.x1, y: wallLine.y1 }, 'Gray')
|
||
findPoints.push({ x: aStartX, y: newPEnd.y, position: 'top_out_end' })
|
||
} else {
|
||
newPEnd.x = roofLine.x2
|
||
}
|
||
} else {
|
||
const cLineX = Big(wallLine.y2).minus(wallBaseLine.y2).abs().toNumber()
|
||
newPEnd.x = Big(newPEnd.x).minus(cLineX).toNumber()
|
||
const inLine = findLineContainingPoint(innerLines, { y: newPEnd.y, x: newPEnd.x })
|
||
if (inLine) {
|
||
if (inLine.y2 > inLine.y1) {
|
||
getAddLine({ y: newPEnd.y, x: newPEnd.x }, { y: inLine.y2, x: inLine.x2 }, 'purple')
|
||
} else {
|
||
getAddLine({ y: inLine.y1, x: inLine.x1 }, { y: newPEnd.y, x: newPEnd.x }, 'purple')
|
||
}
|
||
} else {
|
||
const rLineM = Big(wallBaseLine.y2).minus(roofLine.y2).abs().toNumber()
|
||
newPEnd.x = Big(wallBaseLine.x2).minus(rLineM).toNumber()
|
||
const inLine = findLineContainingPoint(innerLines, { y: newPEnd.y, x: newPEnd.x })
|
||
if (inLine) {
|
||
if (inLine.y1 > inLine.y2) {
|
||
getAddLine({ y: newPEnd.y, x: newPEnd.x }, { y: inLine.y1, x: inLine.x1 }, 'purple')
|
||
} else {
|
||
getAddLine({ y: inLine.y2, x: inLine.x2 }, { y: newPEnd.y, x: newPEnd.x }, 'purple')
|
||
}
|
||
}
|
||
}
|
||
}
|
||
getAddLine(newPStart, newPEnd, 'red')
|
||
}
|
||
} else if (condition === 'bottom_out') {
|
||
// logger.log('bottom_out isStartEnd:::::::', isStartEnd)
|
||
|
||
// [SHOULDER-ADJACENT _out] 인접 pair 흡수 시 현재 pair 의 끝점은 새 SK 토폴로지에서 dissolve 됨.
|
||
// roof.points 옛 코너 참조 → updateAndAddLine NOT FOUND + phantom eaveHelpLine 외부 출현.
|
||
// processInBoth 의 동일 패턴 가드(line 1471-1481) 와 동일 임계.
|
||
{
|
||
const n = sortWallBaseLines.length
|
||
const prevBL = sortWallBaseLines[(index - 1 + n) % n]
|
||
const nextBL = sortWallBaseLines[(index + 1) % n]
|
||
const prevAbsorbed = (prevBL?.attributes?.planeSize ?? Infinity) < 10
|
||
const nextAbsorbed = (nextBL?.attributes?.planeSize ?? Infinity) < 10
|
||
if (prevAbsorbed || nextAbsorbed) {
|
||
// logger.log(`⏭️ [pair#${index}] ${condition} OUT_SKIP adj_absorbed prev=${prevAbsorbed} next=${nextAbsorbed}`)
|
||
return
|
||
}
|
||
}
|
||
|
||
if (isStartEnd.start) {
|
||
// logger.log('isStartEnd:::::::', isStartEnd)
|
||
const moveDist = Big(wallLine.y1).minus(wallBaseLine.y1).abs().toNumber()
|
||
const aStartX = Big(roofLine.x1).minus(moveDist).toNumber()
|
||
const bStartX = Big(wallLine.x1).minus(moveDist).toNumber()
|
||
const inLine = findLineContainingPoint(innerLines, { x: aStartX, y: roofLine.y1 })
|
||
const eLineX = Big(bStartX).minus(wallLine.x1).abs().toNumber()
|
||
newPEnd.x = roofLine.x2
|
||
newPStart.x = aStartX
|
||
|
||
const prevIndex = (index - 1 + sortRoofLines.length) % sortRoofLines.length;
|
||
const nextIndex = (index + 1) % sortRoofLines.length;
|
||
const newLine = sortRoofLines[nextIndex]
|
||
|
||
if (Math.abs(wallBaseLine.x1 - wallLine.x1) < 0.1) {
|
||
if (inLine) {
|
||
if (inLine.y2 < inLine.y1) {
|
||
getAddLine({ x: bStartX, y: wallLine.y1 }, { x: inLine.x2, y: inLine.y2 }, 'pink')
|
||
} else {
|
||
getAddLine({ x: inLine.x1, y: inLine.y1 }, { x: bStartX, y: wallLine.y1 }, 'pink')
|
||
}
|
||
getAddLine({ x: bStartX, y: wallLine.y1 }, { x: roofLine.x1, y: wallLine.y1 }, 'magenta')
|
||
getAddLine({ x: newLine.x1, y: newLine.y1 }, { x: newLine.x1, y: wallLine.y1 }, 'Gray')
|
||
findPoints.push({ x: aStartX, y: newPEnd.y, position: 'bottom_out_start' })
|
||
} else {
|
||
newPStart.x = roofLine.x1
|
||
}
|
||
} else {
|
||
const cLineX = Big(wallBaseLine.y1).minus(wallLine.y1).abs().toNumber()
|
||
newPStart.x = Big(newPStart.x).minus(cLineX).toNumber()
|
||
const inLine = findLineContainingPoint(innerLines, { y: newPStart.y, x: newPStart.x })
|
||
if (inLine) {
|
||
if (inLine.y2 < inLine.y1) {
|
||
getAddLine({ y: newPStart.y, x: newPStart.x }, { y: inLine.y2, x: inLine.x2 }, 'purple')
|
||
} else {
|
||
getAddLine({ y: inLine.y1, x: inLine.x1 }, { y: newPStart.y, x: newPStart.x }, 'purple')
|
||
}
|
||
} else {
|
||
const rLineM = Big(wallBaseLine.y1).minus(roofLine.y1).abs().toNumber()
|
||
newPStart.x = Big(wallBaseLine.x1).minus(rLineM).toNumber()
|
||
const inLine = findLineContainingPoint(innerLines, { y: newPStart.y, x: newPStart.x })
|
||
if (inLine) {
|
||
if (inLine.y2 > inLine.y1) {
|
||
getAddLine({ y: newPStart.y, x: newPStart.x }, { y: inLine.y1, x: inLine.x1 }, 'purple')
|
||
} else {
|
||
getAddLine({ y: inLine.y2, x: inLine.x2 }, { y: newPStart.y, x: newPStart.x }, 'purple')
|
||
}
|
||
}
|
||
}
|
||
}
|
||
getAddLine(newPStart, newPEnd, 'red')
|
||
}
|
||
|
||
if (isStartEnd.end) {
|
||
// logger.log('isStartEnd:::::', isStartEnd)
|
||
const moveDist = Big(wallLine.y1).minus(wallBaseLine.y1).abs().toNumber()
|
||
const aStartX = Big(roofLine.x2).plus(moveDist).toNumber()
|
||
const bStartX = Big(wallLine.x2).plus(moveDist).toNumber()
|
||
const inLine = findLineContainingPoint(innerLines, { x: aStartX, y: roofLine.y1 })
|
||
const eLineX = Big(bStartX).minus(wallLine.x2).abs().toNumber()
|
||
newPEnd.x = aStartX
|
||
newPStart.x = roofLine.x1
|
||
|
||
const prevIndex = (index - 1 + sortRoofLines.length) % sortRoofLines.length;
|
||
const nextIndex = (index + 1) % sortRoofLines.length;
|
||
const newLine = sortRoofLines[prevIndex]
|
||
|
||
if (Math.abs(wallBaseLine.x2 - wallLine.x2) < 0.1) {
|
||
if (inLine) {
|
||
if (inLine.y2 < inLine.y1) {
|
||
getAddLine({ x: bStartX, y: wallLine.y1 }, { x: inLine.x2, y: inLine.y2 }, 'pink')
|
||
} else {
|
||
getAddLine({ x: inLine.x1, y: inLine.y1 }, { x: bStartX, y: wallLine.y1 }, 'pink')
|
||
}
|
||
getAddLine({ x: bStartX, y: wallLine.y1 }, { x: roofLine.x2, y: wallLine.y2 }, 'magenta')
|
||
getAddLine({ x: newLine.x2, y: newLine.y2 }, { x: newLine.x1, y: wallLine.y1 }, 'Gray')
|
||
findPoints.push({ x: aStartX, y: newPEnd.y, position: 'bottom_out_end' })
|
||
} else {
|
||
newPEnd.x = roofLine.x2
|
||
}
|
||
} else {
|
||
const cLineX = Big(wallBaseLine.y2).minus(wallLine.y2).abs().toNumber()
|
||
newPEnd.x = Big(newPEnd.x).plus(cLineX).toNumber()
|
||
const inLine = findLineContainingPoint(innerLines, { y: newPEnd.y, x: newPEnd.x })
|
||
if (inLine) {
|
||
if (inLine.y2 < inLine.y1) {
|
||
getAddLine({ y: newPEnd.y, x: newPEnd.x }, { y: inLine.y2, x: inLine.x2 }, 'purple')
|
||
} else {
|
||
getAddLine({ y: inLine.y1, x: inLine.x1 }, { y: newPEnd.y, x: newPEnd.x }, 'purple')
|
||
}
|
||
} else {
|
||
const rLineM = Big(wallBaseLine.y2).minus(roofLine.y2).abs().toNumber()
|
||
newPEnd.x = Big(wallBaseLine.x2).plus(rLineM).toNumber()
|
||
const inLine = findLineContainingPoint(innerLines, { y: newPEnd.y, x: newPEnd.x })
|
||
if (inLine) {
|
||
if (inLine.y1 > inLine.y2) {
|
||
getAddLine({ y: newPEnd.y, x: newPEnd.x }, { y: inLine.y2, x: inLine.x2 }, 'purple')
|
||
} else {
|
||
getAddLine({ y: inLine.y1, x: inLine.x1 }, { y: newPEnd.y, x: newPEnd.x }, 'purple')
|
||
}
|
||
}
|
||
}
|
||
}
|
||
getAddLine(newPStart, newPEnd, 'red')
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
//getAddLine(newPStart, newPEnd, 'red')
|
||
//canvas.remove(roofLine)
|
||
} else {
|
||
getAddLine(roofLine.startPoint, roofLine.endPoint)
|
||
}
|
||
|
||
canvas.renderAll()
|
||
})
|
||
}
|
||
if (!isOverDetected) {
|
||
getMoveUpDownLine()
|
||
}
|
||
|
||
}
|
||
|
||
// ─────────────────────────────────────────────────────────────────────────
|
||
// [B안 cull 2026-04-30] dead-end ridge 제거
|
||
//
|
||
// 배경:
|
||
// 정확한 수치 입력 + 마루이동 누적시 SK builder (StraightSkeleton) 가
|
||
// 1e-4~1e-7 좌표 drift 로 phantom skeleton edge 1개를 emit 하는 케이스.
|
||
// dedup(line 1583) 으로는 단일 entry 라 못 잡음 → 화면에 RG-N 으로 등장.
|
||
//
|
||
// 정의:
|
||
// - lineName === 'ridge' 인데 한쪽 endpoint 가 다른 어떤 innerLine 과도
|
||
// 연결되지 않고(degree=1, 0.1mm round 기준) 지붕 경계 corner 도 아님.
|
||
// - 이 조건을 만족하는 ridge 만 phantom 으로 간주하고 제거.
|
||
//
|
||
// 안전장치:
|
||
// - hip / eaves / roofLine 은 절대 건드리지 않음 (lineName 가드).
|
||
// - roof.points / roof.lines 의 corner 와 1mm 이내면 정상 끝점으로 인정.
|
||
// - 정상 마루이동/gable 처리 결과는 양 끝이 다른 라인 또는 corner 와 연결됨.
|
||
// ─────────────────────────────────────────────────────────────────────────
|
||
;(() => {
|
||
const TOL = 1.0
|
||
const isNear = (a, b) => Math.abs(a.x - b.x) < TOL && Math.abs(a.y - b.y) < TOL
|
||
const roofVertices = []
|
||
;(roof.points || []).forEach((p) => roofVertices.push(p))
|
||
;(roof.lines || []).forEach((l) => {
|
||
roofVertices.push({ x: l.x1, y: l.y1 })
|
||
roofVertices.push({ x: l.x2, y: l.y2 })
|
||
})
|
||
const isRoofVertex = (p) => roofVertices.some((v) => isNear(v, p))
|
||
|
||
const ptKey = (p) => `${Math.round(p.x * 10) / 10},${Math.round(p.y * 10) / 10}`
|
||
|
||
const toRemove = []
|
||
|
||
// 1단계: zero-length 퇴화 ridge 먼저 제거.
|
||
// SK builder drift 로 한 점에서 시작/끝나는 ridge 가 emit 되는 케이스.
|
||
// 이 라인이 살아있으면 그 점의 degree 가 +2 부풀려져 진짜 dead-end ridge 가
|
||
// degree=3 으로 위장되어 2단계 가드를 우회함.
|
||
const ZERO_LEN = 0.5
|
||
innerLines.forEach((line) => {
|
||
if (line.lineName !== 'ridge') return
|
||
const dx = Math.abs(line.x2 - line.x1)
|
||
const dy = Math.abs(line.y2 - line.y1)
|
||
if (dx < ZERO_LEN && dy < ZERO_LEN) toRemove.push(line)
|
||
})
|
||
|
||
// 2단계: zero-length 제외하고 degree 재계산 → dead-end ridge 식별.
|
||
const degree = new Map()
|
||
innerLines.forEach((l) => {
|
||
if (toRemove.includes(l)) return
|
||
const k1 = ptKey({ x: l.x1, y: l.y1 })
|
||
const k2 = ptKey({ x: l.x2, y: l.y2 })
|
||
degree.set(k1, (degree.get(k1) || 0) + 1)
|
||
degree.set(k2, (degree.get(k2) || 0) + 1)
|
||
})
|
||
|
||
innerLines.forEach((line) => {
|
||
if (toRemove.includes(line)) return
|
||
if (line.lineName !== 'ridge') return
|
||
const p1 = { x: line.x1, y: line.y1 }
|
||
const p2 = { x: line.x2, y: line.y2 }
|
||
const p1Dead = degree.get(ptKey(p1)) === 1 && !isRoofVertex(p1)
|
||
const p2Dead = degree.get(ptKey(p2)) === 1 && !isRoofVertex(p2)
|
||
if (p1Dead || p2Dead) toRemove.push(line)
|
||
})
|
||
|
||
if (toRemove.length > 0) {
|
||
if (process.env.NEXT_PUBLIC_RUN_MODE === 'local') {
|
||
// logger.log(
|
||
// `[B안 cull] dead-end ridge 제거 ${toRemove.length}개`,
|
||
// toRemove.map(
|
||
// (l) => `(${Math.round(l.x1)},${Math.round(l.y1)})→(${Math.round(l.x2)},${Math.round(l.y2)})`
|
||
// )
|
||
// )
|
||
}
|
||
toRemove.forEach((line) => {
|
||
canvas.remove(line)
|
||
const idx = innerLines.indexOf(line)
|
||
if (idx >= 0) innerLines.splice(idx, 1)
|
||
})
|
||
canvas.renderAll()
|
||
}
|
||
})()
|
||
|
||
|
||
if (findPoints.length > 0) {
|
||
// 모든 점에 대해 라인 업데이트를 누적
|
||
return findPoints.reduce((innerLines, point) => {
|
||
return updateAndAddLine(innerLines, point);
|
||
}, [...innerLines]);
|
||
|
||
}
|
||
return innerLines;
|
||
|
||
}
|
||
|
||
/**
|
||
* EAVES(처마) Edge를 처리하여 내부 스켈레톤 선을 추가합니다.
|
||
* @param {object} edgeResult - 스켈레톤 Edge 데이터
|
||
* @param {Array} skeletonLines - 스켈레톤 라인 배열
|
||
* @param {Set} processedInnerEdges - 중복 처리를 방지하기 위한 Set
|
||
* @param roof
|
||
* @param pitch
|
||
*/
|
||
function processEavesEdge(roofId, canvas, skeleton, edgeResult, skeletonLines) {
|
||
let roof = canvas?.getObjects().find((object) => object.id === roofId)
|
||
// [1] 벽 객체를 가져옵니다.
|
||
let wall = canvas.getObjects().find((obj) => obj.name === POLYGON_TYPE.WALL && obj.attributes.roofId === roofId);
|
||
|
||
const polygonPoints = edgeResult.Polygon.map(p => ({ x: p.X, y: p.Y }));
|
||
|
||
//처마선인지 확인하고 pitch 대입 각 처마선마다 pitch가 다를수 있음
|
||
const { Begin, End } = edgeResult.Edge;
|
||
// [2] 현재 처리 중인 엣지가 roof.lines의 몇 번째 인덱스인지 찾습니다.
|
||
const roofLineIndex = roof.lines.findIndex(line =>
|
||
line.attributes.type === 'eaves' && isSameLine(Begin.X, Begin.Y, End.X, End.Y, line)
|
||
);
|
||
|
||
let outerLine = null;
|
||
let targetWallId = null;
|
||
|
||
// [3] 인덱스를 통해 매칭되는 벽 라인의 불변 ID(wallId)를 가져옵니다.
|
||
if (roofLineIndex !== -1) {
|
||
outerLine = roof.lines[roofLineIndex];
|
||
if (wall && wall.lines && wall.lines[roofLineIndex]) {
|
||
targetWallId = wall.lines[roofLineIndex].attributes.wallId;
|
||
}
|
||
targetWallId = outerLine.attributes.wallId;
|
||
}
|
||
|
||
if(!outerLine) {
|
||
outerLine = findMatchingLine(edgeResult.Polygon, roof, roof.points);
|
||
// logger.log('Has matching line:', outerLine);
|
||
//if(outerLine === null) return
|
||
}
|
||
// [hip pitch fallback 2026-04-30]
|
||
// outerLine.attributes.pitch 가 없으면 0 → calcLineActualSize2(deg=0) 가
|
||
// calcLinePlaneSize 와 동치(tan0=0) → 모든 hip 의 planeSize === actualSize.
|
||
// 결과: 伏せ図(plane) ↔ 配置面(actual) 메뉴 토글해도 hip 라벨 변화 없음.
|
||
// roof.pitch 폴백(usePolygon.js:1649 패턴) 으로 진짜 pitch 확보.
|
||
let pitch = outerLine?.attributes?.pitch ?? roof?.pitch ?? 0
|
||
|
||
|
||
const convertedPolygon = edgeResult.Polygon?.map(point => ({
|
||
x: typeof point.X === 'number' ? parseFloat(point.X) : 0,
|
||
y: typeof point.Y === 'number' ? parseFloat(point.Y) : 0
|
||
})).filter(point => point.x !== 0 || point.y !== 0) || [];
|
||
|
||
if (convertedPolygon.length > 0) {
|
||
const skeletonPolygon = new QPolygon(convertedPolygon, {
|
||
type: POLYGON_TYPE.ROOF,
|
||
fill: false,
|
||
stroke: 'blue',
|
||
strokeWidth: 4,
|
||
skeletonType: 'polygon',
|
||
polygonName: '',
|
||
parentId: roof.id,
|
||
});
|
||
//canvas?.add(skeletonPolygon)
|
||
//canvas.renderAll()
|
||
}
|
||
|
||
let eavesLines = []
|
||
// 확장된 외곽선 판별용
|
||
const skPts = roof.skeletonPoints || []
|
||
const isSkeletonOuterEdge = (p1, p2, tolerance = 0.5) => {
|
||
for (let si = 0; si < skPts.length; si++) {
|
||
const sp1 = skPts[si]
|
||
const sp2 = skPts[(si + 1) % skPts.length]
|
||
if ((Math.abs(p1.x - sp1.x) < tolerance && Math.abs(p1.y - sp1.y) < tolerance &&
|
||
Math.abs(p2.x - sp2.x) < tolerance && Math.abs(p2.y - sp2.y) < tolerance) ||
|
||
(Math.abs(p1.x - sp2.x) < tolerance && Math.abs(p1.y - sp2.y) < tolerance &&
|
||
Math.abs(p2.x - sp1.x) < tolerance && Math.abs(p2.y - sp1.y) < tolerance)) {
|
||
return true
|
||
}
|
||
}
|
||
return false
|
||
}
|
||
|
||
// logger.log('📐 [processEavesEdge] face 분석:', {
|
||
// hasOuterLine: !!outerLine,
|
||
// outerLineType: outerLine?.attributes?.type,
|
||
// pitch,
|
||
// roofLineIndex,
|
||
// edgeBegin: { x: Math.round(Begin.X), y: Math.round(Begin.Y) },
|
||
// edgeEnd: { x: Math.round(End.X), y: Math.round(End.Y) },
|
||
// polygonPointCount: polygonPoints.length,
|
||
// polygonPoints: polygonPoints.map(p => ({ x: Math.round(p.x), y: Math.round(p.y) })),
|
||
// skPtsCount: skPts.length,
|
||
// })
|
||
|
||
for (let i = 0; i < polygonPoints.length; i++) {
|
||
const p1 = polygonPoints[i];
|
||
const p2 = polygonPoints[(i + 1) % polygonPoints.length];
|
||
|
||
const _isSkipOuter = skPts.length > 0 && isSkeletonOuterEdge(p1, p2)
|
||
|
||
// 확장된 외곽선에 해당하는 edge는 스킵
|
||
if (_isSkipOuter) {
|
||
// logger.log(' [edge', i, '] SKIP(outerEdge)', { p1: {x: Math.round(p1.x), y: Math.round(p1.y)}, p2: {x: Math.round(p2.x), y: Math.round(p2.y)} })
|
||
continue
|
||
}
|
||
|
||
// 지붕 경계선과 교차 확인 및 클리핑
|
||
const clippedLine = clipLineToRoofBoundary(p1, p2, roof.lines, roof.moveSelectLine);
|
||
//logger.log('clipped line', clippedLine.p1, clippedLine.p2);
|
||
const isOuterLine = isOuterEdge(clippedLine.p1, clippedLine.p2, [edgeResult.Edge])
|
||
|
||
// const _dx = Math.abs(clippedLine.p2.x - clippedLine.p1.x)
|
||
// const _dy = Math.abs(clippedLine.p2.y - clippedLine.p1.y)
|
||
// const _lineType = (_dx < 0.5 && _dy > 0.5) ? '⚠️수직' : (_dy < 0.5 && _dx > 0.5) ? '수평' : '대각'
|
||
// logger.log(` [edge ${i}] ${_lineType} ${isOuterLine ? 'OUTER' : 'INNER'} (${Math.round(clippedLine.p1.x)},${Math.round(clippedLine.p1.y)})→(${Math.round(clippedLine.p2.x)},${Math.round(clippedLine.p2.y)}) dx:${Math.round(_dx)} dy:${Math.round(_dy)}`)
|
||
|
||
addRawLine(roof.id, skeletonLines, clippedLine.p1, clippedLine.p2, 'ridge', '#1083E3', 4, pitch, isOuterLine, targetWallId);
|
||
// }
|
||
}
|
||
}
|
||
|
||
|
||
/**
|
||
* 중복 제거 후 막다른 점(dead end)을 가진 라인과 제로 길이 라인을 제거합니다.
|
||
* 정상 스켈레톤의 내부 꼭짓점은 최소 3개 라인이 만나야 하지만,
|
||
* artifact 라인의 끝점은 자기 자신(역방향 포함)과 제로 길이 라인만 연결됩니다.
|
||
*/
|
||
/**
|
||
* dead end(막다른 점) 라인 분석 - 로그만 출력, 삭제하지 않음
|
||
* 조건: degree=1(유니크 라인 1개만 연결) + 지붕 꼭짓점 아님 → 삭제 후보
|
||
*/
|
||
function logDeadEndLines(skeletonLines, roof) {
|
||
const tolerance = 2.0;
|
||
const isNear = (a, b) => Math.abs(a.x - b.x) < tolerance && Math.abs(a.y - b.y) < tolerance;
|
||
|
||
// 지붕 꼭짓점 수집
|
||
const roofVertices = [];
|
||
(roof.skeletonPoints || []).forEach(p => roofVertices.push(p));
|
||
(roof.points || []).forEach(p => roofVertices.push(p));
|
||
(roof.lines || []).forEach(line => {
|
||
roofVertices.push({ x: line.x1, y: line.y1 });
|
||
roofVertices.push({ x: line.x2, y: line.y2 });
|
||
});
|
||
|
||
const isRoofVertex = (p) => roofVertices.some(v => isNear(v, p));
|
||
|
||
// 1. 중복 제거 (방향 무시) + 제로 길이 제거
|
||
const uniqueLines = [];
|
||
const seenKeys = new Set();
|
||
|
||
skeletonLines.forEach(line => {
|
||
const dx = Math.abs(line.p2.x - line.p1.x);
|
||
const dy = Math.abs(line.p2.y - line.p1.y);
|
||
if (dx < 0.5 && dy < 0.5) return;
|
||
|
||
const key = [
|
||
`${line.p1.x.toFixed(1)},${line.p1.y.toFixed(1)}`,
|
||
`${line.p2.x.toFixed(1)},${line.p2.y.toFixed(1)}`
|
||
].sort().join('|');
|
||
|
||
if (!seenKeys.has(key)) {
|
||
seenKeys.add(key);
|
||
uniqueLines.push(line);
|
||
}
|
||
});
|
||
|
||
// 2. 각 꼭짓점의 degree 카운트
|
||
const vertexDegree = new Map();
|
||
const vertexKey = (p) => `${p.x.toFixed(1)},${p.y.toFixed(1)}`;
|
||
|
||
uniqueLines.forEach(line => {
|
||
const k1 = vertexKey(line.p1);
|
||
const k2 = vertexKey(line.p2);
|
||
vertexDegree.set(k1, (vertexDegree.get(k1) || 0) + 1);
|
||
vertexDegree.set(k2, (vertexDegree.get(k2) || 0) + 1);
|
||
});
|
||
|
||
// 3. dead end 꼭짓점: degree=1 이면서 지붕 꼭짓점이 아닌 점
|
||
const deadEndVertices = new Set();
|
||
vertexDegree.forEach((degree, key) => {
|
||
if (degree === 1) {
|
||
const [x, y] = key.split(',').map(Number);
|
||
if (!isRoofVertex({ x, y })) {
|
||
deadEndVertices.add(key);
|
||
}
|
||
}
|
||
});
|
||
|
||
// 4. 로그 출력
|
||
skeletonLines.forEach((line, idx) => {
|
||
const dx = Math.abs(line.p2.x - line.p1.x);
|
||
const dy = Math.abs(line.p2.y - line.p1.y);
|
||
|
||
if (dx < 0.5 && dy < 0.5) {
|
||
// logger.log('⚠️ [logDeadEndLines] 제로 길이:', { idx, p1: { x: Math.round(line.p1.x), y: Math.round(line.p1.y) } });
|
||
return;
|
||
}
|
||
|
||
const k1 = vertexKey(line.p1);
|
||
const k2 = vertexKey(line.p2);
|
||
const p1Dead = deadEndVertices.has(k1);
|
||
const p2Dead = deadEndVertices.has(k2);
|
||
|
||
if (p1Dead || p2Dead) {
|
||
// logger.log('⚠️ [logDeadEndLines] 삭제 후보:', {
|
||
// idx,
|
||
// p1: { x: Math.round(line.p1.x), y: Math.round(line.p1.y), deadEnd: p1Dead, isRoof: isRoofVertex(line.p1) },
|
||
// p2: { x: Math.round(line.p2.x), y: Math.round(line.p2.y), deadEnd: p2Dead, isRoof: isRoofVertex(line.p2) },
|
||
// dx: Math.round(dx), dy: Math.round(dy),
|
||
// });
|
||
}
|
||
});
|
||
|
||
// logger.log(`🔍 [logDeadEndLines] 전체: ${skeletonLines.length}, 유니크: ${uniqueLines.length}, dead end 꼭짓점: ${deadEndVertices.size}`);
|
||
}
|
||
|
||
function findMatchingLine(edgePolygon, roof, roofPoints) {
|
||
const edgePoints = edgePolygon.map(p => ({ x: p.X, y: p.Y }));
|
||
|
||
for (let i = 0; i < edgePoints.length; i++) {
|
||
const p1 = edgePoints[i];
|
||
const p2 = edgePoints[(i + 1) % edgePoints.length];
|
||
|
||
for (let j = 0; j < roofPoints.length; j++) {
|
||
const rp1 = roofPoints[j];
|
||
const rp2 = roofPoints[(j + 1) % roofPoints.length];
|
||
|
||
if ((isSamePoint(p1, rp1) && isSamePoint(p2, rp2)) ||
|
||
(isSamePoint(p1, rp2) && isSamePoint(p2, rp1))) {
|
||
// 매칭되는 라인을 찾아서 반환
|
||
return roof.lines.find(line =>
|
||
(isSamePoint(line.p1, rp1) && isSamePoint(line.p2, rp2)) ||
|
||
(isSamePoint(line.p1, rp2) && isSamePoint(line.p2, rp1))
|
||
);
|
||
}
|
||
}
|
||
}
|
||
return null;
|
||
}
|
||
|
||
|
||
/**
|
||
* GABLE(케라바) Edge를 처리하여 스켈레톤 선을 정리하고 연장합니다.
|
||
* @param {object} edgeResult - 스켈레톤 Edge 데이터
|
||
* @param {Array<QLine>} baseLines - 전체 외벽선 배열
|
||
* @param {Array} skeletonLines - 전체 스켈레톤 라인 배열
|
||
* @param selectBaseLine
|
||
* @param lastSkeletonLines
|
||
*/
|
||
function processGableEdge(edgeResult, baseLines, skeletonLines, selectBaseLine, lastSkeletonLines) {
|
||
const edgePoints = edgeResult.Polygon.map(p => ({ x: p.X, y: p.Y }));
|
||
//const polygons = createPolygonsFromSkeletonLines(skeletonLines, selectBaseLine);
|
||
//logger.log("edgePoints::::::", edgePoints)
|
||
// 1. Initialize processedLines with a deep copy of lastSkeletonLines
|
||
let processedLines = []
|
||
// 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);
|
||
}
|
||
}
|
||
|
||
//logger.log("skeletonLines::::::", skeletonLines)
|
||
//logger.log("lastSkeletonLines", lastSkeletonLines)
|
||
|
||
// 2. Find common lines between skeletonLines and lastSkeletonLines
|
||
skeletonLines.forEach(line => {
|
||
const matchingLine = lastSkeletonLines?.find(pl =>
|
||
pl.p1 && pl.p2 && line.p1 && line.p2 &&
|
||
((Math.abs(pl.p1.x - line.p1.x) < 0.001 && Math.abs(pl.p1.y - line.p1.y) < 0.001 &&
|
||
Math.abs(pl.p2.x - line.p2.x) < 0.001 && Math.abs(pl.p2.y - line.p2.y) < 0.001) ||
|
||
(Math.abs(pl.p1.x - line.p2.x) < 0.001 && Math.abs(pl.p1.y - line.p2.y) < 0.001 &&
|
||
Math.abs(pl.p2.x - line.p1.x) < 0.001 && Math.abs(pl.p2.y - line.p1.y) < 0.001))
|
||
);
|
||
|
||
if (matchingLine) {
|
||
processedLines.push({...matchingLine});
|
||
}
|
||
});
|
||
|
||
// // 3. Remove lines that are part of the gable edge
|
||
// processedLines = processedLines.filter(line => {
|
||
// 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);
|
||
//
|
||
// return !isEdgeLine;
|
||
// });
|
||
|
||
//logger.log("skeletonLines::::::", skeletonLines);
|
||
//logger.log("lastSkeletonLines", lastSkeletonLines);
|
||
//logger.log("processedLines after filtering", processedLines);
|
||
|
||
return processedLines;
|
||
|
||
}
|
||
|
||
|
||
// --- 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}>} 보정된 스켈레톤 입력 포인트 (실패 시 원본 반환)
|
||
*/
|
||
/**
|
||
* calcOverCorrectedPoints 의 안전 래퍼.
|
||
*
|
||
* 문제:
|
||
* 기존 calcOverCorrectedPoints 는 "원본(roof.points) 대비 이동"으로 moved 판정을
|
||
* 하기 때문에, 누적 이동 시(1차 이동 후 2차 이동) 1차에서 이미 확정된 점까지도
|
||
* moved=true 로 보고 clampToFixed 대상으로 잡아 원본으로 되돌려 버린다.
|
||
*
|
||
* 처리:
|
||
* 1) lastPoints 가 있으면 "이번 이동에서 실제로 변한 인덱스(changedNow)" 만 추림.
|
||
* 2) changedNow=false 인 인덱스는 "원본=lastPoints(직전 확정 상태)" 로 간주한 virtualOrig 구성.
|
||
* → 해당 인덱스는 기존 calcOverCorrectedPoints 내부에서 moved=false 로 판정되어
|
||
* clampToFixed 대상에서 제외됨 (= 1차 이동 결과 보존).
|
||
* 3) changedNow=true 인 인덱스만 원본(origPoints) 대비로 오버 보정 그대로 수행.
|
||
* 4) lastPoints 가 없거나 길이가 안 맞으면 기존 함수를 그대로 호출 (동작 변화 없음).
|
||
*
|
||
* 기존 calcOverCorrectedPoints 는 수정하지 않는다.
|
||
*
|
||
* @param {Array<{x,y}>} points - changRoofLinePoints (수정 X)
|
||
* @param {Array<{x,y}>} origPoints - roof.points (원본)
|
||
* @param {Array<{x,y}>|null} lastPoints - 직전 SK 확정 상태 (canvas.skeleton.lastPoints)
|
||
* @returns {Array<{x,y}>}
|
||
*/
|
||
const calcOverCorrectedPointsSafe = (points, origPoints, lastPoints) => {
|
||
if (!Array.isArray(lastPoints) || !Array.isArray(origPoints)) {
|
||
return calcOverCorrectedPoints(points, origPoints)
|
||
}
|
||
if (lastPoints.length !== origPoints.length || points.length !== origPoints.length) {
|
||
return calcOverCorrectedPoints(points, origPoints)
|
||
}
|
||
|
||
const tol = 1
|
||
const changedNow = points.map((p, i) => {
|
||
const lp = lastPoints[i]
|
||
if (!lp) return true
|
||
return Math.abs(p.x - lp.x) > tol || Math.abs(p.y - lp.y) > tol
|
||
})
|
||
|
||
// 이번에 아무것도 안 바뀌었으면 그대로 반환
|
||
if (!changedNow.some(Boolean)) return points
|
||
|
||
// 이전 이동에서 확정된(이번엔 그대로) 점들은 lastPoints 를 원본으로 간주
|
||
const virtualOrig = origPoints.map((op, i) =>
|
||
changedNow[i] ? op : (lastPoints[i] ?? op)
|
||
)
|
||
|
||
// logger.log('[calcOverCorrectedPointsSafe] changedNow:',
|
||
// changedNow.map((v, i) => v ? i : null).filter(v => v !== null))
|
||
|
||
return calcOverCorrectedPoints(points, virtualOrig)
|
||
}
|
||
|
||
|
||
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) {
|
||
// logger.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) {
|
||
// logger.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
|
||
logger.warn('[SK_OVER] calcOverCorrectedPoints: 보정 실패 → 원본 반환')
|
||
return points
|
||
}
|
||
|
||
/**
|
||
* 두 점으로 이루어진 선분이 외벽선인지 확인합니다.
|
||
* @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 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;
|
||
});
|
||
}
|
||
|
||
/**
|
||
* 스켈레톤 라인 배열에 새로운 라인을 추가합니다. (중복 방지)
|
||
* @param id
|
||
* @param {Array} skeletonLines - 스켈레톤 라인 배열
|
||
* @param {object} p1 - 시작점
|
||
* @param {object} p2 - 끝점
|
||
* @param {string} lineType - 라인 타입
|
||
* @param {string} color - 색상
|
||
* @param {number} width - 두께
|
||
* @param pitch
|
||
* @param isOuterLine
|
||
*/
|
||
function addRawLine(id, skeletonLines, p1, p2, lineType, color, width, pitch, isOuterLine, wallLineId) {
|
||
// 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 currentDegree = getDegreeByChon(pitch)
|
||
const dx = Math.abs(p2.x - p1.x);
|
||
const dy = Math.abs(p2.y - p1.y);
|
||
// [HIP/RIDGE 임계 2026-04-30] 0.1 → 5.0 상향.
|
||
// 배경: 벽라인 이동 누적 drift 로 수직/수평 ridge 의 한쪽 축에 0.1mm 초과
|
||
// drift 가 생기면 즉시 HIP 로 재분류되던 케이스 수정.
|
||
// 진짜 hip 은 dx, dy 모두 수십 mm 이상 → 5mm 임계로 영향 없음.
|
||
// drift ridge 는 작은 축이 ~수 mm 이내 → ridge 유지.
|
||
const isDiagonal = dx > 5.0 && dy > 5.0;
|
||
const normalizedType = isDiagonal ? LINE_TYPE.SUBLINE.HIP : lineType;
|
||
|
||
// Count existing HIP lines
|
||
const existingEavesCount = skeletonLines.filter(line =>
|
||
line.lineName === LINE_TYPE.SUBLINE.RIDGE
|
||
).length;
|
||
|
||
// If this is a HIP line, its index will be the existing count
|
||
const eavesIndex = normalizedType === LINE_TYPE.SUBLINE.RIDGE ? existingEavesCount : undefined;
|
||
|
||
const newLine = {
|
||
p1,
|
||
p2,
|
||
attributes: {
|
||
roofId: id,
|
||
actualSize: (isDiagonal) ? calcLineActualSize2(
|
||
{
|
||
x1: p1.x,
|
||
y1: p1.y,
|
||
x2: p2.x,
|
||
y2: p2.y
|
||
},
|
||
currentDegree
|
||
) : calcLinePlaneSize({ x1: p1.x, y1: p1.y, x2: p2.x, y2: p2.y }),
|
||
type: normalizedType,
|
||
planeSize: calcLinePlaneSize({ x1: p1.x, y1: p1.y, x2: p2.x, y2: p2.y }),
|
||
isRidge: normalizedType === LINE_TYPE.SUBLINE.RIDGE,
|
||
isOuterEdge: isOuterLine,
|
||
pitch: pitch,
|
||
wallLineId: wallLineId, // [5] attributes에 wallId 저장 (이 정보가 최종 roofLines에 들어갑니다)
|
||
...(eavesIndex !== undefined && { eavesIndex })
|
||
},
|
||
lineStyle: { color, width },
|
||
};
|
||
|
||
skeletonLines.push(newLine);
|
||
//logger.log('skeletonLines', skeletonLines);
|
||
}
|
||
|
||
/**
|
||
* 폴리곤 좌표를 스켈레톤 생성에 적합하게 전처리합니다 (중복 제거, 시계 방향 정렬).
|
||
* @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 coordinates.reverse();
|
||
};
|
||
|
||
/**
|
||
* 스켈레톤 Edge와 외벽선이 동일한지 확인합니다.
|
||
* @returns {boolean} 동일 여부
|
||
*/
|
||
const isSameLine = (edgeStartX, edgeStartY, edgeEndX, edgeEndY, 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;
|
||
};
|
||
|
||
// --- Disconnected Line Processing ---
|
||
|
||
/**
|
||
* 라인들이 반시계 방향이 되도록 정렬하고, 왼쪽 상단에서 시작하는 새 배열 반환
|
||
* @param {Array} lines - x1, y1, x2, y2 속성을 가진 라인 객체 배열
|
||
* @returns {Array} 반시계 방향으로 정렬된 새 라인 배열
|
||
*/
|
||
export function ensureCounterClockwiseLines(lines) {
|
||
if (!lines || lines.length < 3) return [...(lines || [])];
|
||
|
||
// 1. 모든 점을 연결 그래프로 구성
|
||
// [graph drift fix 2026-05-06]
|
||
// 좌표 0.1 단위 round 로 부동소수점 drift 흡수 (caller `_keyOfEdge` 정책과 통일).
|
||
// raw 좌표 그대로 키를 만들면 drift (e.g. 100.0 vs 100.0000001) 로 같은 점이 다른 노드가 되어
|
||
// graph 가 끊어짐 → traversal 중도 break → 결과 길이 < 입력 길이 → sortRoofLines 인덱스 어긋남
|
||
// (간헐 발생, 사용자가 다시 그리면 정상화되던 증상의 근본 원인).
|
||
const _r = (v) => Math.round(v * 10) / 10;
|
||
const graph = new Map();
|
||
|
||
// 각 점에서 연결된 점들을 저장
|
||
lines.forEach(line => {
|
||
const x1r = _r(line.x1), y1r = _r(line.y1);
|
||
const x2r = _r(line.x2), y2r = _r(line.y2);
|
||
const p1 = `${x1r},${y1r}`;
|
||
const p2 = `${x2r},${y2r}`;
|
||
|
||
if (!graph.has(p1)) graph.set(p1, []);
|
||
if (!graph.has(p2)) graph.set(p2, []);
|
||
|
||
// 양방향 연결 (이웃 좌표도 rounded — 후속 `${n.x},${n.y}` 키가 graph 키와 일치)
|
||
graph.get(p1).push({ x: x2r, y: y2r, line });
|
||
graph.get(p2).push({ x: x1r, y: y1r, line });
|
||
});
|
||
|
||
// 2. 왼쪽 상단 점 찾기
|
||
let startPoint = null;
|
||
let minY = Infinity;
|
||
let minX = Infinity;
|
||
|
||
for (const [pointStr] of graph) {
|
||
const [x, y] = pointStr.split(',').map(Number);
|
||
if (y < minY || (y === minY && x < minX)) {
|
||
minY = y;
|
||
minX = x;
|
||
startPoint = { x, y };
|
||
}
|
||
}
|
||
|
||
if (!startPoint) return [...lines];
|
||
|
||
// 3. 점들을 순회하며 라인 구성
|
||
const visited = new Set();
|
||
const result = [];
|
||
let current = `${startPoint.x},${startPoint.y}`;
|
||
let prev = null;
|
||
|
||
while (true) {
|
||
if (visited.has(current)) break;
|
||
visited.add(current);
|
||
|
||
const neighbors = graph.get(current) || [];
|
||
if (neighbors.length === 0) break;
|
||
|
||
// 이전 점 제외
|
||
const nextPoints = neighbors.filter(n =>
|
||
!prev || `${n.x},${n.y}` !== `${prev.x},${prev.y}`
|
||
);
|
||
|
||
if (nextPoints.length === 0) break;
|
||
|
||
// 각도가 가장 작은(반시계 방향) 이웃 선택
|
||
const [cx, cy] = current.split(',').map(Number);
|
||
const next = nextPoints.reduce((best, curr) => {
|
||
const angleBest = Math.atan2(best.y - cy, best.x - cx);
|
||
const angleCurr = Math.atan2(curr.y - cy, curr.x - cx);
|
||
return angleCurr > angleBest ? curr : best;
|
||
}, nextPoints[0]);
|
||
|
||
// 라인 추가 (방향 유지)
|
||
// [graph drift fix 2026-05-06] line.x1/y1 은 raw, next.x/y 는 rounded → 0.05 (round 절반) 임계 approx 비교.
|
||
// 정확비교 시 raw vs rounded 불일치로 isReversed 가 항상 true 가 될 위험 차단.
|
||
const line = next.line;
|
||
const isReversed = (Math.abs(line.x1 - next.x) > 0.05 || Math.abs(line.y1 - next.y) > 0.05);
|
||
|
||
result.push({
|
||
...line,
|
||
x1: isReversed ? line.x2 : line.x1,
|
||
y1: isReversed ? line.y2 : line.y1,
|
||
x2: isReversed ? line.x1 : line.x2,
|
||
y2: isReversed ? line.y1 : line.y2,
|
||
idx: result.length
|
||
});
|
||
|
||
prev = { x: cx, y: cy };
|
||
current = `${next.x},${next.y}`;
|
||
}
|
||
|
||
// 4. 시계 방향이면 뒤집기
|
||
let area = 0;
|
||
for (let i = 0; i < result.length; i++) {
|
||
const current = result[i];
|
||
const next = result[(i + 1) % result.length];
|
||
area += (next.x1 - current.x1) * (next.y1 + current.y1);
|
||
}
|
||
|
||
if (area > 0) {
|
||
return result.reverse().map((line, idx) => ({
|
||
...line,
|
||
x1: line.x2,
|
||
y1: line.y2,
|
||
x2: line.x1,
|
||
y2: line.y1,
|
||
idx
|
||
}));
|
||
}
|
||
|
||
return result;
|
||
}
|
||
|
||
|
||
|
||
/**
|
||
* 점을 선분에 투영한 점의 좌표를 반환합니다.
|
||
* @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;
|
||
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 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 (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<QLine>} baseLines - 외벽선 배열
|
||
* @param {Array} skeletonLines - 스켈레톤 라인 배열
|
||
* @param {number} excludeIndex - 검사에서 제외할 현재 라인의 인덱스
|
||
* @returns {object|null} 가장 가까운 교차점 정보 또는 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 };
|
||
let closestHit = null;
|
||
|
||
const checkHit = (hit) => {
|
||
if (hit && hit.t > len - 0.1) { // 원래 선분의 끝점(p1) 너머에서 교차하는지 확인
|
||
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);
|
||
});
|
||
}
|
||
|
||
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;
|
||
}
|
||
|
||
/**
|
||
* 연결이 끊어진 스켈레톤 라인들을 찾아 연장 정보를 계산합니다.
|
||
* @param {Array} skeletonLines - 스켈레톤 라인 배열
|
||
* @param {Array<QLine>} baseLines - 외벽선 배열
|
||
* @returns {object} 끊어진 라인 정보가 담긴 객체
|
||
*/
|
||
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 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) => {
|
||
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, p2Connected };
|
||
};
|
||
|
||
skeletonLines.forEach((line, index) => {
|
||
const { p1Connected, p2Connected } = isConnected(line, index);
|
||
if (p1Connected && p2Connected) return;
|
||
|
||
let extendedLine = null;
|
||
if (!p1Connected) {
|
||
extendedLine = extendFromP2TowardP1(line.p1, line.p2, baseLines, skeletonLines, index);
|
||
|
||
// [수정] 1차 연장 시도(Raycast) 실패 시, 수직 투영(Projection) 대신 모든 선분과의 교차점을 찾는 방식으로 변경
|
||
if (!extendedLine) {
|
||
let closestIntersection = null;
|
||
let minDistance = Infinity;
|
||
|
||
// 모든 외벽선과 다른 내부선을 타겟으로 설정
|
||
const allTargetLines = [
|
||
...baseLines.map(l => ({ p1: {x: l.x1, y: l.y1}, p2: {x: l.x2, y: l.y2} })),
|
||
...skeletonLines.filter((_, i) => i !== index)
|
||
];
|
||
|
||
allTargetLines.forEach(targetLine => {
|
||
// 무한 직선 간의 교차점을 찾음
|
||
const intersection = getInfiniteLineIntersection(line.p1, line.p2, targetLine.p1, targetLine.p2);
|
||
|
||
// 교차점이 존재하고, 타겟 '선분' 위에 있는지 확인
|
||
if (intersection && isPointOnSegmentForExtension(intersection, targetLine.p1, targetLine.p2)) {
|
||
// 연장 방향이 올바른지 확인 (뒤로 가지 않도록)
|
||
const lineVec = { x: line.p1.x - line.p2.x, y: line.p1.y - line.p2.y };
|
||
const intersectVec = { x: intersection.x - line.p1.x, y: intersection.y - line.p1.y };
|
||
const dotProduct = lineVec.x * intersectVec.x + lineVec.y * intersectVec.y;
|
||
|
||
if (dotProduct >= -1e-6) { // 교차점이 p1 기준으로 '앞'에 있을 경우
|
||
const dist = Math.sqrt(Math.pow(line.p1.x - intersection.x, 2) + Math.pow(line.p1.y - intersection.y, 2));
|
||
if (dist > 0.1 && dist < minDistance) { // 자기 자신이 아니고, 가장 가까운 교차점 갱신
|
||
minDistance = dist;
|
||
closestIntersection = intersection;
|
||
}
|
||
}
|
||
}
|
||
});
|
||
|
||
if (closestIntersection) {
|
||
extendedLine = { point: closestIntersection };
|
||
}
|
||
}
|
||
} else if (!p2Connected) {
|
||
extendedLine = extendFromP2TowardP1(line.p2, line.p1, baseLines, skeletonLines, index);
|
||
|
||
// [수정] 1차 연장 시도(Raycast) 실패 시, 수직 투영(Projection) 대신 모든 선분과의 교차점을 찾는 방식으로 변경
|
||
if (!extendedLine) {
|
||
let closestIntersection = null;
|
||
let minDistance = Infinity;
|
||
|
||
// 모든 외벽선과 다른 내부선을 타겟으로 설정
|
||
const allTargetLines = [
|
||
...baseLines.map(l => ({ p1: {x: l.x1, y: l.y1}, p2: {x: l.x2, y: l.y2} })),
|
||
...skeletonLines.filter((_, i) => i !== index)
|
||
];
|
||
|
||
allTargetLines.forEach(targetLine => {
|
||
// 무한 직선 간의 교차점을 찾음
|
||
const intersection = getInfiniteLineIntersection(line.p2, line.p1, targetLine.p1, targetLine.p2);
|
||
|
||
// 교차점이 존재하고, 타겟 '선분' 위에 있는지 확인
|
||
if (intersection && isPointOnSegmentForExtension(intersection, targetLine.p1, targetLine.p2)) {
|
||
// 연장 방향이 올바른지 확인 (뒤로 가지 않도록)
|
||
const lineVec = { x: line.p2.x - line.p1.x, y: line.p2.y - line.p1.y };
|
||
const intersectVec = { x: intersection.x - line.p2.x, y: intersection.y - line.p2.y };
|
||
const dotProduct = lineVec.x * intersectVec.x + lineVec.y * intersectVec.y;
|
||
|
||
if (dotProduct >= -1e-6) { // 교차점이 p2 기준으로 '앞'에 있을 경우
|
||
const dist = Math.sqrt(Math.pow(line.p2.x - intersection.x, 2) + Math.pow(line.p2.y - intersection.y, 2));
|
||
if (dist > 0.1 && dist < minDistance) { // 자기 자신이 아니고, 가장 가까운 교차점 갱신
|
||
minDistance = dist;
|
||
closestIntersection = intersection;
|
||
}
|
||
}
|
||
}
|
||
});
|
||
|
||
if (closestIntersection) {
|
||
extendedLine = { point: closestIntersection };
|
||
}
|
||
}
|
||
}
|
||
|
||
disconnectedLines.push({ line, index, p1Connected, p2Connected, extendedLine });
|
||
});
|
||
|
||
return { disconnectedLines };
|
||
};
|
||
|
||
/**
|
||
* 연장된 스켈레톤 라인들이 서로 교차하는 경우, 교차점에서 잘라냅니다.
|
||
* 이 함수는 skeletonLines 배열의 요소를 직접 수정하여 접점에서 선이 멈추도록 합니다.
|
||
* @param {Array} skeletonLines - (수정될) 전체 스켈레톤 라인 배열
|
||
* @param {Array} disconnectedLines - 연장 정보가 담긴 배열
|
||
*/
|
||
const trimIntersectingExtendedLines = (skeletonLines, disconnectedLines) => {
|
||
// disconnectedLines에는 연장된 선들의 정보가 들어있음
|
||
for (let i = 0; i < disconnectedLines.length; i++) {
|
||
for (let j = i + 1; j < disconnectedLines.length; j++) {
|
||
const dLine1 = disconnectedLines[i];
|
||
const dLine2 = disconnectedLines[j];
|
||
|
||
// skeletonLines 배열에서 직접 참조를 가져오므로, 여기서 line1, line2를 수정하면
|
||
// 원본 skeletonLines 배열의 내용이 변경됩니다.
|
||
const line1 = skeletonLines[dLine1.index];
|
||
const line2 = skeletonLines[dLine2.index];
|
||
|
||
if(!line1 || !line2) continue;
|
||
|
||
// 두 연장된 선분이 교차하는지 확인
|
||
const intersection = getLineIntersection(line1.p1, line1.p2, line2.p1, line2.p2);
|
||
|
||
if (intersection) {
|
||
// 교차점이 있다면, 각 선의 연장된 끝점을 교차점으로 업데이트합니다.
|
||
// 이 변경 사항은 skeletonLines 배열에 바로 반영됩니다.
|
||
if (!dLine1.p1Connected) { // p1이 연장된 점이었으면
|
||
line1.p1 = intersection;
|
||
} else { // p2가 연장된 점이었으면
|
||
line1.p2 = intersection;
|
||
}
|
||
|
||
if (!dLine2.p1Connected) { // p1이 연장된 점이었으면
|
||
line2.p1 = intersection;
|
||
} else { // p2가 연장된 점이었으면
|
||
line2.p2 = intersection;
|
||
}
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
/**
|
||
* 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
|
||
);
|
||
});
|
||
|
||
};
|
||
|
||
/**
|
||
* 두 무한 직선의 교차점을 찾습니다. (선분X)
|
||
* @param {object} p1 - 직선1의 점1
|
||
* @param {object} p2 - 직선1의 점2
|
||
* @param {object} p3 - 직선2의 점1
|
||
* @param {object} p4 - 직선2의 점2
|
||
* @returns {object|null} 교차점 좌표 또는 null (평행/동일선)
|
||
*/
|
||
const getInfiniteLineIntersection = (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;
|
||
|
||
return {
|
||
x: x1 + t * (x2 - x1),
|
||
y: y1 + t * (y2 - y1)
|
||
};
|
||
};
|
||
|
||
/**
|
||
* 점이 선분 위에 있는지 확인합니다. (연장 로직용)
|
||
* @param {object} point - 확인할 점
|
||
* @param {object} segStart - 선분 시작점
|
||
* @param {object} segEnd - 선분 끝점
|
||
* @param {number} tolerance - 허용 오차
|
||
* @returns {boolean} 선분 위 여부
|
||
*/
|
||
const isPointOnSegmentForExtension = (point, segStart, segEnd, tolerance = 0.1) => {
|
||
const dist = Math.sqrt(Math.pow(segEnd.x - segStart.x, 2) + Math.pow(segEnd.y - segStart.y, 2));
|
||
const dist1 = Math.sqrt(Math.pow(point.x - segStart.x, 2) + Math.pow(point.y - segStart.y, 2));
|
||
const dist2 = Math.sqrt(Math.pow(point.x - segEnd.x, 2) + Math.pow(point.y - segEnd.y, 2));
|
||
return Math.abs(dist - (dist1 + dist2)) < tolerance;
|
||
};
|
||
|
||
/**
|
||
* 스켈레톤 라인들 간의 모든 교차점을 찾습니다.
|
||
* @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,
|
||
preprocessPolygonCoordinates,
|
||
findOppositeLine,
|
||
createOrderedBasePoints,
|
||
createInnerLinesFromSkeleton
|
||
};
|
||
|
||
|
||
|
||
|
||
/**
|
||
* Finds the opposite line in a polygon based on the given line
|
||
* @param {Array} edges - The polygon edges from canvas.skeleton.Edges
|
||
* @param {Object} startPoint - The start point of the line to find opposite for
|
||
* @param {Object} endPoint - The end point of the line to find opposite for
|
||
* @param targetPosition
|
||
* @returns {Object|null} The opposite line if found, null otherwise
|
||
*/
|
||
function findOppositeLine(edges, startPoint, endPoint, points) {
|
||
const result = [];
|
||
// 1. 다각형 찾기
|
||
const polygons = findPolygonsContainingLine(edges, startPoint, endPoint);
|
||
if (polygons.length === 0) return null;
|
||
|
||
const referenceSlope = calculateSlope(startPoint, endPoint);
|
||
|
||
// 각 다각형에 대해 처리
|
||
for (const polygon of polygons) {
|
||
// 2. 기준 선분의 인덱스 찾기
|
||
|
||
let baseIndex = -1;
|
||
for (let i = 0; i < polygon.length; i++) {
|
||
const p1 = { x: polygon[i].X, y: polygon[i].Y };
|
||
const p2 = {
|
||
x: polygon[(i + 1) % polygon.length].X,
|
||
y: polygon[(i + 1) % polygon.length].Y
|
||
};
|
||
|
||
|
||
|
||
|
||
if ((isSamePoint(p1, startPoint) && isSamePoint(p2, endPoint)) ||
|
||
(isSamePoint(p1, endPoint) && isSamePoint(p2, startPoint))) {
|
||
baseIndex = i;
|
||
break;
|
||
}
|
||
}
|
||
|
||
if (baseIndex === -1) continue; // 현재 다각형에서 기준 선분을 찾지 못한 경우
|
||
|
||
// 3. 다각형의 각 선분을 순회하면서 평행한 선분 찾기
|
||
const polyLength = polygon.length;
|
||
for (let i = 0; i < polyLength; i++) {
|
||
if (i === baseIndex) continue; // 기준 선분은 제외
|
||
|
||
const p1 = { x: polygon[i].X, y: polygon[i].Y };
|
||
const p2 = {
|
||
x: polygon[(i + 1) % polyLength].X,
|
||
y: polygon[(i + 1) % polyLength].Y
|
||
};
|
||
|
||
|
||
const p1Exist = points.some(p =>
|
||
Math.abs(p.x - p1.x) < 0.0001 && Math.abs(p.y - p1.y) < 0.0001
|
||
);
|
||
|
||
const p2Exist = points.some(p =>
|
||
Math.abs(p.x - p2.x) < 0.0001 && Math.abs(p.y - p2.y) < 0.0001
|
||
);
|
||
|
||
if(p1Exist && p2Exist){
|
||
const position = getLinePosition(
|
||
{ start: p1, end: p2 },
|
||
{ start: startPoint, end: endPoint }
|
||
);
|
||
result.push({
|
||
start: p1,
|
||
end: p2,
|
||
position: position,
|
||
polygon: polygon
|
||
});
|
||
}
|
||
|
||
|
||
}
|
||
}
|
||
|
||
return result.length > 0 ? result:[];
|
||
|
||
}
|
||
|
||
function getLinePosition(line, referenceLine) {
|
||
// 대상선의 중점
|
||
const lineMidX = (line.start.x + line.end.x) / 2;
|
||
const lineMidY = (line.start.y + line.end.y) / 2;
|
||
|
||
// 참조선의 중점
|
||
const refMidX = (referenceLine.start.x + referenceLine.end.x) / 2;
|
||
const refMidY = (referenceLine.start.y + referenceLine.end.y) / 2;
|
||
|
||
// 단순히 좌표 차이로 판단
|
||
const deltaX = lineMidX - refMidX;
|
||
const deltaY = lineMidY - refMidY;
|
||
|
||
// 참조선의 기울기
|
||
const refDeltaX = referenceLine.end.x - referenceLine.start.x;
|
||
const refDeltaY = referenceLine.end.y - referenceLine.start.y;
|
||
|
||
// 참조선이 더 수평인지 수직인지 판단
|
||
if (Math.abs(refDeltaX) > Math.abs(refDeltaY)) {
|
||
// 수평선에 가까운 경우 - Y 좌표로 판단
|
||
return deltaY > 0 ? 'bottom' : 'top';
|
||
} else {
|
||
// 수직선에 가까운 경우 - X 좌표로 판단
|
||
return deltaX > 0 ? 'right' : 'left';
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Helper function to find if two points are the same within a tolerance
|
||
*/
|
||
function isSamePoint(p1, p2, tolerance = 0.1) {
|
||
return Math.abs(p1.x - p2.x) < tolerance && Math.abs(p1.y - p2.y) < tolerance;
|
||
}
|
||
|
||
function isSameLine2(line1, line2, tolerance = 0.1) {
|
||
return (
|
||
Math.abs(line1.x1 - line2.x1) < tolerance &&
|
||
Math.abs(line1.y1 - line2.y1) < tolerance &&
|
||
Math.abs(line1.x2 - line2.x2) < tolerance &&
|
||
Math.abs(line1.y2 - line2.y2) < tolerance
|
||
);
|
||
}
|
||
// 두 점을 지나는 직선의 기울기 계산
|
||
function calculateSlope(p1, p2) {
|
||
// 수직선인 경우 (기울기 무한대)
|
||
if (p1.x === p2.x) return Infinity;
|
||
return (p2.y - p1.y) / (p2.x - p1.x);
|
||
}
|
||
|
||
|
||
/**
|
||
* Helper function to find the polygon containing the given line
|
||
*/
|
||
function findPolygonsContainingLine(edges, p1, p2) {
|
||
const polygons = [];
|
||
for (const edge of edges) {
|
||
const polygon = edge.Polygon;
|
||
for (let i = 0; i < polygon.length; i++) {
|
||
const ep1 = { x: polygon[i].X, y: polygon[i].Y };
|
||
const ep2 = {
|
||
x: polygon[(i + 1) % polygon.length].X,
|
||
y: polygon[(i + 1) % polygon.length].Y
|
||
};
|
||
|
||
if ((isSamePoint(ep1, p1) && isSamePoint(ep2, p2)) ||
|
||
(isSamePoint(ep1, p2) && isSamePoint(ep2, p1))) {
|
||
polygons.push(polygon);
|
||
break; // 이 다각형에 대한 검사 완료
|
||
}
|
||
}
|
||
}
|
||
return polygons; // 일치하는 모든 다각형 반환
|
||
}
|
||
|
||
/**
|
||
* roof.lines로 만들어진 다각형 내부에만 선분이 존재하도록 클리핑합니다.
|
||
* @param {Object} p1 - 선분의 시작점 {x, y}
|
||
* @param {Object} p2 - 선분의 끝점 {x, y}
|
||
* @param {Array} roofLines - 지붕 경계선 배열 (QLine 객체의 배열)
|
||
* @param skeletonLines
|
||
* @returns {Object} {p1: {x, y}, p2: {x, y}} - 다각형 내부로 클리핑된 선분
|
||
*/
|
||
function clipLineToRoofBoundary(p1, p2, roofLines, selectLine) {
|
||
if (!roofLines || !roofLines.length) {
|
||
return { p1: { ...p1 }, p2: { ...p2 } };
|
||
}
|
||
|
||
const dx = Math.abs(p2.x - p1.x);
|
||
const dy = Math.abs(p2.y - p1.y);
|
||
const isDiagonal = dx > 0.5 && dy > 0.5;
|
||
|
||
// 기본값으로 원본 좌표 설정
|
||
let clippedP1 = { x: p1.x, y: p1.y };
|
||
let clippedP2 = { x: p2.x, y: p2.y };
|
||
|
||
// p1이 다각형 내부에 있는지 확인
|
||
const p1Inside = isPointInsidePolygon(p1, roofLines);
|
||
|
||
// p2가 다각형 내부에 있는지 확인
|
||
const p2Inside = isPointInsidePolygon(p2, roofLines);
|
||
|
||
//logger.log('p1Inside:', p1Inside, 'p2Inside:', p2Inside);
|
||
|
||
// 두 점 모두 내부에 있으면 그대로 반환
|
||
if (p1Inside && p2Inside) {
|
||
if(!selectLine || isDiagonal){
|
||
return { p1: clippedP1, p2: clippedP2 };
|
||
}
|
||
//logger.log('평행선::', clippedP1, clippedP2)
|
||
return { p1: clippedP1, p2: clippedP2 };
|
||
}
|
||
|
||
// 선분과 다각형 경계선의 교차점들을 찾음
|
||
const intersections = [];
|
||
|
||
for (const line of roofLines) {
|
||
const lineP1 = { x: line.x1, y: line.y1 };
|
||
const lineP2 = { x: line.x2, y: line.y2 };
|
||
|
||
const intersection = getLineIntersection(p1, p2, lineP1, lineP2);
|
||
|
||
if (intersection) {
|
||
// 교차점이 선분 위에 있는지 확인
|
||
const t = getParameterT(p1, p2, intersection);
|
||
if (t >= 0 && t <= 1) {
|
||
intersections.push({
|
||
point: intersection,
|
||
t: t
|
||
});
|
||
}
|
||
}
|
||
}
|
||
|
||
//logger.log('Found intersections:', intersections.length);
|
||
|
||
// 교차점들을 t 값으로 정렬
|
||
intersections.sort((a, b) => a.t - b.t);
|
||
|
||
if (!p1Inside && !p2Inside) {
|
||
// 두 점 모두 외부에 있는 경우
|
||
if (intersections.length >= 2) {
|
||
//logger.log('Both outside, using intersection points');
|
||
clippedP1.x = intersections[0].point.x;
|
||
clippedP1.y = intersections[0].point.y;
|
||
clippedP2.x = intersections[1].point.x;
|
||
clippedP2.y = intersections[1].point.y;
|
||
} else {
|
||
//logger.log('Both outside, no valid intersections - returning original');
|
||
// 교차점이 충분하지 않으면 원본 반환
|
||
return { p1: clippedP1, p2: clippedP2 };
|
||
}
|
||
} else if (!p1Inside && p2Inside) {
|
||
// p1이 외부, p2가 내부
|
||
if (intersections.length > 0) {
|
||
//logger.log('p1 outside, p2 inside - moving p1 to intersection');
|
||
clippedP1.x = intersections[0].point.x;
|
||
clippedP1.y = intersections[0].point.y;
|
||
// p2는 이미 내부에 있으므로 원본 유지
|
||
clippedP2.x = p2.x;
|
||
clippedP2.y = p2.y;
|
||
}
|
||
} else if (p1Inside && !p2Inside) {
|
||
// p1이 내부, p2가 외부
|
||
if (intersections.length > 0) {
|
||
//logger.log('p1 inside, p2 outside - moving p2 to intersection');
|
||
// p1은 이미 내부에 있으므로 원본 유지
|
||
clippedP1.x = p1.x;
|
||
clippedP1.y = p1.y;
|
||
clippedP2.x = intersections[0].point.x;
|
||
clippedP2.y = intersections[0].point.y;
|
||
}
|
||
}
|
||
|
||
return { p1: clippedP1, p2: clippedP2 };
|
||
}
|
||
|
||
|
||
function isPointInsidePolygon(point, roofLines) {
|
||
// 1. 먼저 경계선 위에 있는지 확인 (방향 무관)
|
||
if (isOnBoundaryDirectionIndependent(point, roofLines)) {
|
||
return true;
|
||
}
|
||
|
||
// 2. 내부/외부 판단 (기존 알고리즘)
|
||
let winding = 0;
|
||
const x = point.x;
|
||
const y = point.y;
|
||
|
||
for (let i = 0; i < roofLines.length; i++) {
|
||
const line = roofLines[i];
|
||
const x1 = line.x1, y1 = line.y1;
|
||
const x2 = line.x2, y2 = line.y2;
|
||
|
||
if (y1 <= y) {
|
||
if (y2 > y) {
|
||
const orientation = (x2 - x1) * (y - y1) - (x - x1) * (y2 - y1);
|
||
if (orientation > 0) winding++;
|
||
}
|
||
} else {
|
||
if (y2 <= y) {
|
||
const orientation = (x2 - x1) * (y - y1) - (x - x1) * (y2 - y1);
|
||
if (orientation < 0) winding--;
|
||
}
|
||
}
|
||
}
|
||
|
||
return winding !== 0;
|
||
}
|
||
|
||
// 방향에 무관한 경계선 검사
|
||
function isOnBoundaryDirectionIndependent(point, roofLines) {
|
||
const tolerance = 1e-10;
|
||
|
||
for (const line of roofLines) {
|
||
if (isPointOnLineSegmentDirectionIndependent(point, line, tolerance)) {
|
||
return true;
|
||
}
|
||
}
|
||
return false;
|
||
}
|
||
|
||
// 핵심: 방향에 무관한 선분 위 점 검사
|
||
function isPointOnLineSegmentDirectionIndependent(point, line, tolerance) {
|
||
const x = point.x, y = point.y;
|
||
const x1 = line.x1, y1 = line.y1;
|
||
const x2 = line.x2, y2 = line.y2;
|
||
|
||
// 방향에 무관하게 경계 상자 체크
|
||
const minX = Math.min(x1, x2);
|
||
const maxX = Math.max(x1, x2);
|
||
const minY = Math.min(y1, y2);
|
||
const maxY = Math.max(y1, y2);
|
||
|
||
if (x < minX - tolerance || x > maxX + tolerance ||
|
||
y < minY - tolerance || y > maxY + tolerance) {
|
||
return false;
|
||
}
|
||
|
||
// 외적을 이용한 직선 위 판단 (방향 무관)
|
||
const cross = (y - y1) * (x2 - x1) - (x - x1) * (y2 - y1);
|
||
return Math.abs(cross) < tolerance;
|
||
}
|
||
|
||
/**
|
||
* 선분 위의 점에 대한 매개변수 t를 계산합니다.
|
||
* p = p1 + t * (p2 - p1)에서 t 값을 구합니다.
|
||
* @param {Object} p1 - 선분의 시작점
|
||
* @param {Object} p2 - 선분의 끝점
|
||
* @param {Object} point - 선분 위의 점
|
||
* @returns {number} 매개변수 t (0이면 p1, 1이면 p2)
|
||
*/
|
||
function getParameterT(p1, p2, point) {
|
||
const dx = p2.x - p1.x;
|
||
const dy = p2.y - p1.y;
|
||
|
||
// x 좌표가 더 큰 변화를 보이면 x로 계산, 아니면 y로 계산
|
||
if (Math.abs(dx) > Math.abs(dy)) {
|
||
return dx === 0 ? 0 : (point.x - p1.x) / dx;
|
||
} else {
|
||
return dy === 0 ? 0 : (point.y - p1.y) / dy;
|
||
}
|
||
}
|
||
export const convertBaseLinesToPoints = (baseLines) => {
|
||
const points = [];
|
||
const pointSet = new Set();
|
||
|
||
baseLines.forEach((line) => {
|
||
[
|
||
{ x: line.x1, y: line.y1 },
|
||
{ x: line.x2, y: line.y2 }
|
||
].forEach(point => {
|
||
const key = `${point.x},${point.y}`;
|
||
if (!pointSet.has(key)) {
|
||
pointSet.add(key);
|
||
points.push(point);
|
||
}
|
||
});
|
||
});
|
||
|
||
return points;
|
||
};
|
||
|
||
function getLineDirection(p1, p2) {
|
||
const dx = p2.x - p1.x;
|
||
const dy = p2.y - p1.y;
|
||
const angle = Math.atan2(dy, dx) * 180 / Math.PI;
|
||
|
||
// 각도 범위에 따라 방향 반환
|
||
if ((angle >= -45 && angle < 45)) return 'right';
|
||
if ((angle >= 45 && angle < 135)) return 'bottom';
|
||
if ((angle >= 135 || angle < -135)) return 'left';
|
||
return 'top'; // (-135 ~ -45)
|
||
}
|
||
|
||
|
||
|
||
|
||
|
||
// selectLine과 baseLines 비교하여 방향 찾기
|
||
function findLineDirection(selectLine, baseLines) {
|
||
for (const baseLine of baseLines) {
|
||
// baseLine의 시작점과 끝점
|
||
const baseStart = baseLine.startPoint;
|
||
const baseEnd = baseLine.endPoint;
|
||
|
||
// selectLine의 시작점과 끝점
|
||
const selectStart = selectLine.startPoint;
|
||
const selectEnd = selectLine.endPoint;
|
||
|
||
// 정방향 또는 역방향으로 일치하는지 확인
|
||
if ((isSamePoint(baseStart, selectStart) && isSamePoint(baseEnd, selectEnd)) ||
|
||
(isSamePoint(baseStart, selectEnd) && isSamePoint(baseEnd, selectStart))) {
|
||
|
||
// baseLine의 방향 계산
|
||
const dx = baseEnd.x - baseStart.x;
|
||
const dy = baseEnd.y - baseStart.y;
|
||
|
||
// 기울기를 바탕으로 방향 판단
|
||
if (Math.abs(dx) > Math.abs(dy)) {
|
||
return dx > 0 ? 'right' : 'left';
|
||
} else {
|
||
return dy > 0 ? 'down' : 'up';
|
||
}
|
||
}
|
||
}
|
||
|
||
return null; // 일치하는 라인이 없는 경우
|
||
}
|
||
|
||
|
||
/**
|
||
* baseLines를 연결하여 다각형 순서로 정렬된 점들 반환
|
||
* @param {Array} baseLines - 라인 배열
|
||
* @returns {Array} 순서대로 정렬된 점들의 배열
|
||
*/
|
||
function getOrderedBasePoints(baseLines) {
|
||
if (baseLines.length === 0) return [];
|
||
|
||
const points = [];
|
||
const usedLines = new Set();
|
||
|
||
// 첫 번째 라인으로 시작
|
||
let currentLine = baseLines[0];
|
||
points.push({ ...currentLine.startPoint });
|
||
points.push({ ...currentLine.endPoint });
|
||
usedLines.add(0);
|
||
|
||
let lastPoint = currentLine.endPoint;
|
||
|
||
// 연결된 라인들을 찾아가며 점들 수집
|
||
while (usedLines.size < baseLines.length) {
|
||
let foundNext = false;
|
||
|
||
for (let i = 0; i < baseLines.length; i++) {
|
||
if (usedLines.has(i)) continue;
|
||
|
||
const line = baseLines[i];
|
||
|
||
// 현재 끝점과 연결되는 라인 찾기
|
||
if (isSamePoint(lastPoint, line.startPoint)) {
|
||
points.push({ ...line.endPoint });
|
||
lastPoint = line.endPoint;
|
||
usedLines.add(i);
|
||
foundNext = true;
|
||
break;
|
||
} else if (isSamePoint(lastPoint, line.endPoint)) {
|
||
points.push({ ...line.startPoint });
|
||
lastPoint = line.startPoint;
|
||
usedLines.add(i);
|
||
foundNext = true;
|
||
break;
|
||
}
|
||
}
|
||
|
||
if (!foundNext) break; // 연결되지 않는 경우 중단
|
||
}
|
||
|
||
// 마지막 점이 첫 번째 점과 같으면 제거 (닫힌 다각형)
|
||
if (points.length > 2 && isSamePoint(points[0], points[points.length - 1])) {
|
||
points.pop();
|
||
}
|
||
|
||
return points;
|
||
}
|
||
|
||
/**
|
||
* roof.points와 baseLines가 정확히 대응되는 경우의 간단한 버전
|
||
*/
|
||
function createOrderedBasePoints(roofPoints, baseLines) {
|
||
const basePoints = [];
|
||
|
||
// wall 생성 시 baseLines[i]는 polygon edge i (roofPoints[i]→roofPoints[i+1])에 대응해 push되므로
|
||
// baseLines[i].startPoint 는 roofPoints[i]의 wall 좌표이다(= 인덱스 직접 정합).
|
||
//
|
||
// 기존 구현은 getOrderedBasePoints(graph traversal)을 사용해 baseLines[0] 시작점과
|
||
// 체인 isSamePoint 성공에 결과 순서가 의존했고, 평행 이동으로 좌표가 mutate되면
|
||
// 체인이 어긋나 roof.points 인덱스와 정합이 깨져 엉뚱한 꼭짓점에 이동이 적용되는
|
||
// SK 붕괴 원인이 됐다. 인덱스 직접 매핑으로 불변식을 확보한다.
|
||
for (let i = 0; i < roofPoints.length; i++) {
|
||
const bl = baseLines[i];
|
||
if (bl && bl.startPoint) {
|
||
basePoints.push({ ...bl.startPoint });
|
||
} else {
|
||
// baseLines 수가 부족한 예외 상황에만 roof 좌표로 fallback
|
||
basePoints.push({ ...roofPoints[i] });
|
||
}
|
||
}
|
||
|
||
return basePoints;
|
||
}
|
||
|
||
export const getSelectLinePosition = (wall, selectLine, options = {}) => {
|
||
const { testDistance = 10, epsilon = 0.5, debug = false } = options;
|
||
|
||
if (!wall || !selectLine) {
|
||
// if (debug) logger.log('ERROR: wall 또는 selectLine이 없음');
|
||
return { position: 'unknown', orientation: 'unknown', error: 'invalid_input' };
|
||
}
|
||
|
||
// selectLine의 좌표 추출
|
||
const lineCoords = extractLineCoords(selectLine);
|
||
if (!lineCoords.valid) {
|
||
// if (debug) logger.log('ERROR: selectLine 좌표가 유효하지 않음');
|
||
return { position: 'unknown', orientation: 'unknown', error: 'invalid_coords' };
|
||
}
|
||
|
||
const { x1, y1, x2, y2 } = lineCoords;
|
||
|
||
//logger.log('wall.points', wall.baseLines);
|
||
for(const line of wall.baseLines) {
|
||
//logger.log('line', line);
|
||
const basePoint = extractLineCoords(line);
|
||
const { x1: bx1, y1: by1, x2: bx2, y2: by2 } = basePoint;
|
||
//logger.log('x1, y1, x2, y2', bx1, by1, bx2, by2);
|
||
|
||
// 객체 비교 대신 좌표값 비교
|
||
if (Math.abs(bx1 - x1) < 0.1 &&
|
||
Math.abs(by1 - y1) < 0.1 &&
|
||
Math.abs(bx2 - x2) < 0.1 &&
|
||
Math.abs(by2 - y2) < 0.1) {
|
||
//logger.log('basePoint 일치!!!', basePoint);
|
||
}
|
||
}
|
||
|
||
|
||
// 라인 방향 분석
|
||
const lineInfo = analyzeLineOrientation(x1, y1, x2, y2, epsilon);
|
||
|
||
// if (debug) {
|
||
// logger.log('=== getSelectLinePosition ===');
|
||
// logger.log('selectLine 좌표:', lineCoords);
|
||
// logger.log('라인 방향:', lineInfo.orientation);
|
||
// }
|
||
|
||
// 라인의 중점
|
||
const midX = (x1 + x2) / 2;
|
||
const midY = (y1 + y2) / 2;
|
||
|
||
let position = 'unknown';
|
||
|
||
if (lineInfo.orientation === 'horizontal') {
|
||
// 수평선: top 또는 bottom 판단
|
||
|
||
// 바로 위쪽 테스트 포인트
|
||
const topTestPoint = { x: midX, y: midY - testDistance };
|
||
// 바로 아래쪽 테스트 포인트
|
||
const bottomTestPoint = { x: midX, y: midY + testDistance };
|
||
|
||
const topIsInside = checkPointInPolygon(topTestPoint, wall);
|
||
const bottomIsInside = checkPointInPolygon(bottomTestPoint, wall);
|
||
|
||
// if (debug) {
|
||
// logger.log('수평선 테스트:');
|
||
// logger.log(' 위쪽 포인트:', topTestPoint, '-> 내부:', topIsInside);
|
||
// logger.log(' 아래쪽 포인트:', bottomTestPoint, '-> 내부:', bottomIsInside);
|
||
// }
|
||
|
||
// top 조건: 위쪽이 외부, 아래쪽이 내부
|
||
if (!topIsInside && bottomIsInside) {
|
||
position = 'top';
|
||
}
|
||
// bottom 조건: 위쪽이 내부, 아래쪽이 외부
|
||
else if (topIsInside && !bottomIsInside) {
|
||
position = 'bottom';
|
||
}
|
||
|
||
} else if (lineInfo.orientation === 'vertical') {
|
||
// 수직선: left 또는 right 판단
|
||
|
||
// 바로 왼쪽 테스트 포인트
|
||
const leftTestPoint = { x: midX - testDistance, y: midY };
|
||
// 바로 오른쪽 테스트 포인트
|
||
const rightTestPoint = { x: midX + testDistance, y: midY };
|
||
|
||
const leftIsInside = checkPointInPolygon(leftTestPoint, wall);
|
||
const rightIsInside = checkPointInPolygon(rightTestPoint, wall);
|
||
|
||
// if (debug) {
|
||
// logger.log('수직선 테스트:');
|
||
// logger.log(' 왼쪽 포인트:', leftTestPoint, '-> 내부:', leftIsInside);
|
||
// logger.log(' 오른쪽 포인트:', rightTestPoint, '-> 내부:', rightIsInside);
|
||
// }
|
||
|
||
// left 조건: 왼쪽이 외부, 오른쪽이 내부
|
||
if (!leftIsInside && rightIsInside) {
|
||
position = 'left';
|
||
}
|
||
// right 조건: 오른쪽이 외부, 왼쪽이 내부
|
||
else if (leftIsInside && !rightIsInside) {
|
||
position = 'right';
|
||
}
|
||
|
||
} else {
|
||
// 대각선
|
||
// if (debug) logger.log('대각선은 지원하지 않음');
|
||
return { position: 'unknown', orientation: 'diagonal', error: 'not_supported' };
|
||
}
|
||
|
||
const result = {
|
||
position,
|
||
orientation: lineInfo.orientation,
|
||
method: 'inside_outside_test',
|
||
confidence: position !== 'unknown' ? 1.0 : 0.0,
|
||
testPoints: lineInfo.orientation === 'horizontal' ? {
|
||
top: { x: midX, y: midY - testDistance },
|
||
bottom: { x: midX, y: midY + testDistance }
|
||
} : {
|
||
left: { x: midX - testDistance, y: midY },
|
||
right: { x: midX + testDistance, y: midY }
|
||
},
|
||
midPoint: { x: midX, y: midY }
|
||
};
|
||
|
||
// if (debug) {
|
||
// logger.log('최종 결과:', result);
|
||
// }
|
||
|
||
return result;
|
||
};
|
||
|
||
// 점이 다각형 내부에 있는지 확인하는 함수
|
||
const checkPointInPolygon = (point, wall) => {
|
||
|
||
// 2. wall.baseLines를 이용한 Ray Casting Algorithm
|
||
if (!wall.baseLines || !Array.isArray(wall.baseLines)) {
|
||
logger.warn('wall.baseLines가 없습니다');
|
||
return false;
|
||
}
|
||
|
||
return raycastingAlgorithm(point, wall.baseLines);
|
||
};
|
||
|
||
// Ray Casting Algorithm 구현
|
||
const raycastingAlgorithm = (point, lines) => {
|
||
const { x, y } = point;
|
||
let intersectionCount = 0;
|
||
|
||
for (const line of lines) {
|
||
const coords = extractLineCoords(line);
|
||
if (!coords.valid) continue;
|
||
|
||
const { x1, y1, x2, y2 } = coords;
|
||
|
||
// Ray casting: 점에서 오른쪽으로 수평선을 그어서 다각형 경계와의 교점 개수를 셈
|
||
// 교점 개수가 홀수면 내부, 짝수면 외부
|
||
|
||
// 선분의 y 범위 확인
|
||
if ((y1 > y) !== (y2 > y)) {
|
||
// x 좌표에서의 교점 계산
|
||
const intersectX = (x2 - x1) * (y - y1) / (y2 - y1) + x1;
|
||
|
||
// 점의 오른쪽에 교점이 있으면 카운트
|
||
if (x < intersectX) {
|
||
intersectionCount++;
|
||
}
|
||
}
|
||
}
|
||
|
||
// 홀수면 내부, 짝수면 외부
|
||
return intersectionCount % 2 === 1;
|
||
};
|
||
|
||
// 라인 객체에서 좌표를 추출하는 헬퍼 함수 (중복 방지용 - 이미 있다면 제거)
|
||
const extractLineCoords = (line) => {
|
||
if (!line) {
|
||
return { x1: 0, y1: 0, x2: 0, y2: 0, valid: false };
|
||
}
|
||
|
||
let x1, y1, x2, y2;
|
||
|
||
// 다양한 라인 객체 형태에 대응
|
||
if (line.x1 !== undefined && line.y1 !== undefined &&
|
||
line.x2 !== undefined && line.y2 !== undefined) {
|
||
x1 = line.x1;
|
||
y1 = line.y1;
|
||
x2 = line.x2;
|
||
y2 = line.y2;
|
||
}
|
||
else if (line.startPoint && line.endPoint) {
|
||
x1 = line.startPoint.x;
|
||
y1 = line.startPoint.y;
|
||
x2 = line.endPoint.x;
|
||
y2 = line.endPoint.y;
|
||
}
|
||
else if (line.p1 && line.p2) {
|
||
x1 = line.p1.x;
|
||
y1 = line.p1.y;
|
||
x2 = line.p2.x;
|
||
y2 = line.p2.y;
|
||
}
|
||
else {
|
||
return { x1: 0, y1: 0, x2: 0, y2: 0, valid: false };
|
||
}
|
||
|
||
const coords = [x1, y1, x2, y2];
|
||
const valid = coords.every(coord =>
|
||
typeof coord === 'number' &&
|
||
!Number.isNaN(coord) &&
|
||
Number.isFinite(coord)
|
||
);
|
||
|
||
return { x1, y1, x2, y2, valid };
|
||
};
|
||
|
||
// 라인 방향 분석 함수 (중복 방지용 - 이미 있다면 제거)
|
||
const analyzeLineOrientation = (x1, y1, x2, y2, epsilon = 0.5) => {
|
||
const dx = x2 - x1;
|
||
const dy = y2 - y1;
|
||
const absDx = Math.abs(dx);
|
||
const absDy = Math.abs(dy);
|
||
const length = Math.sqrt(dx * dx + dy * dy);
|
||
|
||
let orientation;
|
||
if (absDy < epsilon && absDx >= epsilon) {
|
||
orientation = 'horizontal';
|
||
} else if (absDx < epsilon && absDy >= epsilon) {
|
||
orientation = 'vertical';
|
||
} else {
|
||
orientation = 'diagonal';
|
||
}
|
||
|
||
return {
|
||
orientation,
|
||
dx, dy, absDx, absDy, length,
|
||
midX: (x1 + x2) / 2,
|
||
midY: (y1 + y2) / 2,
|
||
isHorizontal: orientation === 'horizontal',
|
||
isVertical: orientation === 'vertical'
|
||
};
|
||
};
|
||
|
||
|
||
// 점에서 선분까지의 최단 거리를 계산하는 도우미 함수
|
||
function pointToLineDistance(point, lineP1, lineP2) {
|
||
const A = point.x - lineP1.x;
|
||
const B = point.y - lineP1.y;
|
||
const C = lineP2.x - lineP1.x;
|
||
const D = lineP2.y - lineP1.y;
|
||
|
||
const dot = A * C + B * D;
|
||
const lenSq = C * C + D * D;
|
||
let param = -1;
|
||
|
||
if (lenSq !== 0) {
|
||
param = dot / lenSq;
|
||
}
|
||
|
||
let xx, yy;
|
||
|
||
if (param < 0) {
|
||
xx = lineP1.x;
|
||
yy = lineP1.y;
|
||
} else if (param > 1) {
|
||
xx = lineP2.x;
|
||
yy = lineP2.y;
|
||
} else {
|
||
xx = lineP1.x + param * C;
|
||
yy = lineP1.y + param * D;
|
||
}
|
||
|
||
const dx = point.x - xx;
|
||
const dy = point.y - yy;
|
||
return Math.sqrt(dx * dx + dy * dy);
|
||
}
|
||
|
||
|
||
const getOrientation = (line, eps = 0.1) => {
|
||
if (!line) {
|
||
logger.error('line 객체가 유효하지 않습니다:', line);
|
||
return null; // 또는 적절한 기본값 반환
|
||
}
|
||
|
||
// get 메서드가 있으면 사용하고, 없으면 직접 프로퍼티에 접근
|
||
const getValue = (obj, key) =>
|
||
obj && typeof obj.get === 'function' ? obj.get(key) : obj[key];
|
||
|
||
try {
|
||
const x1 = getValue(line, 'x1');
|
||
const y1 = getValue(line, 'y1');
|
||
const x2 = getValue(line, 'x2');
|
||
const y2 = getValue(line, 'y2');
|
||
|
||
const dx = Math.abs(x2 - x1);
|
||
const dy = Math.abs(y2 - y1);
|
||
|
||
if (dx < eps && dy >= eps) return 'vertical';
|
||
if (dy < eps && dx >= eps) return 'horizontal';
|
||
if (dx < eps && dy < eps) return 'point';
|
||
return 'diagonal';
|
||
} catch (e) {
|
||
logger.error('방향 계산 중 오류 발생:', e);
|
||
return null;
|
||
}
|
||
}
|
||
|
||
|
||
|
||
export const processEaveHelpLines = (lines) => {
|
||
if (!lines || lines.length === 0) return [];
|
||
|
||
// 수직/수평 라인 분류 (부동소수점 오차 고려)
|
||
const verticalLines = lines.filter(line => Math.abs(line.x1 - line.x2) < 0.1);
|
||
const horizontalLines = lines.filter(line => Math.abs(line.y1 - line.y2) < 0.1);
|
||
|
||
// 라인 병합 (더 엄격한 조건으로)
|
||
const mergedVertical = mergeLines(verticalLines, 'vertical');
|
||
const mergedHorizontal = mergeLines(horizontalLines, 'horizontal');
|
||
|
||
// 결과 확인용 로그
|
||
// logger.log('Original lines:', lines.length);
|
||
// logger.log('Merged vertical:', mergedVertical.length);
|
||
// logger.log('Merged horizontal:', mergedHorizontal.length);
|
||
|
||
return [...mergedVertical, ...mergedHorizontal];
|
||
};
|
||
|
||
const mergeLines = (lines, direction) => {
|
||
if (!lines || lines.length < 2) return lines || [];
|
||
|
||
// 방향에 따라 정렬 (수직: y1 기준, 수평: x1 기준)
|
||
lines.sort((a, b) => {
|
||
const aPos = direction === 'vertical' ? a.y1 : a.x1;
|
||
const bPos = direction === 'vertical' ? b.y1 : b.x1;
|
||
return aPos - bPos;
|
||
});
|
||
|
||
const merged = [];
|
||
let current = { ...lines[0] };
|
||
|
||
for (let i = 1; i < lines.length; i++) {
|
||
const line = lines[i];
|
||
|
||
// 같은 선상에 있는지 확인 (부동소수점 오차 고려)
|
||
const isSameLine = direction === 'vertical'
|
||
? Math.abs(current.x1 - line.x1) < 0.1
|
||
: Math.abs(current.y1 - line.y1) < 0.1;
|
||
|
||
// 연결 가능한지 확인 (약간의 겹침 허용)
|
||
const isConnected = direction === 'vertical'
|
||
? current.y2 + 0.1 >= line.y1 // 약간의 오차 허용
|
||
: current.x2 + 0.1 >= line.x1;
|
||
|
||
if (isSameLine && isConnected) {
|
||
// 라인 병합
|
||
current.y2 = Math.max(current.y2, line.y2);
|
||
current.x2 = direction === 'vertical' ? current.x1 : current.x2;
|
||
} else {
|
||
merged.push(current);
|
||
current = { ...line };
|
||
}
|
||
}
|
||
merged.push(current);
|
||
|
||
// 병합 결과 로그
|
||
// logger.log(`Merged ${direction} lines:`, merged);
|
||
|
||
return merged;
|
||
};
|
||
|
||
|
||
|
||
/**
|
||
* 주어진 점을 포함하는 라인을 찾는 함수
|
||
* @param {Array} lines - 검색할 라인 배열 (각 라인은 x1, y1, x2, y2 속성을 가져야 함)
|
||
* @param {Object} point - 찾고자 하는 점 {x, y}
|
||
* @param {number} [tolerance=0.1] - 점이 선분 위에 있는지 판단할 때의 허용 오차
|
||
* @returns {Object|null} 점을 포함하는 첫 번째 라인 또는 null
|
||
*/
|
||
function findLineContainingPoint(lines, point, tolerance = 0.1) {
|
||
if (!point || !lines || !lines.length) return null;
|
||
|
||
return lines.find(line => {
|
||
const { x1, y1, x2, y2 } = line;
|
||
return isPointOnLineSegment(point, {x: x1, y: y1}, {x: x2, y: y2}, tolerance);
|
||
}) || null;
|
||
}
|
||
|
||
/**
|
||
* 점이 선분 위에 있는지 확인하는 함수
|
||
* @param {Object} point - 확인할 점 {x, y}
|
||
* @param {Object} lineStart - 선분의 시작점 {x, y}
|
||
* @param {Object} lineEnd - 선분의 끝점 {x, y}
|
||
* @param {number} tolerance - 허용 오차
|
||
* @returns {boolean}
|
||
*/
|
||
function isPointOnLineSegment(point, lineStart, lineEnd, tolerance = 0.1) {
|
||
const { x: px, y: py } = point;
|
||
const { x: x1, y: y1 } = lineStart;
|
||
const { x: x2, y: y2 } = lineEnd;
|
||
|
||
// 선분의 길이
|
||
const lineLength = Math.hypot(x2 - x1, y2 - y1);
|
||
|
||
// 점에서 선분의 양 끝점까지의 거리 합
|
||
const dist1 = Math.hypot(px - x1, py - y1);
|
||
const dist2 = Math.hypot(px - x2, py - y2);
|
||
|
||
// 점이 선분 위에 있는지 확인 (허용 오차 범위 내에서)
|
||
return Math.abs(dist1 + dist2 - lineLength) <= tolerance;
|
||
}
|
||
|
||
/**
|
||
* Updates a line in the innerLines array and returns the updated array
|
||
* @param {Array} innerLines - Array of line objects to update
|
||
* @param {Object} targetPoint - The point to find the line {x, y}
|
||
* @param {Object} wallBaseLine - The base line containing new coordinates
|
||
* @param {Function} getAddLine - Function to add a new line
|
||
* @returns {Array} Updated array of lines
|
||
*/
|
||
function updateAndAddLine(innerLines, targetPoint) {
|
||
|
||
// 1. Find the line containing the target point.
|
||
// 우선 non-HIP에서 찾고, 없을 때만 HIP 포함 fallback — target이 HIP 꼭짓점에 정확히
|
||
// 놓이는 케이스(wallBaseLine 꼭짓점이 HIP 위)를 복구하기 위함. non-HIP 우선 순서로
|
||
// 과거 "HIP 잘못 매칭" 회피 의도도 보존.
|
||
const nonHipLines = innerLines.filter(line => line.attributes?.type !== LINE_TYPE.SUBLINE.HIP)
|
||
let foundLine = findLineContainingPoint(nonHipLines, targetPoint);
|
||
if (!foundLine) {
|
||
foundLine = findLineContainingPoint(innerLines, targetPoint);
|
||
if (foundLine) {
|
||
// logger.log(`[MOVE-TRACE] HIP fallback matched: position=${targetPoint.position} target=(${targetPoint.x.toFixed(2)},${targetPoint.y.toFixed(2)}) → (${foundLine.x1.toFixed(1)},${foundLine.y1.toFixed(1)})→(${foundLine.x2.toFixed(1)},${foundLine.y2.toFixed(1)}) type=${foundLine.attributes?.type||'?'}`)
|
||
}
|
||
}
|
||
if (!foundLine) {
|
||
logger.warn(`[updateAndAddLine] NOT FOUND position=${targetPoint.position} point=(${targetPoint.x.toFixed(2)},${targetPoint.y.toFixed(2)})`);
|
||
return [...innerLines];
|
||
}
|
||
|
||
// 2. Create a new array without the found line
|
||
const updatedLines = innerLines.filter(line =>
|
||
line !== foundLine &&
|
||
!(line.x1 === foundLine.x1 &&
|
||
line.y1 === foundLine.y1 &&
|
||
line.x2 === foundLine.x2 &&
|
||
line.y2 === foundLine.y2)
|
||
);
|
||
|
||
// Calculate distances to both endpoints
|
||
const distanceToStart = Math.hypot(
|
||
targetPoint.x - foundLine.x1,
|
||
targetPoint.y - foundLine.y1
|
||
);
|
||
const distanceToEnd = Math.hypot(
|
||
targetPoint.x - foundLine.x2,
|
||
targetPoint.y - foundLine.y2
|
||
);
|
||
|
||
// 단순 거리 비교: 타겟 포인트가 시작점에 더 가까우면 시작점을 수정(isUpdatingStart = true)
|
||
//무조건 start
|
||
let isUpdatingStart = false //distanceToStart < distanceToEnd;
|
||
if(targetPoint.position === "top_in_start"){
|
||
if(foundLine.y2 >= foundLine.y1){
|
||
isUpdatingStart = true;
|
||
}
|
||
}else if(targetPoint.position === "top_in_end"){
|
||
if(foundLine.y2 >= foundLine.y1){
|
||
isUpdatingStart = true;
|
||
}
|
||
|
||
}else if(targetPoint.position === "bottom_in_start"){
|
||
if(foundLine.y2 <= foundLine.y1){
|
||
isUpdatingStart = true;
|
||
}
|
||
}else if(targetPoint.position === "bottom_in_end"){
|
||
if(foundLine.y2 <= foundLine.y1){
|
||
isUpdatingStart = true;
|
||
}
|
||
}else if(targetPoint.position === "left_in_start"){
|
||
if(foundLine.x2 >= foundLine.x1){
|
||
isUpdatingStart = true;
|
||
}
|
||
}else if(targetPoint.position === "left_in_end"){
|
||
if(foundLine.x2 >= foundLine.x1){
|
||
isUpdatingStart = true;
|
||
}
|
||
}else if(targetPoint.position === "right_in_start"){
|
||
if(foundLine.x2 <= foundLine.x1){
|
||
isUpdatingStart = true;
|
||
}
|
||
}else if(targetPoint.position === "right_in_end"){
|
||
if(foundLine.x2 <= foundLine.x1){
|
||
isUpdatingStart = true;
|
||
}
|
||
}else if(targetPoint.position === "top_out_start"){
|
||
if(foundLine.y2 >= foundLine.y1){
|
||
isUpdatingStart = true;
|
||
}
|
||
}else if(targetPoint.position === "top_out_end"){
|
||
if(foundLine.y2 >= foundLine.y1){
|
||
isUpdatingStart = true;
|
||
}
|
||
}else if(targetPoint.position === "bottom_out_start"){
|
||
if(foundLine.y2 <= foundLine.y1){
|
||
isUpdatingStart = true;
|
||
}
|
||
}else if(targetPoint.position === "bottom_out_end"){
|
||
if(foundLine.y2 <= foundLine.y1){
|
||
isUpdatingStart = true;
|
||
}
|
||
}else if(targetPoint.position === "left_out_start"){
|
||
if(foundLine.x2 >= foundLine.x1){
|
||
isUpdatingStart = true;
|
||
}
|
||
}else if(targetPoint.position === "left_out_end"){
|
||
if(foundLine.x2 >= foundLine.x1){
|
||
isUpdatingStart = true;
|
||
}
|
||
}else if(targetPoint.position === "right_out_start"){
|
||
if(foundLine.x2 <= foundLine.x1){
|
||
isUpdatingStart = true;
|
||
}
|
||
}else if(targetPoint.position === "right_out_end"){
|
||
if(foundLine.x2 <= foundLine.x1){
|
||
isUpdatingStart = true;
|
||
}
|
||
}
|
||
|
||
const updatedLine = {
|
||
...foundLine,
|
||
left: isUpdatingStart ? targetPoint.x : foundLine.x1,
|
||
top: isUpdatingStart ? targetPoint.y : foundLine.y1,
|
||
x1: isUpdatingStart ? targetPoint.x : foundLine.x1,
|
||
y1: isUpdatingStart ? targetPoint.y : foundLine.y1,
|
||
x2: isUpdatingStart ? foundLine.x2 : targetPoint.x,
|
||
y2: isUpdatingStart ? foundLine.y2 : targetPoint.y,
|
||
startPoint: {
|
||
x: isUpdatingStart ? targetPoint.x : foundLine.x1,
|
||
y: isUpdatingStart ? targetPoint.y : foundLine.y1
|
||
},
|
||
endPoint: {
|
||
x: isUpdatingStart ? foundLine.x2 : targetPoint.x,
|
||
y: isUpdatingStart ? foundLine.y2 : targetPoint.y
|
||
}
|
||
};
|
||
|
||
// 4. If it's a Fabric.js object, use set method if available
|
||
if (typeof foundLine.set === 'function') {
|
||
foundLine.set({
|
||
x1: isUpdatingStart ? targetPoint.x : foundLine.x1,
|
||
y1: isUpdatingStart ? targetPoint.y : foundLine.y1,
|
||
x2: isUpdatingStart ? foundLine.x2 : targetPoint.x,
|
||
y2: isUpdatingStart ? foundLine.y2 : targetPoint.y
|
||
});
|
||
updatedLines.push(foundLine);
|
||
} else {
|
||
updatedLines.push(updatedLine);
|
||
}
|
||
|
||
return updatedLines;
|
||
}
|
||
|
||
|
||
|
||
/**
|
||
* 점이 선분 위에 있는지 확인
|
||
* @param {Object} point - 확인할 점 {x, y}
|
||
* @param {Object} lineStart - 선분의 시작점 {x, y}
|
||
* @param {Object} lineEnd - 선분의 끝점 {x, y}
|
||
* @param {number} tolerance - 오차 허용 범위
|
||
* @returns {boolean} - 점이 선분 위에 있으면 true, 아니면 false
|
||
*/
|
||
function isPointOnLineSegment2(point, lineStart, lineEnd, tolerance = 0.1) {
|
||
const { x: px, y: py } = point;
|
||
const { x: x1, y: y1 } = lineStart;
|
||
const { x: x2, y: y2 } = lineEnd;
|
||
|
||
// 선분의 길이
|
||
const lineLength = Math.hypot(x2 - x1, y2 - y1);
|
||
|
||
// 점에서 선분의 양 끝점까지의 거리
|
||
const dist1 = Math.hypot(px - x1, py - y1);
|
||
const dist2 = Math.hypot(px - x2, py - y2);
|
||
|
||
// 점이 선분 위에 있는지 확인 (오차 허용 범위 내에서)
|
||
const isOnSegment = Math.abs((dist1 + dist2) - lineLength) <= tolerance;
|
||
|
||
if (isOnSegment) {
|
||
// logger.log(`점 (${px}, ${py})은 선분 [(${x1}, ${y1}), (${x2}, ${y2})] 위에 있습니다.`);
|
||
}
|
||
|
||
return isOnSegment;
|
||
}
|
||
|
||
/**
|
||
* 세 점(p1 -> p2 -> p3)의 방향성을 계산합니다. (2D 외적)
|
||
* 반시계 방향(CCW)으로 그려진 폴리곤(Y축 Down) 기준:
|
||
* - 결과 > 0 : 오른쪽 턴 (Right Turn) -> 골짜기 (Valley/Reflex Vertex)
|
||
* - 결과 < 0 : 왼쪽 턴 (Left Turn) -> 외곽 모서리 (Convex Vertex)
|
||
* - 결과 = 0 : 직선
|
||
*/
|
||
function getTurnDirection(p1, p2, p3) {
|
||
// 벡터 a: p1 -> p2
|
||
// 벡터 b: p2 -> p3
|
||
const val = (p2.x - p1.x) * (p3.y - p2.y) - (p2.y - p1.y) * (p3.x - p2.x);
|
||
return val;
|
||
}
|
||
|
||
/**
|
||
* 현재 점(point)을 기준으로 연결된 이전 라인과 다음 라인을 찾아 골짜기 여부 판단
|
||
*/
|
||
function isValleyVertex(targetPoint, connectedLine, allLines, isStartVertex) {
|
||
const tolerance = 0.1;
|
||
|
||
const connectedLineData = {
|
||
x1: connectedLine.x1 ?? connectedLine.get?.('x1'),
|
||
y1: connectedLine.y1 ?? connectedLine.get?.('y1'),
|
||
x2: connectedLine.x2 ?? connectedLine.get?.('x2'),
|
||
y2: connectedLine.y2 ?? connectedLine.get?.('y2'),
|
||
startPoint: connectedLine.startPoint,
|
||
endPoint: connectedLine.endPoint
|
||
};
|
||
|
||
let neighborLine = null;
|
||
|
||
// [valley degenerate skip 2026-05-13]
|
||
// SHOULDER_ABSORBED 로 인접 baseLine 이 zero-length 가 되면 (p1==p2)
|
||
// cross product 가 노이즈 작은 값이 되어 valley 판정이 우연에 좌우됨
|
||
// (notch 케이스: cross=59.15 → valley=true 오판 → eaveHelpLine 4개 잘못 생성).
|
||
// length < 1.0 (관측치 0.10, 0.50) 이면 skip 하고 그 너머 진짜 인접 라인 탐색.
|
||
const DEGEN_LEN = 1.0;
|
||
if (isStartVertex) {
|
||
neighborLine = allLines.find(l => {
|
||
if (l === connectedLine) return false;
|
||
const lx1 = l.x1 ?? l.get?.('x1');
|
||
const ly1 = l.y1 ?? l.get?.('y1');
|
||
const lx2 = l.x2 ?? l.get?.('x2');
|
||
const ly2 = l.y2 ?? l.get?.('y2');
|
||
if (Math.hypot(lx2 - lx1, ly2 - ly1) < DEGEN_LEN) return false; // [valley degenerate skip 2026-05-13]
|
||
const end = l.endPoint || { x: lx2, y: ly2 };
|
||
return isSamePoint(end, targetPoint, tolerance);
|
||
});
|
||
} else {
|
||
neighborLine = allLines.find(l => {
|
||
if (l === connectedLine) return false;
|
||
const lx1 = l.x1 ?? l.get?.('x1');
|
||
const ly1 = l.y1 ?? l.get?.('y1');
|
||
const lx2 = l.x2 ?? l.get?.('x2');
|
||
const ly2 = l.y2 ?? l.get?.('y2');
|
||
if (Math.hypot(lx2 - lx1, ly2 - ly1) < DEGEN_LEN) return false; // [valley degenerate skip 2026-05-13]
|
||
const start = l.startPoint || { x: lx1, y: ly1 };
|
||
return isSamePoint(start, targetPoint, tolerance);
|
||
});
|
||
}
|
||
|
||
if (!neighborLine) return false;
|
||
|
||
const nlx1 = neighborLine.x1 ?? neighborLine.get?.('x1');
|
||
const nly1 = neighborLine.y1 ?? neighborLine.get?.('y1');
|
||
const nlx2 = neighborLine.x2 ?? neighborLine.get?.('x2');
|
||
const nly2 = neighborLine.y2 ?? neighborLine.get?.('y2');
|
||
|
||
const clx1 = connectedLineData.x1;
|
||
const cly1 = connectedLineData.y1;
|
||
const clx2 = connectedLineData.x2;
|
||
const cly2 = connectedLineData.y2;
|
||
|
||
let p1, p2, p3;
|
||
|
||
if (isStartVertex) {
|
||
p1 = neighborLine.startPoint || { x: nlx1, y: nly1 };
|
||
p2 = targetPoint;
|
||
p3 = connectedLineData.endPoint || { x: clx2, y: cly2 };
|
||
} else {
|
||
p1 = connectedLineData.startPoint || { x: clx1, y: cly1 };
|
||
p2 = targetPoint;
|
||
p3 = neighborLine.endPoint || { x: nlx2, y: nly2 };
|
||
}
|
||
|
||
const crossProduct = getTurnDirection(p1, p2, p3);
|
||
// [collinear continuation 2026-05-13]
|
||
// neigh→target→conn 이 거의 직선이면 (노치 꼬리/연속 segment)
|
||
// drift (round 0.1) 로 cross 가 미세한 양/음수 노이즈 → valley 오판.
|
||
// |cross| / (|v1|*|v2|) = |sin(theta)|. < 0.05 (≈ 2.9°) 면 직선 연속으로 간주.
|
||
const v1len = Math.hypot(p2.x - p1.x, p2.y - p1.y);
|
||
const v2len = Math.hypot(p3.x - p2.x, p3.y - p2.y);
|
||
let collinearSkip = false;
|
||
if (v1len > 0 && v2len > 0) {
|
||
const sinTheta = Math.abs(crossProduct) / (v1len * v2len);
|
||
if (sinTheta < 0.05) collinearSkip = true;
|
||
}
|
||
// [valley diag 2026-05-13] L자(정상) vs notch(E-1~E-4 발생) 비교용
|
||
const fmt = (p) => `(${Math.round(p.x)},${Math.round(p.y)})`;
|
||
const cLen = Math.hypot(clx2 - clx1, cly2 - cly1);
|
||
const nLen = Math.hypot(nlx2 - nlx1, nly2 - nly1);
|
||
// logger.log(
|
||
// `[VALLEY] ${isStartVertex ? 'START' : 'END'} target=${fmt(targetPoint)} ` +
|
||
// `conn=${fmt({x:clx1,y:cly1})}→${fmt({x:clx2,y:cly2})}[len=${cLen.toFixed(2)}] ` +
|
||
// `neigh=${fmt({x:nlx1,y:nly1})}→${fmt({x:nlx2,y:nly2})}[len=${nLen.toFixed(2)}] ` +
|
||
// `p1=${fmt(p1)} p2=${fmt(p2)} p3=${fmt(p3)} cross=${crossProduct.toFixed(2)} ` +
|
||
// `valley=${collinearSkip ? false : (crossProduct > 0)}${collinearSkip ? ' (collinear-skip)' : ''}`
|
||
// );
|
||
if (collinearSkip) return false;
|
||
return crossProduct > 0;
|
||
}
|
||
|
||
function findInteriorPoint(line, polygonLines) {
|
||
const x1 = line.x1 ?? line.get?.('x1');
|
||
const y1 = line.y1 ?? line.get?.('y1');
|
||
const x2 = line.x2 ?? line.get?.('x2');
|
||
const y2 = line.y2 ?? line.get?.('y2');
|
||
|
||
// line 객체 포맷 통일 (함수 내부용)
|
||
const currentLine = {
|
||
...line,
|
||
x1, y1, x2, y2,
|
||
startPoint: { x: x1, y: y1 },
|
||
endPoint: { x: x2, y: y2 }
|
||
};
|
||
|
||
// 1. 시작점이 골짜기인지 확인 (들어오는 라인과 나가는 라인의 각도)
|
||
const startIsValley = isValleyVertex(currentLine.startPoint, currentLine, polygonLines, true);
|
||
|
||
// 2. 끝점이 골짜기인지 확인
|
||
const endIsValley = isValleyVertex(currentLine.endPoint, currentLine, polygonLines, false);
|
||
|
||
|
||
|
||
return {
|
||
start: startIsValley,
|
||
end: endIsValley
|
||
};
|
||
}
|