qcast-front/src/util/skeleton-utils.js
Jaeyoung Lee 53ecbcca0f fix: 처마선 split 시 roof 원본 외곽선 숨김을 공용 함수로 통합
[작업내용] :
- splitRoofLinesToInnerLines 가 처마변 innerLine화 후 roof 원본 외곽(stroke)을 transparent 로 숨기도록 통합
- 변별/용마루 양 경로 공통 적용 — 처마선 삭제 시 roof 기본 stroke:'black' 이 노출되던 문제 해결
- 용마루 호출부(skeletonBuilder)의 개별 roof.set 제거 (split 함수가 담당)

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-12 14:45:12 +09:00

5283 lines
222 KiB
JavaScript
Raw Blame History

This file contains invisible Unicode characters

This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import { LINE_TYPE, POLYGON_TYPE } from '@/common/common'
import { SkeletonBuilder } from '@/lib/skeletons'
import { calcLineActualSize, calcLineActualSize2, calcLinePlaneSize, splitRoofLinesToInnerLines, 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)
// [docs §9-3] 추녀 연장 후 처마변(roof.lines)을 split → 편집 가능한 innerLine(hip/roofLine)으로 등록.
// 단순 우진각: 추녀가 처마 코너(변 끝점)에 닿아 분할점 없음 → 처마 4변 통짜. 복합 형상: 중간 접점에서 분할.
splitRoofLinesToInnerLines(roof, roof.innerLines, canvas, textMode)
// 캔버스에 스켈레톤 상태 저장
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); // [ASI-FIX 2026-05-15] 세미콜론 없으면 다음 줄 `(` 가 함수호출로 묶여 .filter(...)(...) → "is not a function" → SK 빌드 실패 + 확장선 누락
// 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
}
// [KERAB-SKIP 2026-06-04] 순수 케라바 변(GABLE/GABLE_LEFT/GABLE_RIGHT/JERKINHEAD)은
// 처마처럼 wall→roof 사이에 hip이 없으므로 eaveHelpLine 불필요.
// 적용 시 SK 오염. HIPANDGABLE은 처마+케라바 혼합(hip 있음)이므로 제외하지 않음.
if ([
LINE_TYPE.WALLLINE.GABLE,
LINE_TYPE.WALLLINE.GABLE_LEFT,
LINE_TYPE.WALLLINE.GABLE_RIGHT,
LINE_TYPE.WALLLINE.JERKINHEAD,
].includes(wallBaseLine.attributes?.type)) 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 })
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 / 강화 2026-05-18]
// 1) outerLine.attributes.pitch — 매칭된 eaves 라인의 pitch
// 2) roof.pitch — setRoofPitch 에서 stamp (allocation 후)
// 3) roof.lines 중 pitch 가 있는 eaves 라인 — 초기 SK 빌드 시 매칭 실패 시
// 4) canvas 의 wall outerLine 중 pitch 가 있는 라인 — 최후 폴백
// 5) 0
// 기존엔 1~2 폴백만 있어 SK 빌드시 pitch=0 → hip actualSize=planeSize 로 잘못 계산되고,
// 이후 display recompute / 할당 단계에서 다른 pitch 로 재계산되어 라벨이 변하는 문제.
let pitch = outerLine?.attributes?.pitch ?? roof?.pitch
if (pitch === undefined || pitch === null) {
const fallbackFromRoofLines = roof?.lines?.find((l) => l?.attributes?.pitch != null)?.attributes?.pitch
pitch = fallbackFromRoofLines
}
if (pitch === undefined || pitch === null) {
const fallbackOuter = canvas
?.getObjects()
?.find((o) => o?.name === 'outerLine' && o?.attributes?.pitch != null)
pitch = fallbackOuter?.attributes?.pitch
}
if (pitch === undefined || pitch === null) {
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 }));
// 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)을 기준으로 연결된 이전 라인과 다음 라인을 찾아 골짜기 여부 판단
*/
export 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;
}
export 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
};
}