Compare commits

...

6 Commits

Author SHA1 Message Date
b8db935518 보조라인 맞춤 2026-04-23 12:35:14 +09:00
3d142cd95c sk라인 기록 보존 2026-04-23 12:35:14 +09:00
a3e608f059 Cross Mix(DC3) 접속함을 PCS 담당 모듈 타입으로 필터링해 1건만 내려가도록 통합 로직 적용 2026-04-23 12:35:14 +09:00
558769063b PassivityCircuitAllocation에 전달하던 미사용 ref prop 제거
passivityCircuitAllocationRef (useRef) 선언 제거
원인: function component에 ref 전달 시 React warning 발생 (forwardRef 미사용)
PCS 접속함 응답에 추가된 moduleTpCd 필드를 Cross Mix 식별을 위해 프론트 상태 보존 및 getQuotationItem 제출 파라미터까지 흘려보내고, dedupe 키를 (itemId, moduleTpCd) 조합으로 변경.
2026-04-23 12:35:13 +09:00
eaf6bcb3d3 trestles[]에 subModuleTpCd 추가 (Cross Mix 식별용, 단일/동계열은 빈 값) 2026-04-23 12:35:13 +09:00
8493fb037b [1806]서까래 높이 조정에 따른 지붕 형상 불일치 - 라인오버체크 2026-04-23 12:34:10 +09:00
5 changed files with 267 additions and 64 deletions

View File

