@ -72,6 +72,13 @@ const movingLineFromSkeleton = (roofId, canvas) => {
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 ]
console . 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 )
@ -198,104 +205,55 @@ const movingLineFromSkeleton = (roofId, canvas) => {
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 ) &&
isSamePoint ( line . endPoint , selectLine . endPoint ) ) ||
( isSamePoint ( line . startPoint , selectLine . endPoint ) &&
isSamePoint ( line . endPoint , selectLine . startPoint ))
( 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 originalStartPoint = line . startPoint ;
const originalEndPoint = line . endPoint ;
const offset = line . attributes . offset
// 새로운 좌표 계산
let newStartPoint = { ... originalStartPoint } ;
let newEndPoint = { ... originalEndPoint }
// 원본 라인 업데이트
// newPoints 배열에서 일치하는 포인트들을 찾아서 업데이트
console . log ( 'absMove::' , moveUpDownLength ) ;
newPoints . forEach ( ( point , index ) => {
if ( position === 'bottom' ) {
if ( moveDirection === 'in' ) {
if ( isSamePoint ( roof . basePoints [ index ] , originalStartPoint ) || isSamePoint ( roof . basePoints [ index ] , originalEndPoint ) ) {
point . y = Big ( point . y ) . minus ( moveUpDownLength ) . toNumber ( ) ;
}
// if (isSamePoint(roof.basePoints[index], originalEndPoint)) {
// point.y = Big(point.y).minus(absMove).toNumber();
// }
} else if ( moveDirection === 'out' ) {
if ( isSamePoint ( roof . basePoints [ index ] , originalStartPoint ) || isSamePoint ( roof . basePoints [ index ] , originalEndPoint ) ) {
point . y = Big ( point . y ) . plus ( moveUpDownLength ) . toNumber ( ) ;
}
// if (isSamePoint(roof.basePoints[index], originalEndPoint)) {
// point.y = Big(point.y).plus(absMove).toNumber();
// }
}
} else if ( position === 'top' ) {
if ( moveDirection === 'in' ) {
if ( isSamePoint ( roof . basePoints [ index ] , originalStartPoint ) ) {
point . y = Big ( point . y ) . plus ( moveUpDownLength ) . toNumber ( ) ;
}
if ( isSamePoint ( roof . basePoints [ index ] , originalEndPoint ) ) {
point . y = Big ( point . y ) . plus ( moveUpDownLength ) . toNumber ( ) ;
}
} else if ( moveDirection === 'out' ) {
if ( isSamePoint ( roof . basePoints [ index ] , originalStartPoint ) || isSamePoint ( roof . basePoints [ index ] , originalEndPoint ) ) {
point . y = Big ( point . y ) . minus ( moveUpDownLength ) . toNumber ( ) ;
// console.log('roof.basePoints[index]', roof.basePoints[index])
// console.log('point.x::::', point)
// console.log('originalStartPoint', originalStartPoint)
// console.log('originalEndPoint', originalEndPoint)
}
}
} else if ( position === 'left' ) {
if ( moveDirection === 'in' ) {
if ( isSamePoint ( roof . basePoints [ index ] , originalStartPoint ) || isSamePoint ( roof . basePoints [ index ] , originalEndPoint ) ) {
point . x = Big ( point . x ) . plus ( moveUpDownLength ) . toNumber ( ) ;
}
// if (isSamePoint(roof.basePoints[index], originalEndPoint)) {
// point.x = Big(point.x).plus(absMove).toNumber();
// }
} else if ( moveDirection === 'out' ) {
if ( isSamePoint ( roof . basePoints [ index ] , originalStartPoint ) || isSamePoint ( roof . basePoints [ index ] , originalEndPoint ) ) {
point . x = Big ( point . x ) . minus ( moveUpDownLength ) . toNumber ( ) ;
}
// if (isSamePoint(roof.basePoints[index], originalEndPoint)) {
// point.x = Big(point.x).minus(absMove).toNumber();
// }
}
} else if ( position === 'right' ) {
if ( moveDirection === 'in' ) {
if ( isSamePoint ( roof . basePoints [ index ] , originalStartPoint ) ) {
point . x = Big ( point . x ) . minus ( moveUpDownLength ) . toNumber ( ) ;
}
if ( isSamePoint ( roof . basePoints [ index ] , originalEndPoint ) ) {
point . x = Big ( point . x ) . minus ( moveUpDownLength ) . toNumber ( ) ;
}
} else if ( moveDirection === 'out' ) {
if ( isSamePoint ( roof . basePoints [ index ] , originalStartPoint ) ) {
point . x = Big ( point . x ) . plus ( moveUpDownLength ) . toNumber ( ) ;
}
if ( isSamePoint ( roof . basePoints [ index ] , originalEndPoint ) ) {
point . x = Big ( point . x ) . plus ( moveUpDownLength ) . toNumber ( ) ;
}
}
}
} ) ;
// 원본 baseLine도 업데이트
line . startPoint = newStartPoint ;
line . endPoint = newEndPoint ;
const k = line . findIndex ;
if ( typeof k !== 'number' || k < 0 ) return ;
affected . add ( k ) ;
affected . add ( ( k + 1 ) % nPts ) ;
} ) ;
console . log ( 'absMove::' , moveUpDownLength ) ;
affected . forEach ( ( i ) => {
const point = newPoints [ i ] ;
if ( ! point ) return ;
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 ( ) ;
} 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 ( ) ;
} 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 ( ) ;
} 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 ( ) ;
}
} ) ;
/ * *
@ -364,6 +322,309 @@ const movingLineFromSkeleton = (roofId, canvas) => {
}
/ * *
* 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 ]
console . 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 )
} )
affected . forEach ( ( i ) => {
const point = newPoints [ i ]
if ( ! point ) return
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 ( )
} 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 ( )
} 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 ( )
} 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 ( )
}
} )
// [LP-TRACE] buildRawMovedPoints 결과 — matchingLines 수와 결과 [7] 값
try {
const _sel = selectLine
const _sp = _sel ? . startPoint
const _ep = _sel ? . endPoint
console . 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 ) {
console . 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 ) ) {
console . 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
console . log (
` [verifyMoveBoundary] 진입 position= ${ position } direction= ${ direction } moveUpDown= ${ moveUpDown } moveFlowLine= ${ moveFlowLine } `
)
if ( moveFlowLine === 0 && moveUpDown === 0 ) {
console . log ( '[verifyMoveBoundary] 이동 없음 → ok' )
return 'ok'
}
const proposed = buildRawMovedPoints ( roofId , canvas )
const orig = roof . points
if ( ! Array . isArray ( proposed ) || proposed . length !== orig . length ) {
console . 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 ] )
console . 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 ) {
console . warn (
` [verifyMoveBoundary] 꼭짓점[ ${ i } ] 골짜기 → 볼록 뒤집힘 (CROSSED): ${ crossOrig . toFixed ( 1 ) } → ${ crossNew . toFixed ( 1 ) } `
)
return 'crossed'
}
if ( Math . abs ( crossNew ) <= boundaryTol ) {
console . log (
` [verifyMoveBoundary] 꼭짓점[ ${ i } ] 경계 도달 (on-boundary): cross ≈ 0 ( ${ crossNew . toFixed ( 1 ) } ) `
)
if ( worst === 'ok' ) worst = 'on-boundary'
}
}
}
if ( movedIdx . length === 0 ) {
console . log ( '[verifyMoveBoundary] 이동된 꼭짓점 0개 (buildRawMovedPoints 매칭 실패 가능성)' )
}
console . log ( ` [verifyMoveBoundary] 최종 판정: ${ worst } (moved indices=[ ${ movedIdx . join ( ',' ) } ]) ` )
return worst
} catch ( e ) {
console . 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 ) {
console . 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 ) {
console . warn ( '[safeMovedPointsWithFallback] 붕괴/교차 감지 (로그만)' , {
movedLen : movedPoints . length ,
oldLen : oldPoints . length ,
zeroLenEdges ,
severeReduction ,
selfIntersect ,
} )
}
return movedPoints
}
/ * *
* SkeletonBuilder를 사용하여 스켈레톤을 생성하고 내부선을 그립니다 .
* @ param { string } roofId - 지붕 ID
@ -522,36 +783,49 @@ export const skeletonBuilder = (roofId, canvas, textMode) => {
// 6. 마루이동(용마루 위치 수동 조정)이 설정된 경우 movingLineFromSkeleton 결과로 대체
if ( moveFlowLine !== 0 || moveUpDown !== 0 ) {
const movedPoints = movingLineFromSkeleton ( roofId , canvas )
// [보호 가드] 이동이 골짜기 경계를 넘어가는지 사전 판정
// 'crossed' → 외곽선을 뒤집을 만큼 과한 이동 → 기존 SK/innerLines/skeletonLines 전부 보존 후 조용히 종료
// 'ok' | 'on-boundary' | 'unknown' → 기존 흐름 그대로 진행
// ※ 기존 로직은 전혀 수정하지 않고, 진입부에 한 블록만 추가
const _ _moveVerdict = verifyMoveBoundary ( roofId , canvas )
if ( _ _moveVerdict === 'crossed' ) {
console . warn ( '[skeleton] 경계 넘음(crossed) → 재빌드 스킵 (기존 SK/innerLines/skeletonLines 유지)' )
return
}
const movedPoints = safeMovedPointsWithFallback ( roofId , canvas )
// console.log("movedPoints:::", movedPoints);
// movedPoints를 roof 경계로 확장
// 이번 이동에서 실제로 변경된 포인트만 구분하여, 변경되지 않은 축만 roof 경계로 확장
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
// 이번 이동에서 실제로 변경된 축 판별 (oldPoints vs movedPoints)
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
// 이번에 이동되지 않았는데 roof 경계와 다른 축만 교정
if ( ! xMoved && ! xMatch ) newX = rp . x
if ( ! yMoved && ! yMatch ) newY = rp . y
if ( newX !== mp . x || newY !== mp . y ) {
console . log ( ` point[ ${ i } ] 교정: mp=( ${ mp . x } , ${ mp . y } ) → ( ${ newX } , ${ newY } ), op=( ${ op . x } , ${ op . y } ), xMoved= ${ xMoved } , yMoved= ${ yMoved } ` )
}
return { x : newX , y : newY }
} )
// roofLineContactPoints = correctedPoints
// changRoofLinePoints = correctedPoints
/ *
* [ 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
@ -637,7 +911,13 @@ export const skeletonBuilder = (roofId, canvas, textMode) => {
if ( isOverDetected ) {
// [예외] 오버 감지 시 → 별도 함수에서 스켈레톤 전용 보정 포인트 계산
try {
const corrected = calcOverCorrectedPoints ( changRoofLinePoints , origPoints )
// 누적 이동 보호: 이전 SK 확정 상태(lastPoints)를 전달하여
// 1차 이동에서 이미 확정된 점은 보정 대상에서 제외한다.
const corrected = calcOverCorrectedPointsSafe (
changRoofLinePoints ,
origPoints ,
canvas ? . skeleton ? . lastPoints ? ? null
)
if ( corrected && corrected . length >= 3 ) {
skeletonInputPoints = corrected
console . log ( '[SK_OVER] 보정 포인트 적용:' , skeletonInputPoints . map ( ( p , i ) => ` [ ${ i } ]( ${ Math . round ( p . x ) } , ${ Math . round ( p . y ) } ) ` ) )
@ -685,9 +965,30 @@ export const skeletonBuilder = (roofId, canvas, textMode) => {
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
canvas . skeleton . lastPoints = roofLineContactPoints
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 ]
console . log ( ` [LP-TRACE] lastPoints.save [7]=( ${ _p7 ? . x ? . toFixed ( 1 ) } , ${ _p7 ? . y ? . toFixed ( 1 ) } ) len= ${ rawMovedFull . length } ` )
} catch ( _e ) { }
console . log ( '[skeleton] lastPoints 저장: raw 풀 길이' , rawMovedFull . length , '점' )
} else {
canvas . skeleton . lastPoints = roofLineContactPoints
}
canvas . set ( 'skeleton' , cleanSkeleton )
canvas . renderAll ( )
@ -696,11 +997,12 @@ export const skeletonBuilder = (roofId, canvas, textMode) => {
console . error ( '[SK_ERROR] 스켈레톤 생성 중 오류 발생:' , e )
console . error ( '[SK_ERROR] 입력 좌표:' , changRoofLinePoints . map ( ( p , i ) => ` [ ${ i } ] ( ${ p . x . toFixed ( 2 ) } , ${ p . y . toFixed ( 2 ) } ) ` ) )
console . error ( '[SK_ERROR] geoJSON:' , JSON . stringify ( geoJSONPolygon ) )
// [보호] 예외 시 상태 플래그만 복구하고, 기존 SK/innerLines는 유지한다.
// → 사용자가 힘들게 추가한 라인들이 순간적으로 사라지는 현상 방지.
if ( canvas . skeletonStates ) {
canvas . skeletonStates [ roofId ] = false
canvas . skeletonStates = { }
canvas . skeletonLines = [ ]
}
// 주의: canvas.skeletonLines 는 의도적으로 비우지 않음 (이전 성공 상태 보존)
}
}
@ -943,9 +1245,36 @@ const createInnerLinesFromSkeleton = (roofId, canvas, skeleton, textMode, isOver
// 각 라인 집합 정렬
const sortWallLines = ensureCounterClockwiseLines ( wallLines )
const sortWallBaseLines = ensureCounterClockwiseLines ( wall . baseLines )
// wall.baseLines를 독립적으로 CCW 정렬하면 ㅗ→붕괴 등으로 꼭짓점 집합이 달라질 때
// 시작점(min-Y-then-min-X)이 wallLines와 어긋나 배열이 shift된다.
// wallLines[i]와 wall.baseLines[i]는 초기 생성 시부터 raw-index 1:1 페어링이 유지되므로,
// sortWallLines의 CCW 순서에 대응하는 원 인덱스를 복원하여 같은 순서로 wall.baseLines를 매핑한다.
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 ( )
wallLines . forEach ( ( l , i ) => _rawIdxByKey . set ( _keyOfEdge ( l ) , i ) )
const sortWallBaseLines = sortWallLines . map ( ( sl ) => {
const origIdx = _rawIdxByKey . get ( _keyOfEdge ( sl ) )
return origIdx != null && wall . baseLines [ origIdx ] ? wall . baseLines [ origIdx ] : sl
} )
const sortRoofLines = ensureCounterClockwiseLines ( roofLines )
// [MOVE-TRACE] 진입 스냅샷 (필요 시 주석 해제)
// console.log('[MOVE-TRACE] === getMoveUpDownLine entry ===',
// 'moveUpDown=', roof.moveUpDown,
// 'moveFlowLine=', roof.moveFlowLine)
// sortWallBaseLines.forEach((bl, i) => {
// console.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'
@ -965,6 +1294,19 @@ const createInnerLinesFromSkeleton = (roofId, canvas, skeleton, textMode, isOver
console . log ( ` ${ condition } ::::isStartEnd::::: ` )
// [MOVE-TRACE] index 0 전용 (필요 시 주석 해제)
// if (index === 0) {
// console.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 ` ]
@ -974,7 +1316,7 @@ const createInnerLinesFromSkeleton = (roofId, canvas, skeleton, textMode, isOver
newPStart [ moveAxis ] = roofLine [ ` ${ moveAxis } 1 ` ]
}
const moveDist = Big ( wallBaseLine [ m ] ) . minus ( wallLine [ m ] ) . toNumber( )
const moveDist = Big ( wallBaseLine [ m ] ) . minus ( wallLine [ m ] ) . abs( ) . toNumber( )
const ePoint = { [ moveAxis ] : wallBaseLine [ m ] , [ lineAxis ] : wallBaseLine [ l ] }
if ( isStart ) {
newPStart [ lineAxis ] = wallBaseLine [ l ]
@ -984,7 +1326,9 @@ const createInnerLinesFromSkeleton = (roofId, canvas, skeleton, textMode, isOver
findPoints . push ( { ... ePoint , position : ` ${ condition } _ ${ isStart ? 'start' : 'end' } ` } )
let newPointM = Big ( roofLine [ ` ${ moveAxis } 1 ` ] ) . plus ( moveDist ) . toNumber ( )
let newPointM = Big ( roofLine [ ` ${ moveAxis } 1 ` ] )
[ inSign > 0 ? 'plus' : 'minus' ] ( moveDist )
. toNumber ( )
const pLineL = roofLine [ l ]
const prevIndex = ( index - 1 + sortRoofLines . length ) % sortRoofLines . length
@ -1019,6 +1363,22 @@ const createInnerLinesFromSkeleton = (roofId, canvas, skeleton, textMode, isOver
getAddLine , sortRoofLines , findPoints , innerLines ,
moveAxis , lineAxis , inSign
} ) => {
// [SHOULDER-ADJACENT] 인접 pair 가 SHOULDER_ABSORBED 된 경우 HIP 빗변은 orphan 이 됨.
// 이유: processInBoth 는 roofLine(원본 roof.points 기반) 꼭짓점을 HIP 끝점으로 씀.
// 인접 pair 흡수 = 해당 꼭짓점이 현재 wallBaseLine 토폴로지에 없음 → 공중에 뜬 대각선.
// SHOULDER_ABSORBED 가드(line 1482 인근)와 같은 원인·패턴의 후속 증상.
{
const n = sortWallBaseLines . length
const prevBL = sortWallBaseLines [ ( index - 1 + n ) % n ]
const nextBL = sortWallBaseLines [ ( index + 1 ) % n ]
const prevAbsorbed = ( prevBL ? . attributes ? . planeSize ? ? Infinity ) < 1
const nextAbsorbed = ( nextBL ? . attributes ? . planeSize ? ? Infinity ) < 1
if ( prevAbsorbed || nextAbsorbed ) {
console . log ( ` ⏭️ [pair# ${ index } ] ${ condition } HIP_SKIP adj_absorbed prev= ${ prevAbsorbed } next= ${ nextAbsorbed } ` )
return
}
}
console . log ( ` ${ condition } ::::isStartEnd (both)::::: ` )
// 원본 기준: moveDistY = abs(roofLine.y - wallBaseLine.y), moveDistX = abs(roofLine.x - wallBaseLine.x)
@ -1140,6 +1500,26 @@ const createInnerLinesFromSkeleton = (roofId, canvas, skeleton, textMode, isOver
const roofLine = sortRoofLines [ index ]
const wallBaseLine = sortWallBaseLines [ index ]
// [SHOULDER-ABSORBED] wall.baseLines[k] 가 이동 흡수로 zero-length(planeSize≈0)면
// 해당 edge 는 흡수되어 사라진 상태. pair 생성 자체가 불필요(DIR_MISMATCH + eaveHelpLine 노이즈 유발).
// 참고: 같은 파일 line 666 의 filter(planeSize > 0) 선례와 동일 패턴.
if ( ! wallBaseLine || ( wallBaseLine . attributes ? . planeSize ? ? Infinity ) < 1 ) {
console . log ( ` ⏭️ [pair# ${ index } ] SHOULDER_ABSORBED skip: wb=( ${ Math . round ( wallBaseLine ? . x1 ) } , ${ Math . round ( wallBaseLine ? . y1 ) } )→( ${ Math . round ( wallBaseLine ? . x2 ) } , ${ Math . round ( wallBaseLine ? . y2 ) } ) planeSize= ${ wallBaseLine ? . attributes ? . planeSize } ` )
return
}
// [진단] sortWallLines ↔ sortWallBaseLines 페어링 검증
// collapse 시 ensureCounterClockwiseLines 시작점이 달라지면 인덱스가 어긋남
// 정상: 같은 물리 벽 → 적어도 한쪽 끝점 공유 또는 방향 동일(수직/수평)
{
const wlDir = Math . abs ( wallLine . x2 - wallLine . x1 ) < 0.5 ? 'V' : ( Math . abs ( wallLine . y2 - wallLine . y1 ) < 0.5 ? 'H' : 'D' )
const wbDir = Math . abs ( wallBaseLine . x2 - wallBaseLine . x1 ) < 0.5 ? 'V' : ( Math . abs ( wallBaseLine . y2 - wallBaseLine . y1 ) < 0.5 ? 'H' : 'D' )
const wallIdWl = wallLine . attributes ? . wallId ? ? wallLine . attributes ? . idx ? ? '?'
const wallIdWb = wallBaseLine . attributes ? . wallId ? ? wallBaseLine . attributes ? . idx ? ? '?'
const dirMatch = wlDir === wbDir
console . 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 외곽선 설정
// console.log('index::::', index)
@ -1186,6 +1566,17 @@ const createInnerLinesFromSkeleton = (roofId, canvas, skeleton, textMode, isOver
const getAddLine = ( p1 , p2 , stroke = '' , lineType = 'eaveHelpLine' ) => {
movedLines . push ( { index , p1 , p2 } )
// [진단] eaveHelpLine 생성 추적: 어느 index/어느 wallBaseLine-wallLine 쌍에서 만들어지는지
console . log ( '🎯 [getAddLine]' , {
index ,
lineType ,
p1 : { x : Math . round ( p1 . x ) , y : Math . round ( p1 . y ) } ,
p2 : { x : Math . round ( p2 . x ) , y : Math . round ( p2 . y ) } ,
wallLine : ` ( ${ Math . round ( wallLine . x1 ) } , ${ Math . round ( wallLine . y1 ) } )→( ${ Math . round ( wallLine . x2 ) } , ${ Math . round ( wallLine . y2 ) } ) ` ,
wallBaseLine : ` ( ${ Math . round ( wallBaseLine . x1 ) } , ${ Math . round ( wallBaseLine . y1 ) } )→( ${ Math . round ( wallBaseLine . x2 ) } , ${ Math . round ( wallBaseLine . y2 ) } ) ` ,
wallBaseLen : Math . round ( Math . hypot ( wallBaseLine . x2 - wallBaseLine . x1 , wallBaseLine . y2 - wallBaseLine . y1 ) ) ,
} )
const dx = Math . abs ( p2 . x - p1 . x ) ;
const dy = Math . abs ( p2 . y - p1 . y ) ;
const isDiagonal = dx > 0.5 && dy > 0.5 ; // x, y 변화가 모두 있으면 대각선
@ -1247,6 +1638,16 @@ const createInnerLinesFromSkeleton = (roofId, canvas, skeleton, textMode, isOver
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
console . 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 ) {
@ -1866,17 +2267,17 @@ function processEavesEdge(roofId, canvas, skeleton, edgeResult, skeletonLines) {
return false
}
console . 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 ,
} )
// console.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 ] ;
@ -1886,7 +2287,7 @@ function processEavesEdge(roofId, canvas, skeleton, edgeResult, skeletonLines) {
// 확장된 외곽선에 해당하는 edge는 스킵
if ( _isSkipOuter ) {
console . 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 ) } } )
// console.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
}
@ -1895,10 +2296,10 @@ function processEavesEdge(roofId, canvas, skeleton, edgeResult, skeletonLines) {
//console.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 ) ? '수평' : '대각'
console . 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 ) } ` )
// 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) ? '수평' : '대각'
// console.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 ) ;
// }
@ -1978,7 +2379,7 @@ function logDeadEndLines(skeletonLines, roof) {
const dy = Math . abs ( line . p2 . y - line . p1 . y ) ;
if ( dx < 0.5 && dy < 0.5 ) {
console . log ( '⚠️ [logDeadEndLines] 제로 길이:' , { idx , p1 : { x : Math . round ( line . p1 . x ) , y : Math . round ( line . p1 . y ) } } ) ;
// console.log('⚠️ [logDeadEndLines] 제로 길이:', { idx, p1: { x: Math.round(line.p1.x), y: Math.round(line.p1.y) } });
return ;
}
@ -1997,7 +2398,7 @@ function logDeadEndLines(skeletonLines, roof) {
}
} ) ;
console . log ( ` 🔍 [logDeadEndLines] 전체: ${ skeletonLines . length } , 유니크: ${ uniqueLines . length } , dead end 꼭짓점: ${ deadEndVertices . size } ` ) ;
// console.log(`🔍 [logDeadEndLines] 전체: ${skeletonLines.length}, 유니크: ${uniqueLines.length}, dead end 꼭짓점: ${deadEndVertices.size}`);
}
function findMatchingLine ( edgePolygon , roof , roofPoints ) {
@ -2106,6 +2507,59 @@ function processGableEdge(edgeResult, baseLines, skeletonLines, selectBaseLine,
* @ 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 )
)
console . 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 } ) )
@ -3346,22 +3800,22 @@ function getOrderedBasePoints(baseLines) {
function createOrderedBasePoints ( roofPoints , baseLines ) {
const basePoints = [ ] ;
// baseLines에서 연결된 순서대로 점들을 추출
const orderedBasePoints = getOrderedBasePoints ( baseLines ) ;
// roofPoints의 개수와 맞추기
if ( orderedBasePoints . length >= roofPoints . length ) {
return orderedBasePoints . slice ( 0 , roofPoints . length ) ;
}
// 부족한 경우 roofPoints 기반으로 보완
roofPoints . forEach ( ( roofPoint , index ) => {
if ( index < orderedBasePoints . length ) {
basePoints . push ( orderedBasePoints [ index ] ) ;
// 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 {
basePoints . push ( { ... roofPoint } ) ; // fallback
// baseLines 수가 부족한 예외 상황에만 roof 좌표로 fallback
basePoints . push ( { ... roofPoints [ i ] } ) ;
}
} ) ;
}
return basePoints ;
}
@ -3786,14 +4240,20 @@ function isPointOnLineSegment(point, lineStart, lineEnd, tolerance = 0.1) {
* /
function updateAndAddLine ( innerLines , targetPoint ) {
// 1. Find the line containing the target point (HIP 라인 제외 - HIP 위의 점이 잘못 매칭되는 것 방지)
// 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 )
const foundLine = findLineContainingPoint ( nonHipLines , targetPoint ) ;
console . log ( ` [updateAndAddLine] position= ${ targetPoint . position } point=( ${ targetPoint . x } , ${ targetPoint . y } ) ` ,
foundLine ? ` foundLine: ( ${ foundLine . x1 } , ${ foundLine . y1 } )→( ${ foundLine . x2 } , ${ foundLine . y2 } ) name= ${ foundLine . name || foundLine . lineName || '?' } type= ${ foundLine . attributes ? . type || '?' } ` : 'NOT FOUND' ,
` innerLines count= ${ innerLines . length } , nonHip= ${ nonHipLines . length } ` )
let foundLine = findLineContainingPoint ( nonHipLines , targetPoint ) ;
if ( ! foundLine ) {
console . warn ( 'No line found containing the target point' ) ;
foundLine = findLineContainingPoint ( innerLines , targetPoint ) ;
if ( foundLine ) {
console . 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 ) {
console . warn ( ` [updateAndAddLine] NOT FOUND position= ${ targetPoint . position } point=( ${ targetPoint . x . toFixed ( 2 ) } , ${ targetPoint . y . toFixed ( 2 ) } ) ` ) ;
return [ ... innerLines ] ;
}