diff --git a/src/components/estimate/popup/EstimateCopyPop.jsx b/src/components/estimate/popup/EstimateCopyPop.jsx
index 564a1aae..8a21f5c6 100644
--- a/src/components/estimate/popup/EstimateCopyPop.jsx
+++ b/src/components/estimate/popup/EstimateCopyPop.jsx
@@ -2,6 +2,8 @@
import { useEffect, useState, useContext, useRef } from 'react'
import { useMessage } from '@/hooks/useMessage'
import { useAxios } from '@/hooks/useAxios'
+import { useRecoilValue } from 'recoil'
+import { globalLocaleStore } from '@/store/localeAtom'
import Select from 'react-select'
import { SessionContext } from '@/app/SessionProvider'
import { isEmptyArray, isObjectNotEmpty } from '@/util/common-utils'
@@ -10,24 +12,35 @@ import { useEstimateController } from '@/hooks/floorPlan/estimate/useEstimateCon
export default function EstimateCopyPop({ planNo, setEstimateCopyPopupOpen }) {
const { getMessage } = useMessage()
const { get } = useAxios()
-
+ const globalLocale = useRecoilValue(globalLocaleStore)
const { handleEstimateCopy, estimateContextState } = useEstimateController(planNo, true)
const { session } = useContext(SessionContext)
+ // storeLvl 기반 동적 라벨
+ const lvl = session?.storeLvl || '1'
+ const nextLvl = Number(lvl) + 1
+ const saleStoreLabel = globalLocale === 'ko'
+ ? `${lvl}차 판매점명 / ID`
+ : `${lvl}次販売店名/ID`
+ const otherSaleStoreLabel = globalLocale === 'ko'
+ ? `${nextLvl}차+하위 판매점명 / ID`
+ : `${nextLvl}次下位販売店名/ID`
+
const [saleStoreList, setSaleStoreList] = useState([]) // 판매점 리스트
const [favoriteStoreList, setFavoriteStoreList] = useState([]) //즐겨찾기한 판매점목록
const [showSaleStoreList, setShowSaleStoreList] = useState([]) //보여줄 판매점목록
const [otherSaleStoreList, setOtherSaleStoreList] = useState([])
const [originOtherSaleStoreList, setOriginOtherSaleStoreList] = useState([])
- const [saleStoreId, setSaleStoreId] = useState('') //선택한 1차점
- const [otherSaleStoreId, setOtherSaleStoreId] = useState('') //선택한 1차점 이외
+ const [saleStoreId, setSaleStoreId] = useState('') //선택한 상위점
+ const [saleStoreName, setSaleStoreName] = useState('') //선택한 상위점명
+ const [otherSaleStoreId, setOtherSaleStoreId] = useState('') //선택한 하위점
const [sendPlanNo, setSendPlanNo] = useState('1')
const [copyReceiveUser, setCopyReceiveUser] = useState('')
- const ref = useRef() //2차점 자동완성 초기화용
+ const ref = useRef() //하위점 자동완성 초기화용
useEffect(() => {
let url
@@ -40,8 +53,9 @@ export default function EstimateCopyPop({ planNo, setEstimateCopyPopupOpen }) {
if (session.storeLvl === '1') {
url = `/api/object/saleStore/${session?.storeId}/list?firstFlg=1&userId=${session?.userId}`
} else {
- //T01 or 1차점만 복사버튼 노출됨으로
- // url = `/api/object/saleStore/${session?.storeId}/list?firstFlg=1&userId=${session?.userId}`
+ // 2차+ 판매점: 자기 자신이 상위점, 하위점 목록 조회
+ setSaleStoreId(session?.storeId)
+ url = `/api/object/saleStore/${session?.storeId}/childList?storeLvl=${session?.storeLvl}&userId=${session?.userId}`
}
}
@@ -91,7 +105,14 @@ export default function EstimateCopyPop({ planNo, setEstimateCopyPopupOpen }) {
setOtherSaleStoreList(otherList)
} else {
- //T01 or 1차점만 복사버튼 노출됨으로
+ // 2차+ 판매점: 자기 자신은 상위점명으로, 나머지는 하위점 선택 목록으로 설정
+ const myself = res.find((row) => row.saleStoreId === session?.storeId)
+ if (myself) {
+ setSaleStoreName(myself.saleStoreName)
+ }
+ otherList = res.filter((row) => row.saleStoreId !== session?.storeId)
+ setOtherSaleStoreList(otherList)
+ setOriginOtherSaleStoreList(otherList)
}
}
}
@@ -119,7 +140,7 @@ export default function EstimateCopyPop({ planNo, setEstimateCopyPopupOpen }) {
}
}
- // 1차점 변경 이벤트
+ // 상위점 변경 이벤트
const onSelectionChange = (key) => {
if (isObjectNotEmpty(key)) {
if (key.saleStoreId === saleStoreId) {
@@ -154,7 +175,7 @@ export default function EstimateCopyPop({ planNo, setEstimateCopyPopupOpen }) {
}
}
- // 2차점 변경 이벤트
+ // 하위점 변경 이벤트
const onSelectionChange2 = (key) => {
if (isObjectNotEmpty(key)) {
if (key.saleStoreId === otherSaleStoreId) {
@@ -169,7 +190,7 @@ export default function EstimateCopyPop({ planNo, setEstimateCopyPopupOpen }) {
}
}
- //2차점 자동완성 초기화
+ //하위점 자동완성 초기화
const handleClear = () => {
if (ref.current) {
ref.current.clearValue()
@@ -198,7 +219,7 @@ export default function EstimateCopyPop({ planNo, setEstimateCopyPopupOpen }) {
- {getMessage('estimate.detail.estimateCopyPopup.label.saleStoreId')} *
+ {saleStoreLabel} *
{session.storeId === 'T01' && (
@@ -247,9 +268,17 @@ export default function EstimateCopyPop({ planNo, setEstimateCopyPopupOpen }) {
{saleStoreId}
)}
+ {session.storeId !== 'T01' && session.storeLvl !== '1' && (
+
+ )}
-
{getMessage('estimate.detail.estimateCopyPopup.label.otherSaleStoreId')}
+
{otherSaleStoreLabel}
{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 14f6aa03..58a8e190 100644
--- a/src/components/floor-plan/modal/circuitTrestle/step/StepUp.jsx
+++ b/src/components/floor-plan/modal/circuitTrestle/step/StepUp.jsx
@@ -134,9 +134,7 @@ export default function StepUp(props) {
const moduleIds = targetSurface.modules.map((module) => module.id)
/** 기존 모듈 텍스트 삭제 */
- canvas
- .getObjects()
- .filter((obj) => moduleIds.includes(obj.parentId))
+ ;[...canvas.getObjects().filter((obj) => moduleIds.includes(obj.parentId))]
.forEach((text) => canvas.remove(text))
/** 새로운 모듈 회로 정보 추가 */
@@ -347,6 +345,7 @@ export default function StepUp(props) {
itemId: conn.itemId ?? '',
itemNm: conn.itemNm ?? '',
vstuParalCnt: conn.vstuParalCnt ?? 0,
+ moduleTpCd: conn.moduleTpCd,
}))
}
@@ -410,61 +409,100 @@ export default function StepUp(props) {
* 주어진 stepUpListData 를 기준으로 캔버스의 모든 module circuit 텍스트를 다시 그린다.
* 모든 pcsItem 의 selected serQty 를 순회해 적용한다.
*/
- const applyCircuitsToCanvas = (stepUpListSrc) => {
+ /**
+ * 모듈에 circuit 텍스트를 추가하는 공통 함수
+ */
+ const addCircuitTextToModule = (module) => {
+ const targetModule = canvas.getObjects().find((obj) => obj.id === module.uniqueId)
+ if (targetModule && module.circuit !== '' && module.circuit !== null) {
+ const moduleCircuitText = new fabric.Text(module.circuit, {
+ left: targetModule.left + targetModule.width / 2,
+ top: targetModule.top + targetModule.height / 2,
+ fontFamily: circuitNumberText.fontFamily.value,
+ fontWeight: circuitNumberText.fontWeight.value.toLowerCase().includes('bold') ? 'bold' : 'normal',
+ fontStyle: circuitNumberText.fontWeight.value.toLowerCase().includes('italic') ? 'italic' : 'normal',
+ fontSize: circuitNumberText.fontSize.value,
+ fill: circuitNumberText.fontColor.value,
+ width: targetModule.width,
+ height: targetModule.height,
+ textAlign: 'center',
+ originX: 'center',
+ originY: 'center',
+ name: 'circuitNumber',
+ parentId: targetModule.id,
+ circuitInfo: module.pcsItemId,
+ visible: isDisplayCircuitNumber,
+ })
+ targetModule.circuit = moduleCircuitText
+ targetModule.pcsItemId = module.pcsItemId
+ targetModule.circuitNumber = module.circuit
+ canvas.add(moduleCircuitText)
+ }
+ }
+
+ const applyCircuitsToCanvas = (stepUpListSrc, mainIdx, subIdx) => {
if (!stepUpListSrc?.[0]?.pcsItemList) return
- /** 모든 모듈 circuit 데이터 초기화 */
- canvas
- .getObjects()
- .filter((obj) => obj.name === POLYGON_TYPE.MODULE)
- .forEach((module) => {
+ if (mainIdx >= 1) {
+ /**
+ * 우측 PCS (mainIdx >= 1) 클릭 시:
+ * 해당 pcsItem 의 selected serQty 를 찾아서 moduleList 의 circuit 만 갱신한다.
+ * 좌측 PCS 의 circuit 은 그대로 유지.
+ */
+ const pcsItem = stepUpListSrc[0].pcsItemList[mainIdx]
+ const sel = pcsItem?.serQtyList?.find((sq) => sq.selected)
+ if (!sel) return
+
+ // 해당 pcsItem 의 moduleList 에 포함된 모듈의 기존 circuit 텍스트만 제거
+ const moduleUniqueIds = new Set()
+ sel.roofSurfaceList.forEach((rs) => {
+ rs.moduleList.forEach((m) => moduleUniqueIds.add(m.uniqueId))
+ })
+
+ // 해당 모듈의 circuit 데이터 초기화 + 기존 텍스트 제거
+ const targetModuleObjs = canvas.getObjects().filter(
+ (obj) => obj.name === POLYGON_TYPE.MODULE && moduleUniqueIds.has(obj.id),
+ )
+ targetModuleObjs.forEach((module) => {
module.circuit = null
module.circuitNumber = null
module.pcsItemId = null
})
- /** 기존 circuit 텍스트 객체 모두 제거 */
- canvas
- .getObjects()
- .filter((obj) => obj.name === 'circuitNumber')
- .forEach((text) => canvas.remove(text))
+ const textsToRemove = [...canvas.getObjects().filter(
+ (obj) => obj.name === 'circuitNumber' && moduleUniqueIds.has(obj.parentId),
+ )]
+ textsToRemove.forEach((text) => canvas.remove(text))
- stepUpListSrc[0].pcsItemList.forEach((pcsItem) => {
- const sel = pcsItem.serQtyList?.find((sq) => sq.selected)
- if (!sel) return
+ // 새 circuit 적용
sel.roofSurfaceList.forEach((roofSurface) => {
- const targetSurface = canvas.getObjects().filter((obj) => obj.id === roofSurface.roofSurfaceId)[0]
- if (!targetSurface) return
+ roofSurface.moduleList.forEach((module) => addCircuitTextToModule(module))
+ })
+ } else {
+ /**
+ * 좌측 PCS (mainIdx === 0) 클릭 시:
+ * 전체 모듈 circuit 초기화 후 모든 pcsItem 의 selected 를 적용
+ */
+ canvas
+ .getObjects()
+ .filter((obj) => obj.name === POLYGON_TYPE.MODULE)
+ .forEach((module) => {
+ module.circuit = null
+ module.circuitNumber = null
+ module.pcsItemId = null
+ })
- roofSurface.moduleList.forEach((module) => {
- const targetModule = canvas.getObjects().find((obj) => obj.id === module.uniqueId)
- if (targetModule && module.circuit !== '' && module.circuit !== null) {
- const moduleCircuitText = new fabric.Text(module.circuit, {
- left: targetModule.left + targetModule.width / 2,
- top: targetModule.top + targetModule.height / 2,
- fontFamily: circuitNumberText.fontFamily.value,
- fontWeight: circuitNumberText.fontWeight.value.toLowerCase().includes('bold') ? 'bold' : 'normal',
- fontStyle: circuitNumberText.fontWeight.value.toLowerCase().includes('italic') ? 'italic' : 'normal',
- fontSize: circuitNumberText.fontSize.value,
- fill: circuitNumberText.fontColor.value,
- width: targetModule.width,
- height: targetModule.height,
- textAlign: 'center',
- originX: 'center',
- originY: 'center',
- name: 'circuitNumber',
- parentId: targetModule.id,
- circuitInfo: module.pcsItemId,
- visible: isDisplayCircuitNumber,
- })
- targetModule.circuit = moduleCircuitText
- targetModule.pcsItemId = module.pcsItemId
- targetModule.circuitNumber = module.circuit
- canvas.add(moduleCircuitText)
- }
+ const circuitTexts = [...canvas.getObjects().filter((obj) => obj.name === 'circuitNumber')]
+ circuitTexts.forEach((text) => canvas.remove(text))
+
+ stepUpListSrc[0].pcsItemList.forEach((pcsItem) => {
+ const sel = pcsItem.serQtyList?.find((sq) => sq.selected)
+ if (!sel) return
+ sel.roofSurfaceList.forEach((roofSurface) => {
+ roofSurface.moduleList.forEach((module) => addCircuitTextToModule(module))
})
})
- })
+ }
canvas.renderAll()
setModuleStatisticsData()
@@ -483,8 +521,7 @@ export default function StepUp(props) {
setStepUpListData(tempStepUpListData)
/** PCS 2개 이상 또는 첫번째 PCS 선택 시에만 실행 */
- const needsRefetch =
- tempStepUpListData[0].pcsItemList.length > 1 && mainIdx === 0
+ const needsRefetch = tempStepUpListData[0].pcsItemList.length > 1 && mainIdx === 0
if (needsRefetch) {
/** 파워컨디셔너 옵션 조회 요청 파라미터 */
@@ -522,10 +559,10 @@ export default function StepUp(props) {
const dataArray = Array.isArray(res.data) ? res.data : [res.data]
const newStepUpListData = formatStepUpListData(dataArray)
setStepUpListData(newStepUpListData)
- applyCircuitsToCanvas(newStepUpListData)
+ applyCircuitsToCanvas(newStepUpListData, mainIdx, subIdx)
} else {
swalFire({ text: getMessage('common.message.send.error') })
- applyCircuitsToCanvas(tempStepUpListData)
+ applyCircuitsToCanvas(tempStepUpListData, mainIdx, subIdx)
}
})
return
@@ -533,14 +570,28 @@ export default function StepUp(props) {
}
/** API 재조회가 없는 경로: 현재 tempStepUpListData 로 즉시 캔버스 갱신 */
- applyCircuitsToCanvas(tempStepUpListData)
+ applyCircuitsToCanvas(tempStepUpListData, mainIdx, subIdx)
}
/**
* 현재 선택된 값들을 가져오는 함수 추가
*/
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 {
@@ -549,9 +600,23 @@ 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 }])).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(),
+ ]
: [],
}
})
diff --git a/src/components/floor-plan/modal/outerlinesetting/WallLineSetting.jsx b/src/components/floor-plan/modal/outerlinesetting/WallLineSetting.jsx
index 99fefe9f..0919bf60 100644
--- a/src/components/floor-plan/modal/outerlinesetting/WallLineSetting.jsx
+++ b/src/components/floor-plan/modal/outerlinesetting/WallLineSetting.jsx
@@ -10,6 +10,7 @@ import Angle from '@/components/floor-plan/modal/lineTypes/Angle'
import DoublePitch from '@/components/floor-plan/modal/lineTypes/DoublePitch'
import Diagonal from '@/components/floor-plan/modal/lineTypes/Diagonal'
import { usePopup } from '@/hooks/usePopup'
+import { useSwal } from '@/hooks/useSwal'
import { useState } from 'react'
import Image from 'next/image'
import { v4 as uuidv4 } from 'uuid'
@@ -18,6 +19,7 @@ export default function WallLineSetting(props) {
const { id } = props
const { addPopup, closePopup } = usePopup()
const { getMessage } = useMessage()
+ const { swalFire } = useSwal()
const [propertiesId, setPropertiesId] = useState(uuidv4())
const [useCalcPad, setUseCalcPad] = useState(false)
const {
@@ -182,10 +184,11 @@ export default function WallLineSetting(props) {
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/floorPlan/useImgLoader.js b/src/hooks/floorPlan/useImgLoader.js
index 47eea944..036a7605 100644
--- a/src/hooks/floorPlan/useImgLoader.js
+++ b/src/hooks/floorPlan/useImgLoader.js
@@ -106,6 +106,18 @@ export function useImgLoader() {
canvas.renderAll()
+ // 렌더링 완료 대기 (최대 3초, 초과 시 에러)
+ const renderReady = await new Promise((resolve) => {
+ const timeout = setTimeout(() => resolve(false), 3000)
+ requestAnimationFrame(() => {
+ clearTimeout(timeout)
+ resolve(true)
+ })
+ })
+ if (!renderReady) {
+ throw new Error('RENDER_TIMEOUT')
+ }
+
const formData = new FormData()
// 3. 도면 오브젝트의 바운딩 박스 계산
@@ -164,8 +176,9 @@ export function useImgLoader() {
} catch (e) {
canvas.backgroundColor = originalBg
canvas.renderAll()
- setIsGlobalLoading(false)
+ toggleLineEtc(true)
console.log('🚀 ~ handleCanvasToPng ~ e:', e)
+ throw e
}
}
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/hooks/roofcover/useMovementSetting.js b/src/hooks/roofcover/useMovementSetting.js
index 92f2e2c0..d4f9fc74 100644
--- a/src/hooks/roofcover/useMovementSetting.js
+++ b/src/hooks/roofcover/useMovementSetting.js
@@ -11,6 +11,14 @@ import { calcLinePlaneSize } from '@/util/qpolygon-utils'
import { getSelectLinePosition } from '@/util/skeleton-utils'
import { useMouse } from '@/hooks/useMouse'
+// [정책 flag] 오버 이동(인접 라인 반전 = polygon self-cross) 입력 처리 방법.
+// 'clamp' : (기본/2026-04-24 최종) 평행 위치-OVER_EPS 까지 클램핑. wall.baseLines 는 항상 정상 polygon, SK 확장 정상.
+// 'reject' : (2026-04-22 정책) silent skip — 오버면 해당 target 의 mutation 자체를 건너뜀. 토스트 없음.
+// 'passthrough' : 가드 전면 스킵 — 사용자가 입력한 raw 값을 그대로 적용. (과거 crossed 허용 동작; polygon 왜곡 위험)
+// 교체 방법: 이 상수만 변경. OVER_GUARD 분기가 자동 반응.
+const OVER_MOVE_POLICY = 'clamp'
+const OVER_EPS = 0.5
+
//동선이동 형 올림 내림
export function useMovementSetting(id) {
const TYPE = {
@@ -746,6 +754,57 @@ export function useMovementSetting(id) {
deltaX = value.toNumber()
}
+ // [OVER_GUARD] 오버 이동(인접 라인 반전) 처리. 정책은 파일 상단 OVER_MOVE_POLICY 로 전환.
+ // 한계 계산 (공통):
+ // nLimit = (nextLine 의 free 끝점 좌표) - (currentLine 의 해당 끝점 좌표)
+ // pLimit = (prevLine 의 free 끝점 좌표) - (currentLine 의 해당 시작점 좌표)
+ // delta 와 같은 부호인 한계만 후보(반대 부호 한계는 인접 라인이 길어지는 방향).
+ // 정책별 동작:
+ // 'passthrough' → 가드 전체 스킵
+ // 'reject' → |delta| > |한계-EPS| 이면 해당 target 은 mutation 없이 건너뜀 (silent skip)
+ // 'clamp' → |delta| 를 |한계-EPS| 로 잘라 평행 직전까지 이동. roof.moveUpDown 도 동일 비율 동기화.
+ if (OVER_MOVE_POLICY !== 'passthrough') {
+ const __isHoriz = currentLine.y1 === currentLine.y2
+ const __raw = __isHoriz ? deltaY : deltaX
+ const __sgn = Math.sign(__raw)
+ if (__sgn !== 0) {
+ const __nLimit = __isHoriz ? (nextLine.y2 - currentLine.y2) : (nextLine.x2 - currentLine.x2)
+ const __pLimit = __isHoriz ? (prevLine.y1 - currentLine.y1) : (prevLine.x1 - currentLine.x1)
+ let __absLimit = Infinity
+ if (Math.sign(__nLimit) === __sgn) __absLimit = Math.min(__absLimit, Math.abs(__nLimit))
+ if (Math.sign(__pLimit) === __sgn) __absLimit = Math.min(__absLimit, Math.abs(__pLimit))
+ // [EPS] 평행 정확히 도달 시 인접 baseLine zero-length → polygon 중복 꼭짓점 → SkeletonBuilder 헛선 발생.
+ // OVER_EPS 여유 두어 인접 baseLine 길이 OVER_EPS(=0.5) 로 남기고 SHOULDER_ABSORBED(planeSize<1) 가 자연 흡수.
+ const __safeAbsLimit = Math.max(0, __absLimit - OVER_EPS)
+ const __isOver = Math.abs(__raw) > __safeAbsLimit + 0.001
+
+ if (__isOver && OVER_MOVE_POLICY === 'reject') {
+ // silent skip: mutation 자체 건너뜀. movingLineFromSkeleton 의 roof.points 확장도 막으려면 moveUpDown=0.
+ console.warn(
+ `[handleSave] OVER 거부(reject): ${__isHoriz ? 'deltaY' : 'deltaX'}=${__raw.toFixed(1)} > limit=${__safeAbsLimit.toFixed(1)} → target skip`
+ )
+ roof.moveUpDown = 0
+ return
+ }
+
+ if (OVER_MOVE_POLICY === 'clamp') {
+ const __limited = __sgn * Math.min(Math.abs(__raw), __safeAbsLimit)
+ if (Math.abs(__limited - __raw) > 0.001) {
+ console.warn(
+ `[handleSave] OVER 클램핑: ${__isHoriz ? 'deltaY' : 'deltaX'} ${__raw.toFixed(1)} → ${__limited.toFixed(1)} (nLimit=${__nLimit.toFixed(1)} pLimit=${__pLimit.toFixed(1)}, EPS=${OVER_EPS})`
+ )
+ if (__isHoriz) deltaY = __limited
+ else deltaX = __limited
+ // [SYNC roof.moveUpDown] movingLineFromSkeleton 은 roof.moveUpDown(mm) 로 roof.points 확장.
+ // delta 만 줄이고 moveUpDown 방치 시 wall 은 평행까지, roof.points 는 원본 양만큼 이동 →
+ // polygon 자기교차 → removeNonOrthogonalPoints 가 꼭짓점 축소 → "SK 가 wall 안에서 멈춤".
+ // __limited(cm) × 10 = mm.
+ roof.moveUpDown = Math.round(Math.abs(__limited) * 10)
+ }
+ }
+ }
+ }
+
currentLine.set({
x1: currentLine.x1 + deltaX,
y1: currentLine.y1 + deltaY,
diff --git a/src/hooks/roofcover/useOuterLineWall.js b/src/hooks/roofcover/useOuterLineWall.js
index dbd9f104..c330caac 100644
--- a/src/hooks/roofcover/useOuterLineWall.js
+++ b/src/hooks/roofcover/useOuterLineWall.js
@@ -30,6 +30,8 @@ import { calculateAngle } from '@/util/qpolygon-utils'
import { fabric } from 'fabric'
import { outlineDisplaySelector } from '@/store/settingAtom'
import { usePopup } from '@/hooks/usePopup'
+import { useSwal } from '@/hooks/useSwal'
+import { useMessage } from '@/hooks/useMessage'
import Big from 'big.js'
import RoofShapeSetting from '@/components/floor-plan/modal/roofShape/RoofShapeSetting'
import { useObject } from '@/hooks/useObject'
@@ -84,6 +86,8 @@ export function useOuterLineWall(id, propertiesId) {
const arrow1Ref = useRef(arrow1)
const arrow2Ref = useRef(arrow2)
const { addPopup, closePopup } = usePopup()
+ const { swalFire } = useSwal()
+ const { getMessage } = useMessage()
const setOuterLineFix = useSetRecoilState(outerLineFixState)
@@ -929,7 +933,11 @@ export function useOuterLineWall(id, propertiesId) {
const enterCheck = (e) => {
if (e.key === 'Enter') {
- handleFix()
+ swalFire({
+ type: 'confirm',
+ text: getMessage('modal.cover.outline.fix.confirm'),
+ confirmFn: handleFix,
+ })
}
}
diff --git a/src/hooks/surface/usePlacementShapeDrawing.js b/src/hooks/surface/usePlacementShapeDrawing.js
index 3a6d1c05..efb233be 100644
--- a/src/hooks/surface/usePlacementShapeDrawing.js
+++ b/src/hooks/surface/usePlacementShapeDrawing.js
@@ -22,6 +22,8 @@ import {
import { usePolygon } from '@/hooks/usePolygon'
import { POLYGON_TYPE } from '@/common/common'
import { usePopup } from '@/hooks/usePopup'
+import { useSwal } from '@/hooks/useSwal'
+import { useMessage } from '@/hooks/useMessage'
import { useSurfaceShapeBatch } from './useSurfaceShapeBatch'
import { roofDisplaySelector } from '@/store/settingAtom'
@@ -48,6 +50,8 @@ export function usePlacementShapeDrawing(id) {
const { setSurfaceShapePattern } = useRoofFn()
const { changeSurfaceLineType } = useSurfaceShapeBatch({})
const { handleSelectableObjects } = useObject()
+ const { swalFire } = useSwal()
+ const { getMessage } = useMessage()
const verticalHorizontalMode = useRecoilValue(verticalHorizontalModeState)
const adsorptionRange = useRecoilValue(adsorptionRangeState)
@@ -1089,7 +1093,11 @@ export function usePlacementShapeDrawing(id) {
const enterCheck = (e) => {
if (e.key === 'Enter') {
- handleFix()
+ swalFire({
+ type: 'confirm',
+ text: getMessage('modal.cover.outline.fix.confirm'),
+ confirmFn: handleFix,
+ })
}
}
diff --git a/src/locales/ja.json b/src/locales/ja.json
index 13435c2a..96f34735 100644
--- a/src/locales/ja.json
+++ b/src/locales/ja.json
@@ -70,6 +70,7 @@
"modal.cover.outline.length": "長さ(mm)",
"modal.cover.outline.arrow": "方向(矢印)",
"modal.cover.outline.fix": "外壁線確定",
+ "modal.cover.outline.fix.confirm": "本当に確定しますか?確定後は修正できません。",
"modal.cover.outline.rollback": "前に戻る",
"modal.cover.outline.finish": "設定完了",
"common.setting.finish": "設定完了",
diff --git a/src/locales/ko.json b/src/locales/ko.json
index e3403f02..f9b48bb4 100644
--- a/src/locales/ko.json
+++ b/src/locales/ko.json
@@ -70,6 +70,7 @@
"modal.cover.outline.length": "길이(mm)",
"modal.cover.outline.arrow": "방향(화살표)",
"modal.cover.outline.fix": "외벽선 확정",
+ "modal.cover.outline.fix.confirm": "정말로 확정하시겠습니까? 확정 후에는 수정할 수 없습니다.",
"modal.cover.outline.rollback": "일변전으로 돌아가기",
"modal.cover.outline.finish": "설정완료",
"common.setting.finish": "설정완료",
diff --git a/src/util/skeleton-utils.js b/src/util/skeleton-utils.js
index 22eaea64..b0064ea0 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,52 @@ export const skeletonBuilder = (roofId, canvas, textMode) => {
// 6. 마루이동(용마루 위치 수동 조정)이 설정된 경우 movingLineFromSkeleton 결과로 대체
if (moveFlowLine !== 0 || moveUpDown !== 0) {
- const movedPoints = movingLineFromSkeleton(roofId, canvas)
+ // [정책] 오버 이동(verifyMoveBoundary='crossed') 처리:
+ // - 과거: 'crossed' 시 재빌드 스킵하여 기존 SK 유지 (silent skip).
+ // - 현재: 사용자가 재작도 회피용 단축 수단으로 오버를 의도 사용 → 오버된 wall.baseLines 좌표 기반 재빌드 수행.
+ // - 경고 로그만 남기고 기존 흐름 그대로 진행.
+ try {
+ const __moveVerdict = verifyMoveBoundary(roofId, canvas)
+ if (__moveVerdict === 'crossed') {
+ console.warn('[skeleton] 경계 넘음(crossed) → 오버된 wall.baseLine 좌표 기반 재빌드 진행')
+ }
+ } catch (e) {
+ console.warn('[skeleton] verifyMoveBoundary 예외 → 기존 흐름 진행:', e)
+ }
+
+ 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
@@ -559,21 +836,111 @@ export const skeletonBuilder = (roofId, canvas, textMode) => {
console.log('[DEBUG] changRoofLinePoints(최종 SK입력):', changRoofLinePoints.map((p, i) => `[${i}] (${Math.round(p.x)},${Math.round(p.y)})`))
+ // 좌표 유효성 검증
+ const invalidPoints = changRoofLinePoints.filter((p, i) =>
+ p == null || isNaN(p.x) || isNaN(p.y) || !isFinite(p.x) || !isFinite(p.y)
+ )
+ if (invalidPoints.length > 0) {
+ console.error('[SK_ERROR] 유효하지 않은 좌표 발견:', invalidPoints)
+ console.error('[SK_ERROR] 전체 좌표:', JSON.stringify(changRoofLinePoints))
+ }
+
+ // 인접 중복점 검출 (거리 < 0.5)
+ for (let i = 0; i < changRoofLinePoints.length; i++) {
+ const curr = changRoofLinePoints[i]
+ const next = changRoofLinePoints[(i + 1) % changRoofLinePoints.length]
+ const dist = Math.hypot(next.x - curr.x, next.y - curr.y)
+ if (dist < 0.5) {
+ console.warn(`[SK_WARN] 인접 중복점: [${i}](${Math.round(curr.x)},${Math.round(curr.y)}) ↔ [${(i + 1) % changRoofLinePoints.length}](${Math.round(next.x)},${Math.round(next.y)}) dist=${dist.toFixed(3)}`)
+ }
+ }
+
+ // 일직선(collinear) 점 검출
+ for (let i = 0; i < changRoofLinePoints.length; i++) {
+ const prev = changRoofLinePoints[(i - 1 + changRoofLinePoints.length) % changRoofLinePoints.length]
+ const curr = changRoofLinePoints[i]
+ const next = changRoofLinePoints[(i + 1) % changRoofLinePoints.length]
+ const cross = (curr.x - prev.x) * (next.y - prev.y) - (curr.y - prev.y) * (next.x - prev.x)
+ if (Math.abs(cross) < 1.0) {
+ console.warn(`[SK_WARN] 일직선 점: [${i}](${Math.round(curr.x)},${Math.round(curr.y)}) cross=${cross.toFixed(3)}`)
+ }
+ }
+
+ // 다각형 오버 검출: 원본 roof.points와 비교하여 꼭짓점 방향(cross product)이 뒤집혔는지 체크
+ let isOverDetected = false
+ const origPoints = roof.points || []
+ const n = changRoofLinePoints.length
+ console.log('[SK_OVER_DEBUG] origPoints:', origPoints.map((p, i) => `[${i}](${Math.round(p.x)},${Math.round(p.y)})`))
+ console.log('[SK_OVER_DEBUG] changRoofLinePoints:', changRoofLinePoints.map((p, i) => `[${i}](${Math.round(p.x)},${Math.round(p.y)})`))
+ console.log('[SK_OVER_DEBUG] 변경된 점:', changRoofLinePoints.map((p, i) => {
+ const o = origPoints[i]
+ if (!o) return null
+ const dist = Math.hypot(p.x - o.x, p.y - o.y)
+ return dist > 1 ? `[${i}] dx=${Math.round(p.x - o.x)} dy=${Math.round(p.y - o.y)}` : null
+ }).filter(Boolean))
+ if (origPoints.length === n && n >= 3) {
+ for (let i = 0; i < n; i++) {
+ const prevOrig = origPoints[(i - 1 + n) % n]
+ const currOrig = origPoints[i]
+ const nextOrig = origPoints[(i + 1) % n]
+ const crossOrig = (currOrig.x - prevOrig.x) * (nextOrig.y - prevOrig.y) - (currOrig.y - prevOrig.y) * (nextOrig.x - prevOrig.x)
+
+ const prevNew = changRoofLinePoints[(i - 1 + n) % n]
+ const currNew = changRoofLinePoints[i]
+ const nextNew = changRoofLinePoints[(i + 1) % n]
+ const crossNew = (currNew.x - prevNew.x) * (nextNew.y - prevNew.y) - (currNew.y - prevNew.y) * (nextNew.x - prevNew.x)
+
+ if (crossOrig * crossNew < 0) {
+ isOverDetected = true
+ console.error(`[SK_OVER] 꼭짓점[${i}] 방향 뒤집힘! cross: ${crossOrig.toFixed(1)} → ${crossNew.toFixed(1)}`)
+ console.error(`[SK_OVER] 원본: [${(i-1+n)%n}](${Math.round(prevOrig.x)},${Math.round(prevOrig.y)}) → [${i}](${Math.round(currOrig.x)},${Math.round(currOrig.y)}) → [${(i+1)%n}](${Math.round(nextOrig.x)},${Math.round(nextOrig.y)})`)
+ console.error(`[SK_OVER] 변경: [${(i-1+n)%n}](${Math.round(prevNew.x)},${Math.round(prevNew.y)}) → [${i}](${Math.round(currNew.x)},${Math.round(currNew.y)}) → [${(i+1)%n}](${Math.round(nextNew.x)},${Math.round(nextNew.y)})`)
+ }
+ }
+ }
+
// changRoofLinePoints를 roof.skeletonPoints에 저장 (roof.points 원본은 유지)
roof.skeletonPoints = changRoofLinePoints.map(p => ({ x: p.x, y: p.y }))
- const perturbedPoints = perturbPolygonPoints(changRoofLinePoints)
+ // ──────────────────────────────────────────────────────────────────
+ // [분기] 스켈레톤 입력 포인트 결정
+ // 정상: changRoofLinePoints 그대로 사용
+ // 오버(일반 경로로 들어온 에지 케이스): calcOverCorrectedPoints() 로 보정 포인트 생성
+ // ※ verifyMoveBoundary === 'crossed' 케이스는 drawHelpLine 에서 drawSkeletonRidgeRoofFromBaseLines
+ // 로 사전 분기하므로, 여기 isOverDetected 분기는 일반 경로의 안전망 용도로만 작동.
+ // ※ changRoofLinePoints 자체는 절대 수정하지 않음. 실패 시 그대로 fallback.
+ // ──────────────────────────────────────────────────────────────────
+ let skeletonInputPoints = changRoofLinePoints
+
+ if (isOverDetected) {
+ try {
+ const corrected = calcOverCorrectedPointsSafe(
+ changRoofLinePoints,
+ origPoints,
+ canvas?.skeleton?.lastPoints ?? null
+ )
+ if (corrected && corrected.length >= 3) {
+ skeletonInputPoints = corrected
+ console.log('[SK_OVER] 보정 포인트 적용:', skeletonInputPoints.map((p, i) => `[${i}](${Math.round(p.x)},${Math.round(p.y)})`))
+ }
+ } catch (e) {
+ console.error('[SK_OVER] 보정 실패 → fallback:', e)
+ }
+ }
+
+ const perturbedPoints = perturbPolygonPoints(skeletonInputPoints)
const geoJSONPolygon = toGeoJSON(perturbedPoints)
try {
// SkeletonBuilder는 닫히지 않은 폴리곤을 기대하므로 마지막 점 제거
geoJSONPolygon.pop()
console.log('[SkeletonBuilder] geoJSONPolygon:', JSON.stringify(geoJSONPolygon, null, 2))
+ console.log('[SkeletonBuilder] 꼭짓점 수:', geoJSONPolygon.length)
const skeleton = SkeletonBuilder.BuildFromGeoJSON([[geoJSONPolygon]])
// 스켈레톤 데이터를 기반으로 내부선 생성
roof.innerLines = roof.innerLines || []
- roof.innerLines = createInnerLinesFromSkeleton(roofId, canvas, skeleton, textMode)
+ roof.innerLines = createInnerLinesFromSkeleton(roofId, canvas, skeleton, textMode, isOverDetected, changRoofLinePoints)
//console.log("roofInnerLines:::", roof.innerLines);
// 캔버스에 스켈레톤 상태 저장
if (!canvas.skeletonStates) {
@@ -598,20 +965,140 @@ 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()
//console.log('skeleton rendered.', canvas)
} catch (e) {
- console.error('스켈레톤 생성 중 오류 발생:', e)
+ console.error('[SK_ERROR] 스켈레톤 생성 중 오류 발생:', e)
+ console.error('[SK_ERROR] 입력 좌표:', changRoofLinePoints.map((p, i) => `[${i}] (${p.x.toFixed(2)},${p.y.toFixed(2)})`))
+ console.error('[SK_ERROR] geoJSON:', JSON.stringify(geoJSONPolygon))
+ // [보호] 예외 시 상태 플래그만 복구하고, 기존 SK/innerLines는 유지한다.
+ // → 사용자가 힘들게 추가한 라인들이 순간적으로 사라지는 현상 방지.
if (canvas.skeletonStates) {
canvas.skeletonStates[roofId] = false
+ }
+ // 주의: canvas.skeletonLines 는 의도적으로 비우지 않음 (이전 성공 상태 보존)
+ }
+}
+
+/**
+ * 오버 이동(verifyMoveBoundary='crossed') 전용 경로.
+ *
+ * 일반 경로(drawSkeletonRidgeRoof) 는 wall.baseLines → 45도 대각 확장 → roof 경계까지 늘린 polygon 을
+ * SkeletonBuilder 에 입력한다. 오버 이동 상태에선 이 확장/클램핑이 폴리곤 자가교차나 roof 경계로의
+ * 되돌림을 야기해 엉뚱한 SK 를 만든다.
+ *
+ * 본 함수는 **wall.baseLines 의 끝점을 확장/offset 없이 그대로** polygon 으로 넘겨 SK 를 빌드한다.
+ * - roof.points / roof.lines / outerLine / lengthText 는 건드리지 않음.
+ * - SHOULDER_ABSORBED (planeSize < 1) baseLines 는 스킵.
+ * - innerLines 는 기존 createInnerLinesFromSkeleton 으로 생성 (isOverDetected=true 마킹).
+ * - canvas.skeleton.lastPoints 는 기존 값을 보존 (오버 상태에서 새로 계산하지 않음).
+ */
+export const drawSkeletonRidgeRoofFromBaseLines = (roofId, canvas, textMode) => {
+ const roof = canvas?.getObjects().find((o) => o.id === roofId)
+ if (!roof) {
+ console.warn('[SK_OVER_FN] roof 없음 → 중단')
+ return
+ }
+ const wall = canvas.getObjects().find((obj) => obj.name === POLYGON_TYPE.WALL && obj.attributes?.roofId === roofId)
+ if (!wall || !wall.baseLines?.length) {
+ console.warn('[SK_OVER_FN] wall/baseLines 없음 → 중단')
+ return
+ }
+
+ // wall.baseLines 순차 끝점(x1,y1) 수집. zero-length(SHOULDER_ABSORBED) 항목 스킵.
+ const rawPoints = []
+ for (let i = 0; i < wall.baseLines.length; i++) {
+ const bl = wall.baseLines[i]
+ const planeSize = bl?.attributes?.planeSize ?? Infinity
+ if (planeSize < 1) {
+ console.log(`[SK_OVER_FN] baseLines[${i}] SHOULDER_ABSORBED skip (planeSize=${planeSize})`)
+ continue
+ }
+ if (bl == null || !isFinite(bl.x1) || !isFinite(bl.y1)) {
+ console.warn(`[SK_OVER_FN] baseLines[${i}] invalid coord → skip`)
+ continue
+ }
+ rawPoints.push({ x: bl.x1, y: bl.y1 })
+ }
+
+ if (rawPoints.length < 3) {
+ console.warn(`[SK_OVER_FN] 유효 baseLines 끝점 ${rawPoints.length} < 3 → 중단`)
+ return
+ }
+
+ console.log('[SK_OVER_FN] wall.baseLines polygon:', rawPoints.map((p, i) => `[${i}](${Math.round(p.x)},${Math.round(p.y)})`))
+
+ // skeletonPoints 마킹 (오버 좌표 그대로)
+ roof.skeletonPoints = rawPoints.map((p) => ({ x: p.x, y: p.y }))
+
+ const perturbed = perturbPolygonPoints(rawPoints)
+ const geoJSONPolygon = toGeoJSON(perturbed)
+
+ try {
+ geoJSONPolygon.pop()
+ console.log('[SK_OVER_FN] geoJSONPolygon:', JSON.stringify(geoJSONPolygon, null, 2))
+ console.log('[SK_OVER_FN] 꼭짓점 수:', geoJSONPolygon.length)
+ const skeleton = SkeletonBuilder.BuildFromGeoJSON([[geoJSONPolygon]])
+
+ roof.innerLines = roof.innerLines || []
+ roof.innerLines = createInnerLinesFromSkeleton(roofId, canvas, skeleton, textMode, /* isOverDetected */ true, rawPoints)
+
+ if (!canvas.skeletonStates) {
canvas.skeletonStates = {}
canvas.skeletonLines = []
}
+ canvas.skeletonStates[roofId] = true
+ canvas.skeletonLines = []
+ canvas.skeletonLines.push(...roof.innerLines)
+ roof.skeletonLines = canvas.skeletonLines
+
+ const cleanSkeleton = {
+ Edges: skeleton.Edges.map((edge) => ({
+ X1: edge.Edge.Begin.X,
+ Y1: edge.Edge.Begin.Y,
+ X2: edge.Edge.End.X,
+ Y2: edge.Edge.End.Y,
+ Polygon: edge.Polygon,
+ })),
+ roofId: roofId,
+ }
+ // lastPoints 는 기존 값 유지 (오버 상태에서 새 누적 기준점을 만들지 않음)
+ const __preservedLastPoints = canvas.skeleton?.lastPoints ?? null
+ canvas.skeleton = cleanSkeleton
+ if (__preservedLastPoints) canvas.skeleton.lastPoints = __preservedLastPoints
+
+ canvas.set('skeleton', cleanSkeleton)
+ canvas.renderAll()
+ } catch (e) {
+ console.error('[SK_OVER_FN] 스켈레톤 생성 오류:', e)
+ console.error('[SK_OVER_FN] 입력 좌표:', rawPoints.map((p, i) => `[${i}] (${p.x.toFixed(2)},${p.y.toFixed(2)})`))
+ if (canvas.skeletonStates) canvas.skeletonStates[roofId] = false
}
}
@@ -624,7 +1111,7 @@ export const skeletonBuilder = (roofId, canvas, textMode) => {
* @param {string} textMode - 텍스트 표시 모드 ('plane', 'actual', 'none')
* @param {Array
} baseLines - 원본 외벽선 QLine 객체 배열
*/
-const createInnerLinesFromSkeleton = (roofId, canvas, skeleton, textMode) => {
+const createInnerLinesFromSkeleton = (roofId, canvas, skeleton, textMode, isOverDetected = false, skPolygonPoints = []) => {
if (!skeleton?.Edges) return []
const roof = canvas?.getObjects().find((object) => object.id === roofId)
const wall = canvas.getObjects().find((obj) => obj.name === POLYGON_TYPE.WALL && obj.attributes.roofId === roofId)
@@ -852,10 +1339,42 @@ const createInnerLinesFromSkeleton = (roofId, canvas, skeleton, textMode) => {
})
}
- // 각 라인 집합 정렬
- const sortWallLines = ensureCounterClockwiseLines(wallLines)
- const sortWallBaseLines = ensureCounterClockwiseLines(wall.baseLines)
+ // [정렬 정책 — 인덱스가 생명] (2026-04-27)
+ // 1) master = roofLines. roofLine 은 외곽(처마) 경계라 사용자 mutation 영향 없음 → 시작점 안정.
+ // 2) ensureCounterClockwiseLines 가 master 의 leftTop(min-Y → min-X) 꼭짓점에서 시작해 CCW 정렬.
+ // 3) wall.lines, wall.baseLines 는 raw 인덱스 1:1 페어링 유지된다는 전제로
+ // master 의 sorted 순서 → 원 인덱스 → wall.lines[idx] / wall.baseLines[idx] 매핑.
+ // wall 쪽을 master 로 쓰던 구현은 OVER 흡수/usePropertiesSetting 등에서 wall.lines 가
+ // 재할당되며 시작점이 어긋나 페어 인덱스가 회전되던 문제(2026-04-27 ㅗ 좌측오버 케이스) 가 있어 변경.
+ const _keyOfEdge = (l) => {
+ const a = `${Math.round(l.x1 * 10) / 10},${Math.round(l.y1 * 10) / 10}`
+ const b = `${Math.round(l.x2 * 10) / 10},${Math.round(l.y2 * 10) / 10}`
+ return a < b ? `${a}|${b}` : `${b}|${a}`
+ }
+ const _rawIdxByKey = new Map()
+ roofLines.forEach((l, i) => _rawIdxByKey.set(_keyOfEdge(l), i))
const sortRoofLines = ensureCounterClockwiseLines(roofLines)
+ const sortWallLines = sortRoofLines.map((sl) => {
+ const origIdx = _rawIdxByKey.get(_keyOfEdge(sl))
+ return origIdx != null && wallLines[origIdx] ? wallLines[origIdx] : sl
+ })
+ const sortWallBaseLines = sortRoofLines.map((sl) => {
+ const origIdx = _rawIdxByKey.get(_keyOfEdge(sl))
+ return origIdx != null && wall.baseLines[origIdx] ? wall.baseLines[origIdx] : sl
+ })
+
+ // [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'
@@ -876,6 +1395,19 @@ const createInnerLinesFromSkeleton = (roofId, canvas, skeleton, textMode) => {
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`]
@@ -885,7 +1417,7 @@ const createInnerLinesFromSkeleton = (roofId, canvas, skeleton, textMode) => {
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]
@@ -895,7 +1427,9 @@ const createInnerLinesFromSkeleton = (roofId, canvas, skeleton, textMode) => {
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
@@ -930,6 +1464,24 @@ const createInnerLinesFromSkeleton = (roofId, canvas, skeleton, textMode) => {
getAddLine, sortRoofLines, findPoints, innerLines,
moveAxis, lineAxis, inSign
}) => {
+ // [SHOULDER-ADJACENT] 인접 pair 가 SHOULDER_ABSORBED 된 경우 HIP 빗변은 orphan 이 됨.
+ // 이유: processInBoth 는 roofLine(원본 roof.points 기반) 꼭짓점을 HIP 끝점으로 씀.
+ // 인접 pair 흡수 = 해당 꼭짓점이 현재 wallBaseLine 토폴로지에 없음 → 공중에 뜬 대각선.
+ // SHOULDER_ABSORBED 가드(line 1608 인근)와 같은 원인·패턴의 후속 증상.
+ // 임계 = 10: SHOULDER_ABSORBED skip 임계와 동일하게 맞춤. (OVER_EPS=0.5 → planeSize=5)
+ // <1 로 두면 pair#3 흡수(planeSize=5) 인접인 pair#4 가 가드 통과 → phantom HIP 생성.
+ {
+ const n = sortWallBaseLines.length
+ const prevBL = sortWallBaseLines[(index - 1 + n) % n]
+ const nextBL = sortWallBaseLines[(index + 1) % n]
+ const prevAbsorbed = (prevBL?.attributes?.planeSize ?? Infinity) < 10
+ const nextAbsorbed = (nextBL?.attributes?.planeSize ?? Infinity) < 10
+ if (prevAbsorbed || nextAbsorbed) {
+ 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)
@@ -962,25 +1514,45 @@ const createInnerLinesFromSkeleton = (roofId, canvas, skeleton, textMode) => {
innerLines.push(createdLine)
}
+ // [SK-VERTEX-PROXIMITY] HIP target 은 새 SK 폴리곤(changRoofLinePoints) 의 vertex 근처여야 함.
+ // roofLine 은 OLD roof.points 기반 → 인접 흡수로 코너가 dissolve 되면 target 이 SK 어디에도 없는 phantom 좌표가 됨.
+ // pair#0 의 prev/next 인접이 아닌 2-hop 흡수 (ex. left_out 흡수 후 인접 pair#7 stretched, pair#0 end 가 옛 코너 참조) 도 차단.
+ const __isNearSkVertex = (pt, tol = 5) => {
+ if (!Array.isArray(skPolygonPoints) || skPolygonPoints.length === 0) return true
+ return skPolygonPoints.some((v) => Math.hypot(v.x - pt.x, v.y - pt.y) < tol)
+ }
+
if (checkDist1 > 0) {
// findPoints 불필요: wallBaseLine 좌표가 ridge 라인 위에 없음 (스켈레톤이 이동된 폴리곤 기준으로 재계산되므로)
// HIP extension 라인만 생성
+ let target
if (moveDistY1 > moveDistX1) {
const dist = moveDistY1 - moveDistX1
- createHipLine(sPoint, { x: roofLine.x1, y: roofLine.y1 + ySignStart * dist })
+ target = { x: roofLine.x1, y: roofLine.y1 + ySignStart * dist }
} else {
const dist = moveDistX1 - moveDistY1
- createHipLine(sPoint, { x: roofLine.x1 + xSignStart * dist, y: roofLine.y1 })
+ target = { x: roofLine.x1 + xSignStart * dist, y: roofLine.y1 }
+ }
+ if (!__isNearSkVertex(target)) {
+ console.log(`⏭️ [pair#${index}] ${condition} HIP_SKIP_VERTEX_MISS start target=(${target.x.toFixed(1)},${target.y.toFixed(1)})`)
+ } else {
+ createHipLine(sPoint, target)
}
}
if (checkDist2 > 0) {
+ let target
if (moveDistY2 > moveDistX2) {
const dist = moveDistY2 - moveDistX2
- createHipLine(ePoint, { x: roofLine.x2, y: roofLine.y2 + ySignEnd * dist })
+ target = { x: roofLine.x2, y: roofLine.y2 + ySignEnd * dist }
} else {
const dist = moveDistX2 - moveDistY2
- createHipLine(ePoint, { x: roofLine.x2 + xSignEnd * dist, y: roofLine.y2 })
+ target = { x: roofLine.x2 + xSignEnd * dist, y: roofLine.y2 }
+ }
+ if (!__isNearSkVertex(target)) {
+ console.log(`⏭️ [pair#${index}] ${condition} HIP_SKIP_VERTEX_MISS end target=(${target.x.toFixed(1)},${target.y.toFixed(1)})`)
+ } else {
+ createHipLine(ePoint, target)
}
}
}
@@ -1051,6 +1623,27 @@ const createInnerLinesFromSkeleton = (roofId, canvas, skeleton, textMode) => {
const roofLine = sortRoofLines[index]
const wallBaseLine = sortWallBaseLines[index]
+ // [SHOULDER-ABSORBED] wall.baseLines[k] 가 이동 흡수/OVER_EPS 클램핑으로 거의 zero-length 이면
+ // 해당 edge 는 흡수된 상태. pair 생성 자체가 불필요(DIR_MISMATCH + eaveHelpLine 노이즈 유발).
+ // 임계 = 10: useMovementSetting OVER_EPS=0.5 (canvas unit) 시 잔여 길이=0.5 → planeSize=5 (calcLinePlaneSize × 10).
+ // 안전마진 두고 < 10 으로 차단. 정상 baseLine 은 보통 50+ 이므로 false positive 없음.
+ if (!wallBaseLine || (wallBaseLine.attributes?.planeSize ?? Infinity) < 10) {
+ 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)
@@ -1083,8 +1676,6 @@ const createInnerLinesFromSkeleton = (roofId, canvas, skeleton, textMode) => {
return
}
-
-
const movedStart = Math.abs(wallBaseLine.x1 - wallLine.x1) > EPSILON || Math.abs(wallBaseLine.y1 - wallLine.y1) > EPSILON
const movedEnd = Math.abs(wallBaseLine.x2 - wallLine.x2) > EPSILON || Math.abs(wallBaseLine.y2 - wallLine.y2) > EPSILON
@@ -1099,6 +1690,17 @@ const createInnerLinesFromSkeleton = (roofId, canvas, skeleton, textMode) => {
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 변화가 모두 있으면 대각선
@@ -1160,6 +1762,16 @@ const createInnerLinesFromSkeleton = (roofId, canvas, skeleton, textMode) => {
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) {
@@ -1204,6 +1816,21 @@ const createInnerLinesFromSkeleton = (roofId, canvas, skeleton, textMode) => {
}
} else if (condition === 'left_out') {
+ // [SHOULDER-ADJACENT _out] 인접 pair 흡수 시 현재 pair 의 끝점은 새 SK 토폴로지에서 dissolve 됨.
+ // roof.points 옛 코너 참조 → updateAndAddLine NOT FOUND + phantom eaveHelpLine 외부 출현.
+ // processInBoth 의 동일 패턴 가드(line 1471-1481) 와 동일 임계.
+ {
+ const n = sortWallBaseLines.length
+ const prevBL = sortWallBaseLines[(index - 1 + n) % n]
+ const nextBL = sortWallBaseLines[(index + 1) % n]
+ const prevAbsorbed = (prevBL?.attributes?.planeSize ?? Infinity) < 10
+ const nextAbsorbed = (nextBL?.attributes?.planeSize ?? Infinity) < 10
+ if (prevAbsorbed || nextAbsorbed) {
+ console.log(`⏭️ [pair#${index}] ${condition} OUT_SKIP adj_absorbed prev=${prevAbsorbed} next=${nextAbsorbed}`)
+ return
+ }
+ }
+
if (isStartEnd.start) {
const moveDist = Big(wallLine.x1).minus(wallBaseLine.x1).abs().toNumber()
const aStartY = Big(roofLine.y1).minus(moveDist).toNumber()
@@ -1313,6 +1940,21 @@ const createInnerLinesFromSkeleton = (roofId, canvas, skeleton, textMode) => {
} else if (condition === 'right_out') {
+ // [SHOULDER-ADJACENT _out] 인접 pair 흡수 시 현재 pair 의 끝점은 새 SK 토폴로지에서 dissolve 됨.
+ // roof.points 옛 코너 참조 → updateAndAddLine NOT FOUND + phantom eaveHelpLine 외부 출현.
+ // processInBoth 의 동일 패턴 가드(line 1471-1481) 와 동일 임계.
+ {
+ const n = sortWallBaseLines.length
+ const prevBL = sortWallBaseLines[(index - 1 + n) % n]
+ const nextBL = sortWallBaseLines[(index + 1) % n]
+ const prevAbsorbed = (prevBL?.attributes?.planeSize ?? Infinity) < 10
+ const nextAbsorbed = (nextBL?.attributes?.planeSize ?? Infinity) < 10
+ if (prevAbsorbed || nextAbsorbed) {
+ console.log(`⏭️ [pair#${index}] ${condition} OUT_SKIP adj_absorbed prev=${prevAbsorbed} next=${nextAbsorbed}`)
+ return
+ }
+ }
+
if (isStartEnd.start) {
console.log('right_out::::isStartEnd:::::', isStartEnd)
const moveDist = Big(wallLine.x1).minus(wallBaseLine.x1).abs().toNumber()
@@ -1459,6 +2101,21 @@ const createInnerLinesFromSkeleton = (roofId, canvas, skeleton, textMode) => {
}
} else if (condition === 'top_out') {
+ // [SHOULDER-ADJACENT _out] 인접 pair 흡수 시 현재 pair 의 끝점은 새 SK 토폴로지에서 dissolve 됨.
+ // roof.points 옛 코너 참조 → updateAndAddLine NOT FOUND + phantom eaveHelpLine 외부 출현.
+ // processInBoth 의 동일 패턴 가드(line 1471-1481) 와 동일 임계.
+ {
+ const n = sortWallBaseLines.length
+ const prevBL = sortWallBaseLines[(index - 1 + n) % n]
+ const nextBL = sortWallBaseLines[(index + 1) % n]
+ const prevAbsorbed = (prevBL?.attributes?.planeSize ?? Infinity) < 10
+ const nextAbsorbed = (nextBL?.attributes?.planeSize ?? Infinity) < 10
+ if (prevAbsorbed || nextAbsorbed) {
+ console.log(`⏭️ [pair#${index}] ${condition} OUT_SKIP adj_absorbed prev=${prevAbsorbed} next=${nextAbsorbed}`)
+ return
+ }
+ }
+
if (isStartEnd.start) {
console.log('top_out isStartEnd:::::::', isStartEnd)
const moveDist = Big(wallLine.y1).minus(wallBaseLine.y1).abs().toNumber()
@@ -1566,6 +2223,22 @@ const createInnerLinesFromSkeleton = (roofId, canvas, skeleton, textMode) => {
}
} else if (condition === 'bottom_out') {
console.log('bottom_out isStartEnd:::::::', isStartEnd)
+
+ // [SHOULDER-ADJACENT _out] 인접 pair 흡수 시 현재 pair 의 끝점은 새 SK 토폴로지에서 dissolve 됨.
+ // roof.points 옛 코너 참조 → updateAndAddLine NOT FOUND + phantom eaveHelpLine 외부 출현.
+ // processInBoth 의 동일 패턴 가드(line 1471-1481) 와 동일 임계.
+ {
+ const n = sortWallBaseLines.length
+ const prevBL = sortWallBaseLines[(index - 1 + n) % n]
+ const nextBL = sortWallBaseLines[(index + 1) % n]
+ const prevAbsorbed = (prevBL?.attributes?.planeSize ?? Infinity) < 10
+ const nextAbsorbed = (nextBL?.attributes?.planeSize ?? Infinity) < 10
+ if (prevAbsorbed || nextAbsorbed) {
+ console.log(`⏭️ [pair#${index}] ${condition} OUT_SKIP adj_absorbed prev=${prevAbsorbed} next=${nextAbsorbed}`)
+ return
+ }
+ }
+
if (isStartEnd.start) {
console.log('isStartEnd:::::::', isStartEnd)
const moveDist = Big(wallLine.y1).minus(wallBaseLine.y1).abs().toNumber()
@@ -1684,7 +2357,9 @@ const createInnerLinesFromSkeleton = (roofId, canvas, skeleton, textMode) => {
canvas.renderAll()
})
}
- getMoveUpDownLine()
+ if (!isOverDetected) {
+ getMoveUpDownLine()
+ }
}
@@ -1777,17 +2452,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];
@@ -1797,7 +2472,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
}
@@ -1806,10 +2481,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);
// }
@@ -1889,7 +2564,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;
}
@@ -1908,7 +2583,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) {
@@ -2000,6 +2675,167 @@ function processGableEdge(edgeResult, baseLines, skeletonLines, selectBaseLine,
// --- Helper Functions ---
+/**
+ * [오버 전용 예외 처리 함수] calcOverCorrectedPoints
+ *
+ * wallLine이 인접 roofLine을 넘어섰을 때(오버 감지 시)에만 호출된다.
+ * changRoofLinePoints를 직접 수정하지 않고 스켈레톤 전용 보정 포인트를 새로 만들어 반환한다.
+ *
+ * 보정 방법:
+ * 1. 이동된 점(moved) 판별
+ * 2. 이동점이 인접 고정점을 넘어갔으면 → 이동점을 고정점 위치로 클램핑
+ * 3. 클램핑된 점에서 연결된 이동점으로 값 전파
+ * 4. 인접 중복점 제거 (dist < 0.5)
+ * 5. 일직선(collinear) 중간점 제거
+ *
+ * @param {Array<{x,y}>} points - changRoofLinePoints (수정하지 않음, 읽기 전용)
+ * @param {Array<{x,y}>} origPoints - roof.points (원본 폴리곤)
+ * @returns {Array<{x,y}>} 보정된 스켈레톤 입력 포인트 (실패 시 원본 반환)
+ */
+/**
+ * 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 }))
+
+ // Step 1. 이동된 점 판별 (원본 대비 1px 초과 이동)
+ const moved = skPoints.map((p, i) => Math.hypot(p.x - origPoints[i].x, p.y - origPoints[i].y) > 1)
+
+ // Step 2. 이동점이 인접 고정점을 넘어갔으면 → 고정점 위치로 클램핑
+ const clamped = new Set()
+ const clampToFixed = (movedIdx, fixedIdx) => {
+ let didClamp = false
+ // 원래 수평 연결 (같은 y) → x 클램핑
+ if (Math.abs(origPoints[movedIdx].y - origPoints[fixedIdx].y) < 1) {
+ const origDir = origPoints[movedIdx].x - origPoints[fixedIdx].x
+ const newDir = skPoints[movedIdx].x - skPoints[fixedIdx].x
+ if (origDir * newDir < 0) {
+ console.log(`[SK_OVER] 클램핑: [${movedIdx}].x ${Math.round(skPoints[movedIdx].x)} → ${Math.round(skPoints[fixedIdx].x)}`)
+ skPoints[movedIdx].x = skPoints[fixedIdx].x
+ didClamp = true
+ }
+ }
+ // 원래 수직 연결 (같은 x) → y 클램핑
+ if (Math.abs(origPoints[movedIdx].x - origPoints[fixedIdx].x) < 1) {
+ const origDir = origPoints[movedIdx].y - origPoints[fixedIdx].y
+ const newDir = skPoints[movedIdx].y - skPoints[fixedIdx].y
+ if (origDir * newDir < 0) {
+ console.log(`[SK_OVER] 클램핑: [${movedIdx}].y ${Math.round(skPoints[movedIdx].y)} → ${Math.round(skPoints[fixedIdx].y)}`)
+ skPoints[movedIdx].y = skPoints[fixedIdx].y
+ didClamp = true
+ }
+ }
+ if (didClamp) clamped.add(movedIdx)
+ }
+
+ for (let i = 0; i < n; i++) {
+ if (!moved[i]) continue
+ const nextIdx = (i + 1) % n
+ const prevIdx = (i - 1 + n) % n
+ if (!moved[nextIdx]) clampToFixed(i, nextIdx)
+ if (!moved[prevIdx]) clampToFixed(i, prevIdx)
+ }
+
+ // Step 3. 클램핑된 점 → 연결된 미클램핑 이동점으로 값 전파 (반복)
+ let propagated = true
+ while (propagated) {
+ propagated = false
+ for (let i = 0; i < n; i++) {
+ if (!moved[i] || clamped.has(i)) continue
+ const prevIdx = (i - 1 + n) % n
+ const nextIdx = (i + 1) % n
+ if (clamped.has(prevIdx) && Math.abs(origPoints[prevIdx].x - origPoints[i].x) < 1) {
+ skPoints[i].x = skPoints[prevIdx].x; clamped.add(i); propagated = true
+ }
+ if (clamped.has(prevIdx) && Math.abs(origPoints[prevIdx].y - origPoints[i].y) < 1) {
+ skPoints[i].y = skPoints[prevIdx].y; clamped.add(i); propagated = true
+ }
+ if (clamped.has(nextIdx) && Math.abs(origPoints[nextIdx].x - origPoints[i].x) < 1) {
+ skPoints[i].x = skPoints[nextIdx].x; clamped.add(i); propagated = true
+ }
+ if (clamped.has(nextIdx) && Math.abs(origPoints[nextIdx].y - origPoints[i].y) < 1) {
+ skPoints[i].y = skPoints[nextIdx].y; clamped.add(i); propagated = true
+ }
+ }
+ }
+
+ // Step 4. 인접 중복점 제거 (dist < 0.5)
+ const deduped = []
+ for (let i = 0; i < skPoints.length; i++) {
+ const next = skPoints[(i + 1) % skPoints.length]
+ if (Math.hypot(next.x - skPoints[i].x, next.y - skPoints[i].y) > 0.5) {
+ deduped.push(skPoints[i])
+ }
+ }
+
+ // Step 5. 일직선(collinear) 중간점 제거
+ const cleaned = []
+ for (let i = 0; i < deduped.length; i++) {
+ const prev = deduped[(i - 1 + deduped.length) % deduped.length]
+ const curr = deduped[i]
+ const next = deduped[(i + 1) % deduped.length]
+ const cross = (curr.x - prev.x) * (next.y - prev.y) - (curr.y - prev.y) * (next.x - prev.x)
+ if (Math.abs(cross) > 1.0) cleaned.push(curr)
+ }
+
+ // 보정 실패 시(꼭짓점 부족) 원본 반환 → 기존 동작 보장
+ if (cleaned.length >= 3) return cleaned
+ if (deduped.length >= 3) return deduped
+ console.warn('[SK_OVER] calcOverCorrectedPoints: 보정 실패 → 원본 반환')
+ return points
+}
+
/**
* 두 점으로 이루어진 선분이 외벽선인지 확인합니다.
* @param {object} p1 - 점1 {x, y}
@@ -3149,22 +3985,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;
}
@@ -3589,14 +4425,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];
}