@ -58,7 +58,6 @@ export default function CircuitTrestleSetting({ id }) {
const { setModuleStatisticsData } = useCircuitTrestle() const { setModuleStatisticsData } = useCircuitTrestle()
const { handleCanvasToPng } = useImgLoader() const { handleCanvasToPng } = useImgLoader()
const moduleSelectionData = useRecoilValue(moduleSelectionDataState) const moduleSelectionData = useRecoilValue(moduleSelectionDataState)
const passivityCircuitAllocationRef = useRef()
const { setIsGlobalLoading } = useContext(QcastContext) const { setIsGlobalLoading } = useContext(QcastContext)
const originCanvasViewPortTransform = useRef([]) const originCanvasViewPortTransform = useRef([])
@ -832,9 +831,23 @@ export default function CircuitTrestleSetting({ id }) {
// //
const getStepUpListData = () => { 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 = [] const pcs = []
console.log(stepUpListData) console.log(stepUpListData)
stepUpListData[0].pcsItemList.map((item, index) => { stepUpListData[0].pcsItemList.map((item, index) => {
const targetTpCds = pcsModuleTpCds[selectedModels[index]?.id]
return item.serQtyList return item.serQtyList
.filter((serQty) => serQty.selected && serQty.paralQty > 0) .filter((serQty) => serQty.selected && serQty.paralQty > 0)
.forEach((serQty) => { .forEach((serQty) => {
@ -844,9 +857,19 @@ export default function CircuitTrestleSetting({ id }) {
pcsItemId: item.itemId, pcsItemId: item.itemId,
pscOptCd: getPcsOptCd(index), pscOptCd: getPcsOptCd(index),
paralQty: serQty.paralQty, paralQty: serQty.paralQty,
// : itemId 1, // : PCS itemId dedupe
// (Cross Mix conn / · Mix )
connections: item.connList?.length 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 { // return {
@ -1041,7 +1064,7 @@ export default function CircuitTrestleSetting({ id }) {
</div> </div>
{tabNum === 1 && allocationType === ALLOCATION_TYPE.AUTO && <PowerConditionalSelect {...powerConditionalSelectProps} />} {tabNum === 1 && allocationType === ALLOCATION_TYPE.AUTO && <PowerConditionalSelect {...powerConditionalSelectProps} />}
{tabNum === 1 && allocationType === ALLOCATION_TYPE.PASSIVITY && ( {tabNum === 1 && allocationType === ALLOCATION_TYPE.PASSIVITY && (
<PassivityCircuitAllocation {...passivityProps} ref={passivityCircuitAllocationRef} isFold={isFold} /> <PassivityCircuitAllocation {...passivityProps} isFold={isFold} />
)} )}
{tabNum === 2 && <StepUp {...stepUpProps} onInitialize={handleStepUpInitialize} />} {tabNum === 2 && <StepUp {...stepUpProps} onInitialize={handleStepUpInitialize} />}
</div> </div>

View File

@ -345,6 +345,7 @@ export default function StepUp(props) {
itemId: conn.itemId ?? '', itemId: conn.itemId ?? '',
itemNm: conn.itemNm ?? '', itemNm: conn.itemNm ?? '',
vstuParalCnt: conn.vstuParalCnt ?? 0, vstuParalCnt: conn.vstuParalCnt ?? 0,
moduleTpCd: conn.moduleTpCd,
})) }))
} }
@ -576,7 +577,21 @@ export default function StepUp(props) {
* 현재 선택된 값들을 가져오는 함수 추가 * 현재 선택된 값들을 가져오는 함수 추가
*/ */
const getCurrentSelections = () => { 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) item.serQtyList.filter((serQty) => serQty.selected)
return item.serQtyList.map((serQty) => { return item.serQtyList.map((serQty) => {
return { return {
@ -585,17 +600,21 @@ export default function StepUp(props) {
pcsItemId: serQty.itemId, pcsItemId: serQty.itemId,
pcsOptCd: seletedOption, pcsOptCd: seletedOption,
paralQty: serQty.paralQty, paralQty: serQty.paralQty,
// : itemId 1, // : PCS itemId dedupe
// (Cross Mix conn / · Mix )
connections: item.connList?.length connections: item.connList?.length
? [ ? [
...new Map( ...new Map(
item.connList.map((conn) => [ item.connList
conn.itemId, .filter((conn) => !targetTpCds || !conn.moduleTpCd || targetTpCds.has(conn.moduleTpCd))
{ .map((conn) => [
connItemId: conn.itemId, conn.itemId,
connMaxParalCnt: conn.connMaxParalCnt, {
}, connItemId: conn.itemId,
]), connMaxParalCnt: conn.connMaxParalCnt,
moduleTpCd: conn.moduleTpCd,
},
]),
).values(), ).values(),
] ]
: [], : [],

View File

@ -264,6 +264,8 @@ export function useMasterController() {
*/ */
const getPcsAutoRecommendList = async (params = null) => { const getPcsAutoRecommendList = async (params = null) => {
return await post({ url: '/api/v1/master/getPcsAutoRecommendList', data: params }).then((res) => { 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 return res
}) })
} }
@ -286,6 +288,8 @@ export function useMasterController() {
*/ */
const getPcsVoltageChk = async (params = null) => { const getPcsVoltageChk = async (params = null) => {
return await post({ url: '/api/v1/master/getPcsVoltageChk', data: params }).then((res) => { 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 return res
}) })
} }
@ -322,11 +326,15 @@ export function useMasterController() {
} }
return await post({ url: '/api/v1/master/getPcsVoltageStepUpList', data: params }).then((res) => { 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 return res
}) })
} }
const getQuotationItem = async (params) => { 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 }) return await post({ url: '/api/v1/master/getQuotationItem', data: params })
.then((res) => { .then((res) => {
return res return res
@ -355,6 +363,8 @@ export function useMasterController() {
*/ */
const getPcsConnOptionItemList = async (params = null) => { const getPcsConnOptionItemList = async (params = null) => {
return await post({ url: '/api/v1/master/getPcsConnOptionItemList', data: params }).then((res) => { 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 return res
}) })
} }

View File

@ -2601,8 +2601,16 @@ export const useTrestle = () => {
const { constTp } = moduleSelection.construction const { constTp } = moduleSelection.construction
const { addRoof } = moduleSelection 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 { return {
moduleTpCd: module.itemTp, moduleTpCd: module.itemTp,
subModuleTpCd,
roofMatlCd: parent.roofMaterial.roofMatlCd, roofMatlCd: parent.roofMaterial.roofMatlCd,
mixMatlNo: module.mixMatlNo, mixMatlNo: module.mixMatlNo,
raftBaseCd: addRoof.raft ?? addRoof.raftBaseCd, raftBaseCd: addRoof.raft ?? addRoof.raftBaseCd,

View File

@ -72,6 +72,13 @@ const movingLineFromSkeleton = (roofId, canvas) => {
const endPoint = selectLine.endPoint const endPoint = selectLine.endPoint
const orgRoofPoints = roof.points; // orgPoint를 orgPoints로 변경 const orgRoofPoints = roof.points; // orgPoint를 orgPoints로 변경
const oldPoints = canvas?.skeleton.lastPoints ?? orgRoofPoints // 여기도 변경 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 oppositeLine = findOppositeLine(canvas.skeleton.Edges, startPoint, endPoint, oldPoints);
const wall = canvas.getObjects().find((obj) => obj.name === POLYGON_TYPE.WALL && obj.attributes.roofId === roofId) const wall = canvas.getObjects().find((obj) => obj.name === POLYGON_TYPE.WALL && obj.attributes.roofId === roofId)
@ -419,6 +426,11 @@ const buildRawMovedPoints = (roofId, canvas) => {
const src = (prevLast && prevLast[i]) ? prevLast[i] : orgRoofPoints[i] const src = (prevLast && prevLast[i]) ? prevLast[i] : orgRoofPoints[i]
oldPoints.push({ x: src.x, y: src.y }) 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 const selectLine = roof.moveSelectLine
if (!selectLine) return oldPoints if (!selectLine) return oldPoints
@ -465,6 +477,14 @@ const buildRawMovedPoints = (roofId, canvas) => {
}) })
}) })
// [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 return newPoints
} catch (e) { } catch (e) {
console.warn('[buildRawMovedPoints] 실패 → null fallback:', e) console.warn('[buildRawMovedPoints] 실패 → null fallback:', e)
@ -495,26 +515,44 @@ const buildRawMovedPoints = (roofId, canvas) => {
export const verifyMoveBoundary = (roofId, canvas) => { export const verifyMoveBoundary = (roofId, canvas) => {
try { try {
const roof = canvas?.getObjects().find((o) => o.id === roofId) const roof = canvas?.getObjects().find((o) => o.id === roofId)
if (!roof || !Array.isArray(roof.points)) return 'unknown' if (!roof || !Array.isArray(roof.points)) {
console.log('[verifyMoveBoundary] roof/points 없음 → unknown')
return 'unknown'
}
const moveFlowLine = roof.moveFlowLine ?? 0 const moveFlowLine = roof.moveFlowLine ?? 0
const moveUpDown = roof.moveUpDown ?? 0 const moveUpDown = roof.moveUpDown ?? 0
if (moveFlowLine === 0 && moveUpDown === 0) return 'ok' 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 proposed = buildRawMovedPoints(roofId, canvas)
const orig = roof.points const orig = roof.points
if (!Array.isArray(proposed) || proposed.length !== orig.length) return 'unknown' 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 const n = orig.length
if (n < 3) return 'unknown' if (n < 3) return 'unknown'
const posTol = 0.5 const posTol = 0.5
let worst = 'ok' let worst = 'ok'
const movedIdx = []
for (let i = 0; i < n; i++) { for (let i = 0; i < n; i++) {
const moved = const moved =
Math.abs(proposed[i].x - orig[i].x) > posTol || Math.abs(proposed[i].y - orig[i].y) > posTol Math.abs(proposed[i].x - orig[i].x) > posTol || Math.abs(proposed[i].y - orig[i].y) > posTol
if (!moved) continue if (!moved) continue
movedIdx.push(i)
const prev = (i - 1 + n) % n const prev = (i - 1 + n) % n
const next = (i + 1) % n const next = (i + 1) % n
@ -522,6 +560,10 @@ export const verifyMoveBoundary = (roofId, canvas) => {
const crossOrig = getTurnDirection(orig[prev], orig[i], orig[next]) const crossOrig = getTurnDirection(orig[prev], orig[i], orig[next])
const crossNew = getTurnDirection(proposed[prev], proposed[i], proposed[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) 였던 꼭짓점만 경계 판정 대상 // 원래 골짜기(>0) 였던 꼭짓점만 경계 판정 대상
if (crossOrig > 0) { if (crossOrig > 0) {
// 상대 허용오차: 원본 cross 크기 5% 이내면 "0 근처" 로 간주 (on-boundary) // 상대 허용오차: 원본 cross 크기 5% 이내면 "0 근처" 로 간주 (on-boundary)
@ -542,6 +584,10 @@ export const verifyMoveBoundary = (roofId, canvas) => {
} }
} }
if (movedIdx.length === 0) {
console.log('[verifyMoveBoundary] 이동된 꼭짓점 0개 (buildRawMovedPoints 매칭 실패 가능성)')
}
console.log(`[verifyMoveBoundary] 최종 판정: ${worst} (moved indices=[${movedIdx.join(',')}])`)
return worst return worst
} catch (e) { } catch (e) {
console.warn('[verifyMoveBoundary] 판정 실패 → unknown:', e) console.warn('[verifyMoveBoundary] 판정 실패 → unknown:', e)
@ -795,31 +841,34 @@ export const skeletonBuilder = (roofId, canvas, textMode) => {
// movedPoints를 roof 경계로 확장 // movedPoints를 roof 경계로 확장
// 이번 이동에서 실제로 변경된 포인트만 구분하여, 변경되지 않은 축만 roof 경계로 확장 // 이번 이동에서 실제로 변경된 포인트만 구분하여, 변경되지 않은 축만 roof 경계로 확장
const oldPoints = canvas?.skeleton?.lastPoints ?? roofLinePoints /*
const tolerance = 0.1 * [DEAD CODE 비활성 보존]
const correctedPoints = movedPoints.map((mp, i) => { * 의도: "이번 이동에서 바뀌지 않은 축은 roof 경계로 교정" 하려던 블록.
const rp = roofLinePoints[i] * 비활성화 이유: correctedPoints 결과가 아래 대입(roofLineContactPoints/changRoofLinePoints)에서
const op = oldPoints[i] * movedPoints로 대체되어 실사용되지 않음. 또한 누적 이동 상태에서 "이번에 안 움직인 y축"
if (!rp || !op) return mp * roof 경계로 되돌려 이전 이동(: 1 top) y값을 지우는 부작용 위험이 있어 이전 작업자가
const xMatch = Math.abs(mp.x - rp.x) < tolerance * 대입을 주석 처리한 것으로 보임. 구조/의도 흔적만 남겨 두고 실행은 하지 않는다.
const yMatch = Math.abs(mp.y - rp.y) < tolerance *
if (xMatch && yMatch) return mp * const oldPoints = canvas?.skeleton?.lastPoints ?? roofLinePoints
// 이번 이동에서 실제로 변경된 축 판별 (oldPoints vs movedPoints) * const tolerance = 0.1
const xMoved = Math.abs(mp.x - op.x) >= tolerance * const correctedPoints = movedPoints.map((mp, i) => {
const yMoved = Math.abs(mp.y - op.y) >= tolerance * const rp = roofLinePoints[i]
let newX = mp.x * const op = oldPoints[i]
let newY = mp.y * if (!rp || !op) return mp
// 이번에 이동되지 않았는데 roof 경계와 다른 축만 교정 * const xMatch = Math.abs(mp.x - rp.x) < tolerance
if (!xMoved && !xMatch) newX = rp.x * const yMatch = Math.abs(mp.y - rp.y) < tolerance
if (!yMoved && !yMatch) newY = rp.y * if (xMatch && yMatch) return mp
if (newX !== mp.x || newY !== mp.y) { * const xMoved = Math.abs(mp.x - op.x) >= tolerance
console.log(`point[${i}] 교정: mp=(${mp.x}, ${mp.y}) → (${newX}, ${newY}), op=(${op.x}, ${op.y}), xMoved=${xMoved}, yMoved=${yMoved}`) * const yMoved = Math.abs(mp.y - op.y) >= tolerance
} * let newX = mp.x
return { x: newX, y: newY } * let newY = mp.y
}) * if (!xMoved && !xMatch) newX = rp.x
* if (!yMoved && !yMatch) newY = rp.y
// roofLineContactPoints = correctedPoints * return { x: newX, y: newY }
// changRoofLinePoints = correctedPoints * })
* roofLineContactPoints = correctedPoints
* changRoofLinePoints = correctedPoints
*/
roofLineContactPoints = movedPoints roofLineContactPoints = movedPoints
changRoofLinePoints = movedPoints changRoofLinePoints = movedPoints
@ -959,13 +1008,26 @@ export const skeletonBuilder = (roofId, canvas, textMode) => {
roofId: roofId, roofId: roofId,
// Add other necessary top-level properties // 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 = []
canvas.skeleton = cleanSkeleton canvas.skeleton = cleanSkeleton
if (__preservedLastPoints) canvas.skeleton.lastPoints = __preservedLastPoints
// lastPoints 는 축소되지 않은 풀 길이(roof.points.length) 버전으로 저장해야 // lastPoints 는 축소되지 않은 풀 길이(roof.points.length) 버전으로 저장해야
// 다음 이동 시 인덱스 기반 매칭이 유지됨. raw 재구성 실패 시에만 기존 값 사용. // 다음 이동 시 인덱스 기반 매칭이 유지됨. raw 재구성 실패 시에만 기존 값 사용.
const rawMovedFull = buildRawMovedPoints(roofId, canvas) const rawMovedFull = buildRawMovedPoints(roofId, canvas)
if (rawMovedFull && rawMovedFull.length === (roof.points?.length ?? 0)) { if (rawMovedFull && rawMovedFull.length === (roof.points?.length ?? 0)) {
canvas.skeleton.lastPoints = rawMovedFull 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, '점') console.log('[skeleton] lastPoints 저장: raw 풀 길이', rawMovedFull.length, '점')
} else { } else {
canvas.skeleton.lastPoints = roofLineContactPoints canvas.skeleton.lastPoints = roofLineContactPoints
@ -1226,9 +1288,36 @@ const createInnerLinesFromSkeleton = (roofId, canvas, skeleton, textMode, isOver
// 각 라인 집합 정렬 // 각 라인 집합 정렬
const sortWallLines = ensureCounterClockwiseLines(wallLines) 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) 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' // axis config: vertical(left/right) → moveAxis='x', lineAxis='y'
// horizontal(top/bottom) → moveAxis='y', lineAxis='x' // horizontal(top/bottom) → moveAxis='y', lineAxis='x'
@ -1248,6 +1337,19 @@ const createInnerLinesFromSkeleton = (roofId, canvas, skeleton, textMode, isOver
console.log(`${condition}::::isStartEnd:::::`) 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) { if (isStart) {
newPEnd[lineAxis] = roofLine[`${lineAxis}2`] newPEnd[lineAxis] = roofLine[`${lineAxis}2`]
@ -1257,7 +1359,7 @@ const createInnerLinesFromSkeleton = (roofId, canvas, skeleton, textMode, isOver
newPStart[moveAxis] = roofLine[`${moveAxis}1`] 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] } const ePoint = { [moveAxis]: wallBaseLine[m], [lineAxis]: wallBaseLine[l] }
if (isStart) { if (isStart) {
newPStart[lineAxis] = wallBaseLine[l] newPStart[lineAxis] = wallBaseLine[l]
@ -1267,7 +1369,9 @@ const createInnerLinesFromSkeleton = (roofId, canvas, skeleton, textMode, isOver
findPoints.push({ ...ePoint, position: `${condition}_${isStart ? 'start' : 'end'}` }) 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 pLineL = roofLine[l]
const prevIndex = (index - 1 + sortRoofLines.length) % sortRoofLines.length const prevIndex = (index - 1 + sortRoofLines.length) % sortRoofLines.length
@ -1423,6 +1527,18 @@ const createInnerLinesFromSkeleton = (roofId, canvas, skeleton, textMode, isOver
const roofLine = sortRoofLines[index] const roofLine = sortRoofLines[index]
const wallBaseLine = sortWallBaseLines[index] const wallBaseLine = sortWallBaseLines[index]
// [진단] 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 외곽선 설정 //roofline 외곽선 설정
// console.log('index::::', index) // console.log('index::::', index)
@ -1469,6 +1585,17 @@ const createInnerLinesFromSkeleton = (roofId, canvas, skeleton, textMode, isOver
const getAddLine = (p1, p2, stroke = '', lineType = 'eaveHelpLine') => { const getAddLine = (p1, p2, stroke = '', lineType = 'eaveHelpLine') => {
movedLines.push({ index, p1, p2 }) 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 dx = Math.abs(p2.x - p1.x);
const dy = Math.abs(p2.y - p1.y); const dy = Math.abs(p2.y - p1.y);
const isDiagonal = dx > 0.5 && dy > 0.5; // x, y 변화가 모두 있으면 대각선 const isDiagonal = dx > 0.5 && dy > 0.5; // x, y 변화가 모두 있으면 대각선
@ -1530,6 +1657,16 @@ const createInnerLinesFromSkeleton = (roofId, canvas, skeleton, textMode, isOver
const mLine = getSelectLinePosition(wall, wallBaseLine) 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 (getOrientation(roofLine) === 'vertical') {
if (['left', 'right'].includes(mLine.position)) { if (['left', 'right'].includes(mLine.position)) {
if (Math.abs(wallLine.x1 - wallBaseLine.x1) < 0.1 || Math.abs(wallLine.x2 - wallBaseLine.x2) < 0.1) { if (Math.abs(wallLine.x1 - wallBaseLine.x1) < 0.1 || Math.abs(wallLine.x2 - wallBaseLine.x2) < 0.1) {
@ -3682,22 +3819,22 @@ function getOrderedBasePoints(baseLines) {
function createOrderedBasePoints(roofPoints, baseLines) { function createOrderedBasePoints(roofPoints, baseLines) {
const basePoints = []; const basePoints = [];
// baseLines에서 연결된 순서대로 점들을 추출 // wall 생성 시 baseLines[i]는 polygon edge i (roofPoints[i]→roofPoints[i+1])에 대응해 push되므로
const orderedBasePoints = getOrderedBasePoints(baseLines); // baseLines[i].startPoint 는 roofPoints[i]의 wall 좌표이다(= 인덱스 직접 정합).
//
// roofPoints의 개수와 맞추기 // 기존 구현은 getOrderedBasePoints(graph traversal)을 사용해 baseLines[0] 시작점과
if (orderedBasePoints.length >= roofPoints.length) { // 체인 isSamePoint 성공에 결과 순서가 의존했고, 평행 이동으로 좌표가 mutate되면
return orderedBasePoints.slice(0, roofPoints.length); // 체인이 어긋나 roof.points 인덱스와 정합이 깨져 엉뚱한 꼭짓점에 이동이 적용되는
} // SK 붕괴 원인이 됐다. 인덱스 직접 매핑으로 불변식을 확보한다.
for (let i = 0; i < roofPoints.length; i++) {
// 부족한 경우 roofPoints 기반으로 보완 const bl = baseLines[i];
roofPoints.forEach((roofPoint, index) => { if (bl && bl.startPoint) {
if (index < orderedBasePoints.length) { basePoints.push({ ...bl.startPoint });
basePoints.push(orderedBasePoints[index]);
} else { } else {
basePoints.push({ ...roofPoint }); // fallback // baseLines 수가 부족한 예외 상황에만 roof 좌표로 fallback
basePoints.push({ ...roofPoints[i] });
} }
}); }
return basePoints; return basePoints;
} }
@ -4122,14 +4259,20 @@ function isPointOnLineSegment(point, lineStart, lineEnd, tolerance = 0.1) {
*/ */
function updateAndAddLine(innerLines, targetPoint) { 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 nonHipLines = innerLines.filter(line => line.attributes?.type !== LINE_TYPE.SUBLINE.HIP)
const foundLine = findLineContainingPoint(nonHipLines, targetPoint); let 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}`)
if (!foundLine) { 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]; return [...innerLines];
} }