diff --git a/src/components/fabric/QPolygon.js b/src/components/fabric/QPolygon.js index 353cb344..337d90e3 100644 --- a/src/components/fabric/QPolygon.js +++ b/src/components/fabric/QPolygon.js @@ -6,7 +6,7 @@ import { calculateAngle, drawGableRoof, drawRoofByAttribute, drawShedRoof, toGeo import * as turf from '@turf/turf' import { LINE_TYPE, POLYGON_TYPE } from '@/common/common' import Big from 'big.js' -import { drawSkeletonRidgeRoof } from '@/util/skeleton-utils' +import { drawSkeletonRidgeRoof, verifyMoveBoundary } from '@/util/skeleton-utils' export const QPolygon = fabric.util.createClass(fabric.Polygon, { type: 'QPolygon', @@ -387,6 +387,22 @@ export const QPolygon = fabric.util.createClass(fabric.Polygon, { * @param settingModalFirstOptions */ drawHelpLine(settingModalFirstOptions) { + // [보호 가드] 이번 이동이 골짜기 경계를 넘어가는 과한 이동이면 + // 아예 초기화/재빌드 자체를 수행하지 않고 현재 상태 그대로 유지한다. + // ※ 기존 drawHelpLine 로직은 건드리지 않고, 진입부에 가드 한 블록만 추가. + // ※ 최초 그리기·offset 변경 등 moveUpDown=0 / moveFlowLine=0 케이스는 + // verifyMoveBoundary 가 'ok' 를 돌려주므로 영향 없음. + try { + const __verdict = verifyMoveBoundary(this.id, this.canvas) + if (__verdict === 'crossed') { + console.warn('[drawHelpLine] 경계 넘음(crossed) → 초기화/재빌드 스킵 (기존 innerLines/SK 유지)') + return + } + } catch (e) { + // 판정기 자체 실패는 무시하고 기존 흐름 진행 (안전한 no-op) + console.warn('[drawHelpLine] verifyMoveBoundary 예외 → 기존 흐름 진행:', e) + } + /* innerLines 초기화 */ this.canvas .getObjects() diff --git a/src/components/floor-plan/modal/circuitTrestle/CircuitTrestleSetting.jsx b/src/components/floor-plan/modal/circuitTrestle/CircuitTrestleSetting.jsx index cb18bbbd..068d6b18 100644 --- a/src/components/floor-plan/modal/circuitTrestle/CircuitTrestleSetting.jsx +++ b/src/components/floor-plan/modal/circuitTrestle/CircuitTrestleSetting.jsx @@ -60,7 +60,6 @@ export default function CircuitTrestleSetting({ id }) { const { setModuleStatisticsData } = useCircuitTrestle() const { handleCanvasToPng } = useImgLoader() const moduleSelectionData = useRecoilValue(moduleSelectionDataState) - const passivityCircuitAllocationRef = useRef() const { setIsGlobalLoading } = useContext(QcastContext) const originCanvasViewPortTransform = useRef([]) @@ -835,9 +834,23 @@ export default function CircuitTrestleSetting({ id }) { // 승압설정 목록 조회 const getStepUpListData = () => { + // PCS 인스턴스별 실제 담당 모듈 타입 집합 수집 (Cross Mix DC3 접속함 분리용) + const pcsModuleTpCds = {} + canvas + .getObjects() + .filter((obj) => obj.name === POLYGON_TYPE.MODULE && obj.circuit && obj.pcsItemId) + .forEach((module) => { + const pcsInstanceId = module.circuit?.circuitInfo?.id + const tpCd = module.moduleInfo?.moduleTpCd + if (!pcsInstanceId || !tpCd) return + if (!pcsModuleTpCds[pcsInstanceId]) pcsModuleTpCds[pcsInstanceId] = new Set() + pcsModuleTpCds[pcsInstanceId].add(tpCd) + }) + const pcs = [] console.log(stepUpListData) stepUpListData[0].pcsItemList.map((item, index) => { + const targetTpCds = pcsModuleTpCds[selectedModels[index]?.id] return item.serQtyList .filter((serQty) => serQty.selected && serQty.paralQty > 0) .forEach((serQty) => { @@ -847,9 +860,19 @@ export default function CircuitTrestleSetting({ id }) { pcsItemId: item.itemId, pscOptCd: getPcsOptCd(index), paralQty: serQty.paralQty, - // 접속함 중복 체크: 동일 itemId는 1개만, 다르면 각각 올림 + // 접속함: PCS가 실제 담당하는 모듈 타입으로 필터 후 itemId 기준 dedupe + // (Cross Mix 시 다른 모듈 계열의 conn 제외 / 단일·동계열 Mix는 기존과 동일 결과) connections: item.connList?.length - ? [...new Map(item.connList.map((conn) => [conn.itemId, { connItemId: conn.itemId, connMaxParalCnt: conn.connMaxParalCnt }])).values()] + ? [ + ...new Map( + item.connList + .filter((conn) => !targetTpCds || !conn.moduleTpCd || targetTpCds.has(conn.moduleTpCd)) + .map((conn) => [ + conn.itemId, + { connItemId: conn.itemId, connMaxParalCnt: conn.connMaxParalCnt, moduleTpCd: conn.moduleTpCd }, + ]), + ).values(), + ] : [], }) // return { @@ -1044,7 +1067,7 @@ export default function CircuitTrestleSetting({ id }) { {tabNum === 1 && allocationType === ALLOCATION_TYPE.AUTO && } {tabNum === 1 && allocationType === ALLOCATION_TYPE.PASSIVITY && ( - + )} {tabNum === 2 && } diff --git a/src/components/floor-plan/modal/circuitTrestle/step/StepUp.jsx b/src/components/floor-plan/modal/circuitTrestle/step/StepUp.jsx index da6ea213..58a8e190 100644 --- a/src/components/floor-plan/modal/circuitTrestle/step/StepUp.jsx +++ b/src/components/floor-plan/modal/circuitTrestle/step/StepUp.jsx @@ -345,6 +345,7 @@ export default function StepUp(props) { itemId: conn.itemId ?? '', itemNm: conn.itemNm ?? '', vstuParalCnt: conn.vstuParalCnt ?? 0, + moduleTpCd: conn.moduleTpCd, })) } @@ -576,7 +577,21 @@ export default function StepUp(props) { * 현재 선택된 값들을 가져오는 함수 추가 */ const getCurrentSelections = () => { - const selectedValues = stepUpListData[0].pcsItemList.forEach((item) => { + // PCS 인스턴스별 실제 담당 모듈 타입 집합 수집 (Cross Mix DC3 접속함 분리용) + const pcsModuleTpCds = {} + canvas + .getObjects() + .filter((obj) => obj.name === POLYGON_TYPE.MODULE && obj.circuit && obj.pcsItemId) + .forEach((module) => { + const pcsInstanceId = module.circuit?.circuitInfo?.id + const tpCd = module.moduleInfo?.moduleTpCd + if (!pcsInstanceId || !tpCd) return + if (!pcsModuleTpCds[pcsInstanceId]) pcsModuleTpCds[pcsInstanceId] = new Set() + pcsModuleTpCds[pcsInstanceId].add(tpCd) + }) + + const selectedValues = stepUpListData[0].pcsItemList.forEach((item, index) => { + const targetTpCds = pcsModuleTpCds[selectedModels[index]?.id] item.serQtyList.filter((serQty) => serQty.selected) return item.serQtyList.map((serQty) => { return { @@ -585,17 +600,21 @@ export default function StepUp(props) { pcsItemId: serQty.itemId, pcsOptCd: seletedOption, paralQty: serQty.paralQty, - // 접속함 중복 체크: 동일 itemId는 1개만, 다르면 각각 올림 + // 접속함: PCS가 실제 담당하는 모듈 타입으로 필터 후 itemId 기준 dedupe + // (Cross Mix 시 다른 모듈 계열의 conn 제외 / 단일·동계열 Mix는 기존과 동일 결과) connections: item.connList?.length ? [ ...new Map( - item.connList.map((conn) => [ - conn.itemId, - { - connItemId: conn.itemId, - connMaxParalCnt: conn.connMaxParalCnt, - }, - ]), + item.connList + .filter((conn) => !targetTpCds || !conn.moduleTpCd || targetTpCds.has(conn.moduleTpCd)) + .map((conn) => [ + conn.itemId, + { + connItemId: conn.itemId, + connMaxParalCnt: conn.connMaxParalCnt, + moduleTpCd: conn.moduleTpCd, + }, + ]), ).values(), ] : [], diff --git a/src/hooks/common/useMasterController.js b/src/hooks/common/useMasterController.js index 36f5bd61..971b42ce 100644 --- a/src/hooks/common/useMasterController.js +++ b/src/hooks/common/useMasterController.js @@ -264,6 +264,8 @@ export function useMasterController() { */ const getPcsAutoRecommendList = async (params = null) => { return await post({ url: '/api/v1/master/getPcsAutoRecommendList', data: params }).then((res) => { + console.log('[moduleTpCd] getPcsAutoRecommendList req:', params) + console.log('[moduleTpCd] getPcsAutoRecommendList res pcsItemList:', res?.data?.pcsItemList?.map((p) => ({ itemId: p.itemId, pcsTpCd: p.pcsTpCd, connList: p.connList?.map((c) => ({ itemId: c.itemId, moduleTpCd: c.moduleTpCd })) }))) return res }) } @@ -286,6 +288,8 @@ export function useMasterController() { */ const getPcsVoltageChk = async (params = null) => { return await post({ url: '/api/v1/master/getPcsVoltageChk', data: params }).then((res) => { + console.log('[moduleTpCd] getPcsVoltageChk req:', params) + console.log('[moduleTpCd] getPcsVoltageChk res pcsItemList:', res?.data?.pcsItemList?.map((p) => ({ itemId: p.itemId, pcsTpCd: p.pcsTpCd, connList: p.connList?.map((c) => ({ itemId: c.itemId, moduleTpCd: c.moduleTpCd })) }))) return res }) } @@ -322,11 +326,15 @@ export function useMasterController() { } return await post({ url: '/api/v1/master/getPcsVoltageStepUpList', data: params }).then((res) => { + console.log('[moduleTpCd] getPcsVoltageStepUpList req:', params) + console.log('[moduleTpCd] getPcsVoltageStepUpList res pcsItemList:', res?.data?.pcsItemList?.map((p) => ({ itemId: p.itemId, pcsTpCd: p.pcsTpCd, connList: p.connList?.map((c) => ({ itemId: c.itemId, moduleTpCd: c.moduleTpCd })) }))) return res }) } const getQuotationItem = async (params) => { + console.log('[moduleTpCd] getQuotationItem req pcses.connections:', params?.pcses?.map((p) => ({ pcsItemId: p.pcsItemId, connections: p.connections }))) + console.log('[moduleTpCd] getQuotationItem full req:', params) return await post({ url: '/api/v1/master/getQuotationItem', data: params }) .then((res) => { return res @@ -355,6 +363,8 @@ export function useMasterController() { */ const getPcsConnOptionItemList = async (params = null) => { return await post({ url: '/api/v1/master/getPcsConnOptionItemList', data: params }).then((res) => { + console.log('[moduleTpCd] getPcsConnOptionItemList req:', params) + console.log('[moduleTpCd] getPcsConnOptionItemList res pcsItemList:', res?.data?.pcsItemList?.map((p) => ({ itemId: p.itemId, pcsTpCd: p.pcsTpCd, connList: p.connList?.map((c) => ({ itemId: c.itemId, moduleTpCd: c.moduleTpCd })) }))) return res }) } diff --git a/src/hooks/module/useTrestle.js b/src/hooks/module/useTrestle.js index b24e28d1..c13dc67f 100644 --- a/src/hooks/module/useTrestle.js +++ b/src/hooks/module/useTrestle.js @@ -2601,8 +2601,16 @@ export const useTrestle = () => { const { constTp } = moduleSelection.construction const { addRoof } = moduleSelection + // 서브모듈 코드 판별: 서로 다른 계열 혼합(예: DC3=D+C3)일 때만 서브모듈 코드 세팅 + // 단일(C3) 또는 같은 계열 혼합(C1C2C3)은 빈 문자열 + const subCodes = module.itemTp?.match(/[A-Z]\d*/g) || [] + const uniquePrefixes = new Set(subCodes.map((code) => code.charAt(0))) + const isDifferentSeriesMix = subCodes.length > 1 && uniquePrefixes.size > 1 + const subModuleTpCd = isDifferentSeriesMix ? surface.modules?.[0]?.moduleInfo?.moduleTpCd || '' : '' + return { moduleTpCd: module.itemTp, + subModuleTpCd, roofMatlCd: parent.roofMaterial.roofMatlCd, mixMatlNo: module.mixMatlNo, raftBaseCd: addRoof.raft ?? addRoof.raftBaseCd, diff --git a/src/util/skeleton-utils.js b/src/util/skeleton-utils.js index f713693b..88096345 100644 --- a/src/util/skeleton-utils.js +++ b/src/util/skeleton-utils.js @@ -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]; }