From 10d9079ca5dc8eada06b25a555f344b079dd6738 Mon Sep 17 00:00:00 2001 From: "hyojun.choi" Date: Fri, 20 Mar 2026 10:19:00 +0900 Subject: [PATCH 01/38] =?UTF-8?q?=EC=B2=98=EB=A7=88=EC=BB=A4=EB=B2=84=20?= =?UTF-8?q?=EC=84=A4=EC=B9=98=EC=9D=B4=EC=83=81=20=ED=98=84=EC=83=81=20?= =?UTF-8?q?=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/hooks/module/useTrestle.js | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/src/hooks/module/useTrestle.js b/src/hooks/module/useTrestle.js index 0ff486dc..173a5cd2 100644 --- a/src/hooks/module/useTrestle.js +++ b/src/hooks/module/useTrestle.js @@ -106,6 +106,7 @@ export const useTrestle = () => { console.log('모듈이 없거나 계산 실패:', surface.modules?.length || 0) return } + const centerPoints = result.centerPoints const exposedBottomModules = [] // 아래 두면이 모두 노출 되어있는 경우 @@ -164,11 +165,8 @@ export const useTrestle = () => { if (isEaveBar) { // 처마력바설치 true인 경우 설치 + // exposedBottomModules는 아래가 노출된 최하단 모듈이므로 level 체크 없이 항상 설치 exposedBottomModules.forEach((module) => { - const level = module.level - if (level > cvrLmtRow) { - return - } const bottomPoints = findTopTwoPoints([...module.getCurrentPoints()], direction) if (!bottomPoints) return const eaveBar = new fabric.Line([bottomPoints[0].x, bottomPoints[0].y, bottomPoints[1].x, bottomPoints[1].y], { From 56b4ee9563b2fca711ba4f39c22e5d95e274a4a3 Mon Sep 17 00:00:00 2001 From: ysCha Date: Fri, 20 Mar 2026 18:09:50 +0900 Subject: [PATCH 02/38] =?UTF-8?q?[1766]=EB=B0=A9=EC=9C=84=20=EB=B3=80?= =?UTF-8?q?=EA=B2=BD=20=EC=B2=B4=ED=81=AC=EB=B0=95=EC=8A=A4=EB=A5=BC=20?= =?UTF-8?q?=EB=84=A3=EC=97=88=EC=9D=84=20=EB=95=8C=20=EA=B2=AC=EC=A0=81?= =?UTF-8?q?=EC=84=9C=20=ED=91=9C=EA=B8=B0=20=EB=B0=A9=EB=B2=95=EC=97=90=20?= =?UTF-8?q?=EB=8C=80=ED=95=B4(1)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/components/floor-plan/modal/basic/step/Orientation.jsx | 6 +----- 1 file changed, 1 insertion(+), 5 deletions(-) diff --git a/src/components/floor-plan/modal/basic/step/Orientation.jsx b/src/components/floor-plan/modal/basic/step/Orientation.jsx index fe00c93b..733cddb1 100644 --- a/src/components/floor-plan/modal/basic/step/Orientation.jsx +++ b/src/components/floor-plan/modal/basic/step/Orientation.jsx @@ -453,13 +453,9 @@ export const Orientation = forwardRef((props, ref) => { value={inputCompasDeg} readOnly={!hasAnglePassivity} onChange={(value) => { - // Convert to number and ensure it's within -180 to 180 range const numValue = parseInt(value, 10); if (!isNaN(numValue)) { - const clampedValue = Math.min(180, Math.max(-180, numValue)); - setInputCompasDeg(String(clampedValue)); - } else { - setInputCompasDeg(value); + setInputCompasDeg(Math.min(180, Math.max(-180, numValue))); } }} options={{ From 9d491bd8aa8b3a736cac812a10d0b9be29672f70 Mon Sep 17 00:00:00 2001 From: "hyojun.choi" Date: Wed, 1 Apr 2026 17:11:58 +0900 Subject: [PATCH 03/38] =?UTF-8?q?1830=20=EB=AA=A8=EB=93=88=20=EC=84=A4?= =?UTF-8?q?=EC=B9=98=EC=98=81=EC=97=AD=20=EB=8C=80=EA=B0=81=20=EC=9E=88?= =?UTF-8?q?=EB=8A=94=20=ED=98=84=EC=83=81=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/hooks/useMode.js | 48 ++++++++++++++++++++++++++++++++++++++------ 1 file changed, 42 insertions(+), 6 deletions(-) diff --git a/src/hooks/useMode.js b/src/hooks/useMode.js index 8492042a..d014b25f 100644 --- a/src/hooks/useMode.js +++ b/src/hooks/useMode.js @@ -1756,6 +1756,11 @@ export function useMode() { offsetEdges.push(createOffsetEdge(edge, dx, dy)) }) + // 폴리곤의 감김 방향(signed area)을 구한다 + const signedArea = bevelJoin + ? polygon.edges.reduce((sum, edge) => sum + (edge.vertex1.x * edge.vertex2.y - edge.vertex2.x * edge.vertex1.y), 0) + : 0 + const vertices = [] offsetEdges.forEach((thisEdge, i) => { @@ -1765,9 +1770,22 @@ export function useMode() { if (!vertex.isIntersectionOutside || !bevelJoin) { vertices.push({ x: vertex.x, y: vertex.y }) } else { - // 둔각 + bevelJoin: offset edge 끝점 사용 - vertices.push({ x: prevEdge.vertex2.x, y: prevEdge.vertex2.y }) - vertices.push({ x: thisEdge.vertex1.x, y: thisEdge.vertex1.y }) + // 꼭짓점의 볼록/오목 판별 (cross product와 signed area 비교) + const origPrev = polygon.edges[(i + polygon.edges.length - 1) % polygon.edges.length] + const origThis = polygon.edges[i] + const cross = + (origPrev.vertex2.x - origPrev.vertex1.x) * (origThis.vertex2.y - origThis.vertex1.y) - + (origPrev.vertex2.y - origPrev.vertex1.y) * (origThis.vertex2.x - origThis.vertex1.x) + const isConvex = cross * signedArea > 0 + + if (isConvex) { + // 볼록 꼭짓점: bevel 적용 (offset edge 끝점 사용) + vertices.push({ x: prevEdge.vertex2.x, y: prevEdge.vertex2.y }) + vertices.push({ x: thisEdge.vertex1.x, y: thisEdge.vertex1.y }) + } else { + // 오목 꼭짓점: 교차점 사용 (90도 유지) + vertices.push({ x: vertex.x, y: vertex.y }) + } } } }) @@ -1795,6 +1813,11 @@ export function useMode() { offsetEdges.push(createOffsetEdge(edge, dx, dy)) }) + // 폴리곤의 감김 방향(signed area)을 구한다 + const signedArea = bevelJoin + ? polygon.edges.reduce((sum, edge) => sum + (edge.vertex1.x * edge.vertex2.y - edge.vertex2.x * edge.vertex1.y), 0) + : 0 + const vertices = [] offsetEdges.forEach((thisEdge, i) => { @@ -1804,9 +1827,22 @@ export function useMode() { if (!vertex.isIntersectionOutside || !bevelJoin) { vertices.push({ x: vertex.x, y: vertex.y }) } else { - // 둔각 + bevelJoin: offset edge 끝점 사용 - vertices.push({ x: prevEdge.vertex2.x, y: prevEdge.vertex2.y }) - vertices.push({ x: thisEdge.vertex1.x, y: thisEdge.vertex1.y }) + // 꼭짓점의 볼록/오목 판별 (cross product와 signed area 비교) + const origPrev = polygon.edges[(i + polygon.edges.length - 1) % polygon.edges.length] + const origThis = polygon.edges[i] + const cross = + (origPrev.vertex2.x - origPrev.vertex1.x) * (origThis.vertex2.y - origThis.vertex1.y) - + (origPrev.vertex2.y - origPrev.vertex1.y) * (origThis.vertex2.x - origThis.vertex1.x) + const isConvex = cross * signedArea > 0 + + if (isConvex) { + // 볼록 꼭짓점: bevel 적용 (offset edge 끝점 사용) + vertices.push({ x: prevEdge.vertex2.x, y: prevEdge.vertex2.y }) + vertices.push({ x: thisEdge.vertex1.x, y: thisEdge.vertex1.y }) + } else { + // 오목 꼭짓점: 교차점 사용 (90도 유지) + vertices.push({ x: vertex.x, y: vertex.y }) + } } } }) From e04ff156d6f76ad5ed9f6a2b5f25da25230e471f Mon Sep 17 00:00:00 2001 From: ysCha Date: Wed, 1 Apr 2026 17:48:25 +0900 Subject: [PATCH 04/38] =?UTF-8?q?Revert=20"=EC=88=98=EB=8F=99=20=EB=B0=B0?= =?UTF-8?q?=EC=B9=98=20=EC=8B=9C=20=EC=9E=90=EB=8F=99=20=EB=AC=BC=EB=96=BC?= =?UTF-8?q?=EC=84=B8=20=EB=B0=B0=EC=B9=98=20=ED=95=9C=EB=8B=A4=EB=A1=9C=20?= =?UTF-8?q?=EC=88=98=EC=A0=95"?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit This reverts commit b1c35376da012c1b685f7b58a0b14b3ffddf5425. --- src/components/floor-plan/modal/basic/BasicSetting.jsx | 10 ++-------- 1 file changed, 2 insertions(+), 8 deletions(-) diff --git a/src/components/floor-plan/modal/basic/BasicSetting.jsx b/src/components/floor-plan/modal/basic/BasicSetting.jsx index 04dbbcb0..983492bf 100644 --- a/src/components/floor-plan/modal/basic/BasicSetting.jsx +++ b/src/components/floor-plan/modal/basic/BasicSetting.jsx @@ -17,7 +17,6 @@ import { currentCanvasPlanState, isManualModuleLayoutSetupState, isManualModuleSetupState, - moduleSetupOptionState, toggleManualSetupModeState, } from '@/store/canvasAtom' import { loginUserStore } from '@/store/commonAtom' @@ -48,7 +47,6 @@ export default function BasicSetting({ id, pos = { x: 50, y: 230 } }) { const [checkedModules, setCheckedModules] = useRecoilState(checkedModuleState) const [roofs, setRoofs] = useState(addedRoofs) const [manualSetupMode, setManualSetupMode] = useRecoilState(toggleManualSetupModeState) - const [moduleSetupOption, setModuleSetupOption] = useRecoilState(moduleSetupOptionState) const [layoutSetup, setLayoutSetup] = useState([{}]) const { selectedModules, @@ -213,12 +211,8 @@ export default function BasicSetting({ id, pos = { x: 50, y: 230 } }) { } const handleManualModuleSetup = () => { - const nextManual = !isManualModuleSetup - setManualSetupMode(`manualSetup_${nextManual}`) - setIsManualModuleSetup(nextManual) - if (nextManual) { - setModuleSetupOption((prev) => ({ ...prev, isChidori: true })) - } + setManualSetupMode(`manualSetup_${!isManualModuleSetup}`) + setIsManualModuleSetup(!isManualModuleSetup) } const handleManualModuleLayoutSetup = () => { From e0a4da21b8b113169bb3149444f1afa7c46ee9a8 Mon Sep 17 00:00:00 2001 From: ysCha Date: Thu, 2 Apr 2026 18:27:12 +0900 Subject: [PATCH 05/38] =?UTF-8?q?[1480]=EC=82=AC=EC=9D=98=20=EA=B0=81?= =?UTF-8?q?=EB=8F=84=20=EC=84=A4=EC=A0=95=EC=9D=84=20"=EA=B0=81=EB=8F=84"?= =?UTF-8?q?=EB=A1=9C=20=ED=96=88=EC=9D=84=20=EB=95=8C=20=EB=B0=9C=EC=83=9D?= =?UTF-8?q?=ED=95=98=EB=8A=94=20=EB=AC=B8=EC=A0=9C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/hooks/module/useTrestle.js | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/hooks/module/useTrestle.js b/src/hooks/module/useTrestle.js index 19da5b4e..fad9a810 100644 --- a/src/hooks/module/useTrestle.js +++ b/src/hooks/module/useTrestle.js @@ -799,6 +799,7 @@ export const useTrestle = () => { const parent = canvas.getObjects().find((obj) => obj.id === surface.parentId) const { directionText, roofMaterial, moduleCompass, surfaceCompass } = parent const slope = Number(roofMaterial.pitch) + const angle = Number(roofMaterial.angle) const roofMaterialIndex = parent.roofMaterial.index const { nameJp: roofMaterialIdMulti } = roofMaterial const moduleSelection = moduleSelectionData?.roofConstructions?.find((construction) => construction.roofIndex === roofMaterialIndex) @@ -836,7 +837,7 @@ export const useTrestle = () => { supportMeaker, slope: +roofSizeSet === 3 ? 0 : slope, classType: currentAngleType === 'slope' ? '0' : '1', - angle: +roofSizeSet === 3 ? 0 : getDegreeByChon(slope), + angle: +roofSizeSet === 3 ? 0 : angle, azimuth: getAzimuth(parent), moduleList, } From b7ba6d8fc17ab599144e93350d8b821922ee7796 Mon Sep 17 00:00:00 2001 From: ysCha Date: Fri, 3 Apr 2026 09:12:43 +0900 Subject: [PATCH 06/38] =?UTF-8?q?[1480]=EC=82=AC=EC=9D=98=20=EA=B0=81?= =?UTF-8?q?=EB=8F=84=20=EC=84=A4=EC=A0=95=EC=9D=84=20"=EA=B0=81=EB=8F=84"?= =?UTF-8?q?=EB=A1=9C=20=ED=96=88=EC=9D=84=20=EB=95=8C=20=EB=B0=9C=EC=83=9D?= =?UTF-8?q?=ED=95=98=EB=8A=94=20=EB=AC=B8=EC=A0=9C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/hooks/module/useTrestle.js | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/hooks/module/useTrestle.js b/src/hooks/module/useTrestle.js index fad9a810..411fc118 100644 --- a/src/hooks/module/useTrestle.js +++ b/src/hooks/module/useTrestle.js @@ -799,7 +799,7 @@ export const useTrestle = () => { const parent = canvas.getObjects().find((obj) => obj.id === surface.parentId) const { directionText, roofMaterial, moduleCompass, surfaceCompass } = parent const slope = Number(roofMaterial.pitch) - const angle = Number(roofMaterial.angle) + const angle = Number(roofMaterial.angle) || getDegreeByChon(slope) const roofMaterialIndex = parent.roofMaterial.index const { nameJp: roofMaterialIdMulti } = roofMaterial const moduleSelection = moduleSelectionData?.roofConstructions?.find((construction) => construction.roofIndex === roofMaterialIndex) @@ -1123,7 +1123,7 @@ export const useTrestle = () => { return } const roof = canvas.getObjects().find((obj) => obj.id === surface.parentId) - const degree = getDegreeByChon(roof.roofMaterial.pitch) + const degree = Number(roof.roofMaterial.angle) || getDegreeByChon(roof.roofMaterial.pitch) rackIntvlPct = rackIntvlPct === 0 ? 1 : rackIntvlPct // 0인 경우 1로 변경 rackIntvlPct = 100 / rackIntvlPct // 퍼센트로 변경 const moduleLeft = lastX ?? left From 1028d210ad35af1ba36a726bb2eaca2b0bff182e Mon Sep 17 00:00:00 2001 From: "hyojun.choi" Date: Thu, 16 Apr 2026 11:23:23 +0900 Subject: [PATCH 07/38] =?UTF-8?q?=ED=9A=8C=EB=A1=9C=20=EC=84=A4=EC=A0=95?= =?UTF-8?q?=20=EC=9D=B4=EC=83=81=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../modal/circuitTrestle/step/StepUp.jsx | 150 ++++++++++++------ 1 file changed, 98 insertions(+), 52 deletions(-) diff --git a/src/components/floor-plan/modal/circuitTrestle/step/StepUp.jsx b/src/components/floor-plan/modal/circuitTrestle/step/StepUp.jsx index 14f6aa03..da6ea213 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)) /** 새로운 모듈 회로 정보 추가 */ @@ -410,61 +408,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 +520,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 +558,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,7 +569,7 @@ export default function StepUp(props) { } /** API 재조회가 없는 경로: 현재 tempStepUpListData 로 즉시 캔버스 갱신 */ - applyCircuitsToCanvas(tempStepUpListData) + applyCircuitsToCanvas(tempStepUpListData, mainIdx, subIdx) } /** @@ -551,7 +587,17 @@ export default function StepUp(props) { paralQty: serQty.paralQty, // 접속함 중복 체크: 동일 itemId는 1개만, 다르면 각각 올림 connections: item.connList?.length - ? [...new Map(item.connList.map((conn) => [conn.itemId, { connItemId: conn.itemId, connMaxParalCnt: conn.connMaxParalCnt }])).values()] + ? [ + ...new Map( + item.connList.map((conn) => [ + conn.itemId, + { + connItemId: conn.itemId, + connMaxParalCnt: conn.connMaxParalCnt, + }, + ]), + ).values(), + ] : [], } }) From 8d6c4a6504028650b16e2ba477e803b381daa5ac Mon Sep 17 00:00:00 2001 From: ysCha Date: Thu, 16 Apr 2026 13:18:39 +0900 Subject: [PATCH 08/38] =?UTF-8?q?[1806]=EC=84=9C=EA=B9=8C=EB=9E=98=20?= =?UTF-8?q?=EB=86=92=EC=9D=B4=20=EC=A1=B0=EC=A0=95=EC=97=90=20=EB=94=B0?= =?UTF-8?q?=EB=A5=B8=20=EC=A7=80=EB=B6=95=20=ED=98=95=EC=83=81=20=EB=B6=88?= =?UTF-8?q?=EC=9D=BC=EC=B9=98=20-=20=EB=9D=BC=EC=9D=B8=EC=98=A4=EB=B2=84?= =?UTF-8?q?=EC=B2=B4=ED=81=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/util/skeleton-utils.js | 211 +++++++++++++++++++++++++++++++++++-- 1 file changed, 204 insertions(+), 7 deletions(-) diff --git a/src/util/skeleton-utils.js b/src/util/skeleton-utils.js index 22eaea64..f713693b 100644 --- a/src/util/skeleton-utils.js +++ b/src/util/skeleton-utils.js @@ -559,21 +559,108 @@ 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() 별도 함수로 보정 포인트 생성 + // ※ changRoofLinePoints 자체는 절대 수정하지 않음 + // ※ 오버 보정 실패 시 changRoofLinePoints 그대로 fallback + // ────────────────────────────────────────────────────────────────── + let skeletonInputPoints = changRoofLinePoints // 정상 경로 + + if (isOverDetected) { + // [예외] 오버 감지 시 → 별도 함수에서 스켈레톤 전용 보정 포인트 계산 + try { + const corrected = calcOverCorrectedPoints(changRoofLinePoints, origPoints) + 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) { + // 보정 실패 시 기존 changRoofLinePoints로 fallback → 기존 동작 보장 + 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) //console.log("roofInnerLines:::", roof.innerLines); // 캔버스에 스켈레톤 상태 저장 if (!canvas.skeletonStates) { @@ -606,7 +693,9 @@ export const skeletonBuilder = (roofId, canvas, textMode) => { //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)) if (canvas.skeletonStates) { canvas.skeletonStates[roofId] = false canvas.skeletonStates = {} @@ -624,7 +713,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) => { 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) @@ -1083,8 +1172,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 @@ -1684,7 +1771,9 @@ const createInnerLinesFromSkeleton = (roofId, canvas, skeleton, textMode) => { canvas.renderAll() }) } - getMoveUpDownLine() + if (!isOverDetected) { + getMoveUpDownLine() + } } @@ -2000,6 +2089,114 @@ 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}>} 보정된 스켈레톤 입력 포인트 (실패 시 원본 반환) + */ +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} From c872d5c0f64c3068aa98e3d39016023c6ca55fb5 Mon Sep 17 00:00:00 2001 From: ysCha Date: Fri, 17 Apr 2026 18:04:05 +0900 Subject: [PATCH 09/38] =?UTF-8?q?[1960]=EC=B6=9C=EB=A0=A5=EB=90=9C=20[?= =?UTF-8?q?=EB=B0=B0=EC=B9=98=EB=8F=84=C2=B7=EA=B3=84=ED=86=B5=EB=8F=84]?= =?UTF-8?q?=EC=97=90=20=EA=B0=80=EB=8C=80=EA=B0=80=20=ED=91=9C=EC=8B=9C?= =?UTF-8?q?=EB=90=98=EB=8A=94=20=EA=B2=BD=EC=9A=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../circuitTrestle/CircuitTrestleSetting.jsx | 13 ++++++++++++- src/hooks/floorPlan/useImgLoader.js | 15 ++++++++++++++- 2 files changed, 26 insertions(+), 2 deletions(-) diff --git a/src/components/floor-plan/modal/circuitTrestle/CircuitTrestleSetting.jsx b/src/components/floor-plan/modal/circuitTrestle/CircuitTrestleSetting.jsx index 83d6f6c3..011cfe54 100644 --- a/src/components/floor-plan/modal/circuitTrestle/CircuitTrestleSetting.jsx +++ b/src/components/floor-plan/modal/circuitTrestle/CircuitTrestleSetting.jsx @@ -687,7 +687,18 @@ export default function CircuitTrestleSetting({ id }) { .map((obj) => { obj.pcses = getStepUpListData() }) - await capture(1) + // 캡처 실패 시 에러 팝업 후 중단 (다시 회로구성 가능) + try { + await capture(1) + } catch (e) { + console.log('🚀 ~ capture(1) error:', e) + setIsGlobalLoading(false) + swalFire({ + text: getMessage('common.message.send.error'), + icon: 'error', + }) + return + } //회로할당 저장 시 result=null인 경우에도 회로번호 텍스트 표시 유지 처리 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 } } From a4d3f7a4c9489c6eb0145248ecb52ca4d6fc521e Mon Sep 17 00:00:00 2001 From: ysCha Date: Wed, 22 Apr 2026 16:39:28 +0900 Subject: [PATCH 10/38] =?UTF-8?q?sk=EB=9D=BC=EC=9D=B8=20=EA=B8=B0=EB=A1=9D?= =?UTF-8?q?=20=EB=B3=B4=EC=A1=B4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/components/fabric/QPolygon.js | 18 +- src/util/skeleton-utils.js | 382 ++++++++++++++++++++++++++++-- 2 files changed, 376 insertions(+), 24 deletions(-) diff --git a/src/components/fabric/QPolygon.js b/src/components/fabric/QPolygon.js index b89e9daf..8e898d37 100644 --- a/src/components/fabric/QPolygon.js +++ b/src/components/fabric/QPolygon.js @@ -6,7 +6,7 @@ import { calculateAngle, drawGableRoof, drawRoofByAttribute, drawShedRoof, toGeo import * as turf from '@turf/turf' import { LINE_TYPE, POLYGON_TYPE } from '@/common/common' import Big from 'big.js' -import { drawSkeletonRidgeRoof } from '@/util/skeleton-utils' +import { drawSkeletonRidgeRoof, verifyMoveBoundary } from '@/util/skeleton-utils' export const QPolygon = fabric.util.createClass(fabric.Polygon, { type: 'QPolygon', @@ -382,6 +382,22 @@ export const QPolygon = fabric.util.createClass(fabric.Polygon, { * @param settingModalFirstOptions */ drawHelpLine(settingModalFirstOptions) { + // [보호 가드] 이번 이동이 골짜기 경계를 넘어가는 과한 이동이면 + // 아예 초기화/재빌드 자체를 수행하지 않고 현재 상태 그대로 유지한다. + // ※ 기존 drawHelpLine 로직은 건드리지 않고, 진입부에 가드 한 블록만 추가. + // ※ 최초 그리기·offset 변경 등 moveUpDown=0 / moveFlowLine=0 케이스는 + // verifyMoveBoundary 가 'ok' 를 돌려주므로 영향 없음. + try { + const __verdict = verifyMoveBoundary(this.id, this.canvas) + if (__verdict === 'crossed') { + console.warn('[drawHelpLine] 경계 넘음(crossed) → 초기화/재빌드 스킵 (기존 innerLines/SK 유지)') + return + } + } catch (e) { + // 판정기 자체 실패는 무시하고 기존 흐름 진행 (안전한 no-op) + console.warn('[drawHelpLine] verifyMoveBoundary 예외 → 기존 흐름 진행:', e) + } + /* innerLines 초기화 */ this.canvas .getObjects() diff --git a/src/util/skeleton-utils.js b/src/util/skeleton-utils.js index f713693b..56606bde 100644 --- a/src/util/skeleton-utils.js +++ b/src/util/skeleton-utils.js @@ -364,6 +364,264 @@ 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 }) + } + + 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 + + const matchingLines = baseLines.filter((line) => + (isSamePoint(line.startPoint, selectLine.startPoint) && isSamePoint(line.endPoint, selectLine.endPoint)) || + (isSamePoint(line.startPoint, selectLine.endPoint) && isSamePoint(line.endPoint, selectLine.startPoint)) + ) + + matchingLines.forEach((line) => { + const s = line.startPoint + const e = line.endPoint + newPoints.forEach((point, index) => { + const bp = basePoints[index] + if (!bp) return + const matchS = isSamePoint(bp, s) + const matchE = isSamePoint(bp, e) + if (!matchS && !matchE) 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() + } + }) + }) + + 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)) return 'unknown' + + const moveFlowLine = roof.moveFlowLine ?? 0 + const moveUpDown = roof.moveUpDown ?? 0 + if (moveFlowLine === 0 && moveUpDown === 0) return 'ok' + + const proposed = buildRawMovedPoints(roofId, canvas) + const orig = roof.points + if (!Array.isArray(proposed) || proposed.length !== orig.length) return 'unknown' + + const n = orig.length + if (n < 3) return 'unknown' + + const posTol = 0.5 + let worst = 'ok' + + 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 + + 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]) + + // 원래 골짜기(>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' + } + } + } + + 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,7 +780,17 @@ export const skeletonBuilder = (roofId, canvas, textMode) => { // 6. 마루이동(용마루 위치 수동 조정)이 설정된 경우 movingLineFromSkeleton 결과로 대체 if (moveFlowLine !== 0 || moveUpDown !== 0) { - const movedPoints = movingLineFromSkeleton(roofId, canvas) + // [보호 가드] 이동이 골짜기 경계를 넘어가는지 사전 판정 + // 'crossed' → 외곽선을 뒤집을 만큼 과한 이동 → 기존 SK/innerLines/skeletonLines 전부 보존 후 조용히 종료 + // 'ok' | 'on-boundary' | 'unknown' → 기존 흐름 그대로 진행 + // ※ 기존 로직은 전혀 수정하지 않고, 진입부에 한 블록만 추가 + const __moveVerdict = verifyMoveBoundary(roofId, canvas) + if (__moveVerdict === 'crossed') { + console.warn('[skeleton] 경계 넘음(crossed) → 재빌드 스킵 (기존 SK/innerLines/skeletonLines 유지)') + return + } + + const movedPoints = safeMovedPointsWithFallback(roofId, canvas) // console.log("movedPoints:::", movedPoints); // movedPoints를 roof 경계로 확장 @@ -637,7 +905,13 @@ export const skeletonBuilder = (roofId, canvas, textMode) => { if (isOverDetected) { // [예외] 오버 감지 시 → 별도 함수에서 스켈레톤 전용 보정 포인트 계산 try { - const corrected = calcOverCorrectedPoints(changRoofLinePoints, origPoints) + // 누적 이동 보호: 이전 SK 확정 상태(lastPoints)를 전달하여 + // 1차 이동에서 이미 확정된 점은 보정 대상에서 제외한다. + const corrected = calcOverCorrectedPointsSafe( + changRoofLinePoints, + origPoints, + canvas?.skeleton?.lastPoints ?? null + ) if (corrected && corrected.length >= 3) { skeletonInputPoints = corrected console.log('[SK_OVER] 보정 포인트 적용:', skeletonInputPoints.map((p, i) => `[${i}](${Math.round(p.x)},${Math.round(p.y)})`)) @@ -687,7 +961,15 @@ export const skeletonBuilder = (roofId, canvas, textMode) => { } canvas.skeleton = [] canvas.skeleton = cleanSkeleton - canvas.skeleton.lastPoints = roofLineContactPoints + // lastPoints 는 축소되지 않은 풀 길이(roof.points.length) 버전으로 저장해야 + // 다음 이동 시 인덱스 기반 매칭이 유지됨. raw 재구성 실패 시에만 기존 값 사용. + const rawMovedFull = buildRawMovedPoints(roofId, canvas) + if (rawMovedFull && rawMovedFull.length === (roof.points?.length ?? 0)) { + canvas.skeleton.lastPoints = rawMovedFull + console.log('[skeleton] lastPoints 저장: raw 풀 길이', rawMovedFull.length, '점') + } else { + canvas.skeleton.lastPoints = roofLineContactPoints + } canvas.set('skeleton', cleanSkeleton) canvas.renderAll() @@ -696,11 +978,12 @@ export const skeletonBuilder = (roofId, canvas, textMode) => { console.error('[SK_ERROR] 스켈레톤 생성 중 오류 발생:', e) console.error('[SK_ERROR] 입력 좌표:', changRoofLinePoints.map((p, i) => `[${i}] (${p.x.toFixed(2)},${p.y.toFixed(2)})`)) console.error('[SK_ERROR] geoJSON:', JSON.stringify(geoJSONPolygon)) + // [보호] 예외 시 상태 플래그만 복구하고, 기존 SK/innerLines는 유지한다. + // → 사용자가 힘들게 추가한 라인들이 순간적으로 사라지는 현상 방지. if (canvas.skeletonStates) { canvas.skeletonStates[roofId] = false - canvas.skeletonStates = {} - canvas.skeletonLines = [] } + // 주의: canvas.skeletonLines 는 의도적으로 비우지 않음 (이전 성공 상태 보존) } } @@ -1866,17 +2149,17 @@ function processEavesEdge(roofId, canvas, skeleton, edgeResult, skeletonLines) { return false } - console.log('📐 [processEavesEdge] face 분석:', { - hasOuterLine: !!outerLine, - outerLineType: outerLine?.attributes?.type, - pitch, - roofLineIndex, - edgeBegin: { x: Math.round(Begin.X), y: Math.round(Begin.Y) }, - edgeEnd: { x: Math.round(End.X), y: Math.round(End.Y) }, - polygonPointCount: polygonPoints.length, - polygonPoints: polygonPoints.map(p => ({ x: Math.round(p.x), y: Math.round(p.y) })), - skPtsCount: skPts.length, - }) + // console.log('📐 [processEavesEdge] face 분석:', { + // hasOuterLine: !!outerLine, + // outerLineType: outerLine?.attributes?.type, + // pitch, + // roofLineIndex, + // edgeBegin: { x: Math.round(Begin.X), y: Math.round(Begin.Y) }, + // edgeEnd: { x: Math.round(End.X), y: Math.round(End.Y) }, + // polygonPointCount: polygonPoints.length, + // polygonPoints: polygonPoints.map(p => ({ x: Math.round(p.x), y: Math.round(p.y) })), + // skPtsCount: skPts.length, + // }) for (let i = 0; i < polygonPoints.length; i++) { const p1 = polygonPoints[i]; @@ -1886,7 +2169,7 @@ function processEavesEdge(roofId, canvas, skeleton, edgeResult, skeletonLines) { // 확장된 외곽선에 해당하는 edge는 스킵 if (_isSkipOuter) { - console.log(' [edge', i, '] SKIP(outerEdge)', { p1: {x: Math.round(p1.x), y: Math.round(p1.y)}, p2: {x: Math.round(p2.x), y: Math.round(p2.y)} }) + // console.log(' [edge', i, '] SKIP(outerEdge)', { p1: {x: Math.round(p1.x), y: Math.round(p1.y)}, p2: {x: Math.round(p2.x), y: Math.round(p2.y)} }) continue } @@ -1895,10 +2178,10 @@ function processEavesEdge(roofId, canvas, skeleton, edgeResult, skeletonLines) { //console.log('clipped line', clippedLine.p1, clippedLine.p2); const isOuterLine = isOuterEdge(clippedLine.p1, clippedLine.p2, [edgeResult.Edge]) - const _dx = Math.abs(clippedLine.p2.x - clippedLine.p1.x) - const _dy = Math.abs(clippedLine.p2.y - clippedLine.p1.y) - const _lineType = (_dx < 0.5 && _dy > 0.5) ? '⚠️수직' : (_dy < 0.5 && _dx > 0.5) ? '수평' : '대각' - console.log(` [edge ${i}] ${_lineType} ${isOuterLine ? 'OUTER' : 'INNER'} (${Math.round(clippedLine.p1.x)},${Math.round(clippedLine.p1.y)})→(${Math.round(clippedLine.p2.x)},${Math.round(clippedLine.p2.y)}) dx:${Math.round(_dx)} dy:${Math.round(_dy)}`) + // const _dx = Math.abs(clippedLine.p2.x - clippedLine.p1.x) + // const _dy = Math.abs(clippedLine.p2.y - clippedLine.p1.y) + // const _lineType = (_dx < 0.5 && _dy > 0.5) ? '⚠️수직' : (_dy < 0.5 && _dx > 0.5) ? '수평' : '대각' + // console.log(` [edge ${i}] ${_lineType} ${isOuterLine ? 'OUTER' : 'INNER'} (${Math.round(clippedLine.p1.x)},${Math.round(clippedLine.p1.y)})→(${Math.round(clippedLine.p2.x)},${Math.round(clippedLine.p2.y)}) dx:${Math.round(_dx)} dy:${Math.round(_dy)}`) addRawLine(roof.id, skeletonLines, clippedLine.p1, clippedLine.p2, 'ridge', '#1083E3', 4, pitch, isOuterLine, targetWallId); // } @@ -1978,7 +2261,7 @@ function logDeadEndLines(skeletonLines, roof) { const dy = Math.abs(line.p2.y - line.p1.y); if (dx < 0.5 && dy < 0.5) { - console.log('⚠️ [logDeadEndLines] 제로 길이:', { idx, p1: { x: Math.round(line.p1.x), y: Math.round(line.p1.y) } }); + // console.log('⚠️ [logDeadEndLines] 제로 길이:', { idx, p1: { x: Math.round(line.p1.x), y: Math.round(line.p1.y) } }); return; } @@ -1997,7 +2280,7 @@ function logDeadEndLines(skeletonLines, roof) { } }); - console.log(`🔍 [logDeadEndLines] 전체: ${skeletonLines.length}, 유니크: ${uniqueLines.length}, dead end 꼭짓점: ${deadEndVertices.size}`); + // console.log(`🔍 [logDeadEndLines] 전체: ${skeletonLines.length}, 유니크: ${uniqueLines.length}, dead end 꼭짓점: ${deadEndVertices.size}`); } function findMatchingLine(edgePolygon, roof, roofPoints) { @@ -2106,6 +2389,59 @@ function processGableEdge(edgeResult, baseLines, skeletonLines, selectBaseLine, * @param {Array<{x,y}>} origPoints - roof.points (원본 폴리곤) * @returns {Array<{x,y}>} 보정된 스켈레톤 입력 포인트 (실패 시 원본 반환) */ +/** + * calcOverCorrectedPoints 의 안전 래퍼. + * + * 문제: + * 기존 calcOverCorrectedPoints 는 "원본(roof.points) 대비 이동"으로 moved 판정을 + * 하기 때문에, 누적 이동 시(1차 이동 후 2차 이동) 1차에서 이미 확정된 점까지도 + * moved=true 로 보고 clampToFixed 대상으로 잡아 원본으로 되돌려 버린다. + * + * 처리: + * 1) lastPoints 가 있으면 "이번 이동에서 실제로 변한 인덱스(changedNow)" 만 추림. + * 2) changedNow=false 인 인덱스는 "원본=lastPoints(직전 확정 상태)" 로 간주한 virtualOrig 구성. + * → 해당 인덱스는 기존 calcOverCorrectedPoints 내부에서 moved=false 로 판정되어 + * clampToFixed 대상에서 제외됨 (= 1차 이동 결과 보존). + * 3) changedNow=true 인 인덱스만 원본(origPoints) 대비로 오버 보정 그대로 수행. + * 4) lastPoints 가 없거나 길이가 안 맞으면 기존 함수를 그대로 호출 (동작 변화 없음). + * + * 기존 calcOverCorrectedPoints 는 수정하지 않는다. + * + * @param {Array<{x,y}>} points - changRoofLinePoints (수정 X) + * @param {Array<{x,y}>} origPoints - roof.points (원본) + * @param {Array<{x,y}>|null} lastPoints - 직전 SK 확정 상태 (canvas.skeleton.lastPoints) + * @returns {Array<{x,y}>} + */ +const calcOverCorrectedPointsSafe = (points, origPoints, lastPoints) => { + if (!Array.isArray(lastPoints) || !Array.isArray(origPoints)) { + return calcOverCorrectedPoints(points, origPoints) + } + if (lastPoints.length !== origPoints.length || points.length !== origPoints.length) { + return calcOverCorrectedPoints(points, origPoints) + } + + const tol = 1 + const changedNow = points.map((p, i) => { + const lp = lastPoints[i] + if (!lp) return true + return Math.abs(p.x - lp.x) > tol || Math.abs(p.y - lp.y) > tol + }) + + // 이번에 아무것도 안 바뀌었으면 그대로 반환 + if (!changedNow.some(Boolean)) return points + + // 이전 이동에서 확정된(이번엔 그대로) 점들은 lastPoints 를 원본으로 간주 + const virtualOrig = origPoints.map((op, i) => + changedNow[i] ? op : (lastPoints[i] ?? op) + ) + + console.log('[calcOverCorrectedPointsSafe] changedNow:', + changedNow.map((v, i) => v ? i : null).filter(v => v !== null)) + + return calcOverCorrectedPoints(points, virtualOrig) +} + + const calcOverCorrectedPoints = (points, origPoints) => { const n = points.length const skPoints = points.map(p => ({ x: p.x, y: p.y })) From 8493fb037b68656f75b9d9c410bcb3ed9c9c091a Mon Sep 17 00:00:00 2001 From: ysCha Date: Thu, 16 Apr 2026 13:18:39 +0900 Subject: [PATCH 11/38] =?UTF-8?q?[1806]=EC=84=9C=EA=B9=8C=EB=9E=98=20?= =?UTF-8?q?=EB=86=92=EC=9D=B4=20=EC=A1=B0=EC=A0=95=EC=97=90=20=EB=94=B0?= =?UTF-8?q?=EB=A5=B8=20=EC=A7=80=EB=B6=95=20=ED=98=95=EC=83=81=20=EB=B6=88?= =?UTF-8?q?=EC=9D=BC=EC=B9=98=20-=20=EB=9D=BC=EC=9D=B8=EC=98=A4=EB=B2=84?= =?UTF-8?q?=EC=B2=B4=ED=81=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/util/skeleton-utils.js | 382 +++---------------------------------- 1 file changed, 23 insertions(+), 359 deletions(-) diff --git a/src/util/skeleton-utils.js b/src/util/skeleton-utils.js index 56606bde..f713693b 100644 --- a/src/util/skeleton-utils.js +++ b/src/util/skeleton-utils.js @@ -364,264 +364,6 @@ 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 }) - } - - 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 - - const matchingLines = baseLines.filter((line) => - (isSamePoint(line.startPoint, selectLine.startPoint) && isSamePoint(line.endPoint, selectLine.endPoint)) || - (isSamePoint(line.startPoint, selectLine.endPoint) && isSamePoint(line.endPoint, selectLine.startPoint)) - ) - - matchingLines.forEach((line) => { - const s = line.startPoint - const e = line.endPoint - newPoints.forEach((point, index) => { - const bp = basePoints[index] - if (!bp) return - const matchS = isSamePoint(bp, s) - const matchE = isSamePoint(bp, e) - if (!matchS && !matchE) 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() - } - }) - }) - - 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)) return 'unknown' - - const moveFlowLine = roof.moveFlowLine ?? 0 - const moveUpDown = roof.moveUpDown ?? 0 - if (moveFlowLine === 0 && moveUpDown === 0) return 'ok' - - const proposed = buildRawMovedPoints(roofId, canvas) - const orig = roof.points - if (!Array.isArray(proposed) || proposed.length !== orig.length) return 'unknown' - - const n = orig.length - if (n < 3) return 'unknown' - - const posTol = 0.5 - let worst = 'ok' - - 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 - - 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]) - - // 원래 골짜기(>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' - } - } - } - - 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 @@ -780,17 +522,7 @@ export const skeletonBuilder = (roofId, canvas, textMode) => { // 6. 마루이동(용마루 위치 수동 조정)이 설정된 경우 movingLineFromSkeleton 결과로 대체 if (moveFlowLine !== 0 || moveUpDown !== 0) { - // [보호 가드] 이동이 골짜기 경계를 넘어가는지 사전 판정 - // 'crossed' → 외곽선을 뒤집을 만큼 과한 이동 → 기존 SK/innerLines/skeletonLines 전부 보존 후 조용히 종료 - // 'ok' | 'on-boundary' | 'unknown' → 기존 흐름 그대로 진행 - // ※ 기존 로직은 전혀 수정하지 않고, 진입부에 한 블록만 추가 - const __moveVerdict = verifyMoveBoundary(roofId, canvas) - if (__moveVerdict === 'crossed') { - console.warn('[skeleton] 경계 넘음(crossed) → 재빌드 스킵 (기존 SK/innerLines/skeletonLines 유지)') - return - } - - const movedPoints = safeMovedPointsWithFallback(roofId, canvas) + const movedPoints = movingLineFromSkeleton(roofId, canvas) // console.log("movedPoints:::", movedPoints); // movedPoints를 roof 경계로 확장 @@ -905,13 +637,7 @@ export const skeletonBuilder = (roofId, canvas, textMode) => { if (isOverDetected) { // [예외] 오버 감지 시 → 별도 함수에서 스켈레톤 전용 보정 포인트 계산 try { - // 누적 이동 보호: 이전 SK 확정 상태(lastPoints)를 전달하여 - // 1차 이동에서 이미 확정된 점은 보정 대상에서 제외한다. - const corrected = calcOverCorrectedPointsSafe( - changRoofLinePoints, - origPoints, - canvas?.skeleton?.lastPoints ?? null - ) + const corrected = calcOverCorrectedPoints(changRoofLinePoints, origPoints) if (corrected && corrected.length >= 3) { skeletonInputPoints = corrected console.log('[SK_OVER] 보정 포인트 적용:', skeletonInputPoints.map((p, i) => `[${i}](${Math.round(p.x)},${Math.round(p.y)})`)) @@ -961,15 +687,7 @@ export const skeletonBuilder = (roofId, canvas, textMode) => { } canvas.skeleton = [] canvas.skeleton = cleanSkeleton - // lastPoints 는 축소되지 않은 풀 길이(roof.points.length) 버전으로 저장해야 - // 다음 이동 시 인덱스 기반 매칭이 유지됨. raw 재구성 실패 시에만 기존 값 사용. - const rawMovedFull = buildRawMovedPoints(roofId, canvas) - if (rawMovedFull && rawMovedFull.length === (roof.points?.length ?? 0)) { - canvas.skeleton.lastPoints = rawMovedFull - console.log('[skeleton] lastPoints 저장: raw 풀 길이', rawMovedFull.length, '점') - } else { - canvas.skeleton.lastPoints = roofLineContactPoints - } + canvas.skeleton.lastPoints = roofLineContactPoints canvas.set('skeleton', cleanSkeleton) canvas.renderAll() @@ -978,12 +696,11 @@ export const skeletonBuilder = (roofId, canvas, textMode) => { console.error('[SK_ERROR] 스켈레톤 생성 중 오류 발생:', e) console.error('[SK_ERROR] 입력 좌표:', changRoofLinePoints.map((p, i) => `[${i}] (${p.x.toFixed(2)},${p.y.toFixed(2)})`)) console.error('[SK_ERROR] geoJSON:', JSON.stringify(geoJSONPolygon)) - // [보호] 예외 시 상태 플래그만 복구하고, 기존 SK/innerLines는 유지한다. - // → 사용자가 힘들게 추가한 라인들이 순간적으로 사라지는 현상 방지. if (canvas.skeletonStates) { canvas.skeletonStates[roofId] = false + canvas.skeletonStates = {} + canvas.skeletonLines = [] } - // 주의: canvas.skeletonLines 는 의도적으로 비우지 않음 (이전 성공 상태 보존) } } @@ -2149,17 +1866,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]; @@ -2169,7 +1886,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 } @@ -2178,10 +1895,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); // } @@ -2261,7 +1978,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; } @@ -2280,7 +1997,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) { @@ -2389,59 +2106,6 @@ function processGableEdge(edgeResult, baseLines, skeletonLines, selectBaseLine, * @param {Array<{x,y}>} origPoints - roof.points (원본 폴리곤) * @returns {Array<{x,y}>} 보정된 스켈레톤 입력 포인트 (실패 시 원본 반환) */ -/** - * calcOverCorrectedPoints 의 안전 래퍼. - * - * 문제: - * 기존 calcOverCorrectedPoints 는 "원본(roof.points) 대비 이동"으로 moved 판정을 - * 하기 때문에, 누적 이동 시(1차 이동 후 2차 이동) 1차에서 이미 확정된 점까지도 - * moved=true 로 보고 clampToFixed 대상으로 잡아 원본으로 되돌려 버린다. - * - * 처리: - * 1) lastPoints 가 있으면 "이번 이동에서 실제로 변한 인덱스(changedNow)" 만 추림. - * 2) changedNow=false 인 인덱스는 "원본=lastPoints(직전 확정 상태)" 로 간주한 virtualOrig 구성. - * → 해당 인덱스는 기존 calcOverCorrectedPoints 내부에서 moved=false 로 판정되어 - * clampToFixed 대상에서 제외됨 (= 1차 이동 결과 보존). - * 3) changedNow=true 인 인덱스만 원본(origPoints) 대비로 오버 보정 그대로 수행. - * 4) lastPoints 가 없거나 길이가 안 맞으면 기존 함수를 그대로 호출 (동작 변화 없음). - * - * 기존 calcOverCorrectedPoints 는 수정하지 않는다. - * - * @param {Array<{x,y}>} points - changRoofLinePoints (수정 X) - * @param {Array<{x,y}>} origPoints - roof.points (원본) - * @param {Array<{x,y}>|null} lastPoints - 직전 SK 확정 상태 (canvas.skeleton.lastPoints) - * @returns {Array<{x,y}>} - */ -const calcOverCorrectedPointsSafe = (points, origPoints, lastPoints) => { - if (!Array.isArray(lastPoints) || !Array.isArray(origPoints)) { - return calcOverCorrectedPoints(points, origPoints) - } - if (lastPoints.length !== origPoints.length || points.length !== origPoints.length) { - return calcOverCorrectedPoints(points, origPoints) - } - - const tol = 1 - const changedNow = points.map((p, i) => { - const lp = lastPoints[i] - if (!lp) return true - return Math.abs(p.x - lp.x) > tol || Math.abs(p.y - lp.y) > tol - }) - - // 이번에 아무것도 안 바뀌었으면 그대로 반환 - if (!changedNow.some(Boolean)) return points - - // 이전 이동에서 확정된(이번엔 그대로) 점들은 lastPoints 를 원본으로 간주 - const virtualOrig = origPoints.map((op, i) => - changedNow[i] ? op : (lastPoints[i] ?? op) - ) - - console.log('[calcOverCorrectedPointsSafe] changedNow:', - changedNow.map((v, i) => v ? i : null).filter(v => v !== null)) - - return calcOverCorrectedPoints(points, virtualOrig) -} - - const calcOverCorrectedPoints = (points, origPoints) => { const n = points.length const skPoints = points.map(p => ({ x: p.x, y: p.y })) From eaf6bcb3d317c1ecc4795651e37229baf4a04eb8 Mon Sep 17 00:00:00 2001 From: ysCha Date: Tue, 21 Apr 2026 16:10:15 +0900 Subject: [PATCH 12/38] =?UTF-8?q?trestles[]=EC=97=90=20subModuleTpCd=20?= =?UTF-8?q?=EC=B6=94=EA=B0=80=20(Cross=20Mix=20=EC=8B=9D=EB=B3=84=EC=9A=A9?= =?UTF-8?q?,=20=EB=8B=A8=EC=9D=BC/=EB=8F=99=EA=B3=84=EC=97=B4=EC=9D=80=20?= =?UTF-8?q?=EB=B9=88=20=EA=B0=92)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/hooks/module/useTrestle.js | 8 ++++++++ 1 file changed, 8 insertions(+) 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, From 558769063b1237bd2131bf0f6dae960121552812 Mon Sep 17 00:00:00 2001 From: ysCha Date: Tue, 21 Apr 2026 17:51:47 +0900 Subject: [PATCH 13/38] =?UTF-8?q?PassivityCircuitAllocation=EC=97=90=20?= =?UTF-8?q?=EC=A0=84=EB=8B=AC=ED=95=98=EB=8D=98=20=EB=AF=B8=EC=82=AC?= =?UTF-8?q?=EC=9A=A9=20ref=20prop=20=EC=A0=9C=EA=B1=B0=20passivityCircuitA?= =?UTF-8?q?llocationRef=20(useRef)=20=EC=84=A0=EC=96=B8=20=EC=A0=9C?= =?UTF-8?q?=EA=B1=B0=20=EC=9B=90=EC=9D=B8:=20function=20component=EC=97=90?= =?UTF-8?q?=20ref=20=EC=A0=84=EB=8B=AC=20=EC=8B=9C=20React=20warning=20?= =?UTF-8?q?=EB=B0=9C=EC=83=9D=20(forwardRef=20=EB=AF=B8=EC=82=AC=EC=9A=A9)?= =?UTF-8?q?=20PCS=20=EC=A0=91=EC=86=8D=ED=95=A8=20=EC=9D=91=EB=8B=B5?= =?UTF-8?q?=EC=97=90=20=EC=B6=94=EA=B0=80=EB=90=9C=20moduleTpCd=20?= =?UTF-8?q?=ED=95=84=EB=93=9C=EB=A5=BC=20Cross=20Mix=20=EC=8B=9D=EB=B3=84?= =?UTF-8?q?=EC=9D=84=20=EC=9C=84=ED=95=B4=20=ED=94=84=EB=A1=A0=ED=8A=B8=20?= =?UTF-8?q?=EC=83=81=ED=83=9C=20=EB=B3=B4=EC=A1=B4=20=EB=B0=8F=20getQuotat?= =?UTF-8?q?ionItem=20=EC=A0=9C=EC=B6=9C=20=ED=8C=8C=EB=9D=BC=EB=AF=B8?= =?UTF-8?q?=ED=84=B0=EA=B9=8C=EC=A7=80=20=ED=9D=98=EB=A0=A4=EB=B3=B4?= =?UTF-8?q?=EB=82=B4=EA=B3=A0,=20dedupe=20=ED=82=A4=EB=A5=BC=20(itemId,=20?= =?UTF-8?q?moduleTpCd)=20=EC=A1=B0=ED=95=A9=EC=9C=BC=EB=A1=9C=20=EB=B3=80?= =?UTF-8?q?=EA=B2=BD.?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../modal/circuitTrestle/CircuitTrestleSetting.jsx | 14 ++++++++++---- .../modal/circuitTrestle/step/StepUp.jsx | 6 ++++-- src/hooks/common/useMasterController.js | 10 ++++++++++ 3 files changed, 24 insertions(+), 6 deletions(-) diff --git a/src/components/floor-plan/modal/circuitTrestle/CircuitTrestleSetting.jsx b/src/components/floor-plan/modal/circuitTrestle/CircuitTrestleSetting.jsx index 011cfe54..24c5a44d 100644 --- a/src/components/floor-plan/modal/circuitTrestle/CircuitTrestleSetting.jsx +++ b/src/components/floor-plan/modal/circuitTrestle/CircuitTrestleSetting.jsx @@ -58,7 +58,6 @@ export default function CircuitTrestleSetting({ id }) { const { setModuleStatisticsData } = useCircuitTrestle() const { handleCanvasToPng } = useImgLoader() const moduleSelectionData = useRecoilValue(moduleSelectionDataState) - const passivityCircuitAllocationRef = useRef() const { setIsGlobalLoading } = useContext(QcastContext) const originCanvasViewPortTransform = useRef([]) @@ -844,9 +843,16 @@ export default function CircuitTrestleSetting({ id }) { pcsItemId: item.itemId, pscOptCd: getPcsOptCd(index), paralQty: serQty.paralQty, - // 접속함 중복 체크: 동일 itemId는 1개만, 다르면 각각 올림 + // 접속함 중복 체크: (itemId, moduleTpCd) 조합 기준으로 dedupe (Cross Mix 구분) connections: item.connList?.length - ? [...new Map(item.connList.map((conn) => [conn.itemId, { connItemId: conn.itemId, connMaxParalCnt: conn.connMaxParalCnt }])).values()] + ? [ + ...new Map( + item.connList.map((conn) => [ + `${conn.itemId}_${conn.moduleTpCd}`, + { connItemId: conn.itemId, connMaxParalCnt: conn.connMaxParalCnt, moduleTpCd: conn.moduleTpCd }, + ]), + ).values(), + ] : [], }) // return { @@ -1041,7 +1047,7 @@ export default function CircuitTrestleSetting({ id }) { {tabNum === 1 && allocationType === ALLOCATION_TYPE.AUTO && } {tabNum === 1 && allocationType === ALLOCATION_TYPE.PASSIVITY && ( - + )} {tabNum === 2 && } diff --git a/src/components/floor-plan/modal/circuitTrestle/step/StepUp.jsx b/src/components/floor-plan/modal/circuitTrestle/step/StepUp.jsx index da6ea213..60f44db3 100644 --- a/src/components/floor-plan/modal/circuitTrestle/step/StepUp.jsx +++ b/src/components/floor-plan/modal/circuitTrestle/step/StepUp.jsx @@ -345,6 +345,7 @@ export default function StepUp(props) { itemId: conn.itemId ?? '', itemNm: conn.itemNm ?? '', vstuParalCnt: conn.vstuParalCnt ?? 0, + moduleTpCd: conn.moduleTpCd, })) } @@ -585,15 +586,16 @@ export default function StepUp(props) { pcsItemId: serQty.itemId, pcsOptCd: seletedOption, paralQty: serQty.paralQty, - // 접속함 중복 체크: 동일 itemId는 1개만, 다르면 각각 올림 + // 접속함 중복 체크: (itemId, moduleTpCd) 조합 기준으로 dedupe connections: item.connList?.length ? [ ...new Map( item.connList.map((conn) => [ - conn.itemId, + `${conn.itemId}_${conn.moduleTpCd}`, { connItemId: conn.itemId, connMaxParalCnt: conn.connMaxParalCnt, + moduleTpCd: conn.moduleTpCd, }, ]), ).values(), diff --git a/src/hooks/common/useMasterController.js b/src/hooks/common/useMasterController.js index 36f5bd61..971b42ce 100644 --- a/src/hooks/common/useMasterController.js +++ b/src/hooks/common/useMasterController.js @@ -264,6 +264,8 @@ export function useMasterController() { */ const getPcsAutoRecommendList = async (params = null) => { return await post({ url: '/api/v1/master/getPcsAutoRecommendList', data: params }).then((res) => { + console.log('[moduleTpCd] getPcsAutoRecommendList req:', params) + console.log('[moduleTpCd] getPcsAutoRecommendList res pcsItemList:', res?.data?.pcsItemList?.map((p) => ({ itemId: p.itemId, pcsTpCd: p.pcsTpCd, connList: p.connList?.map((c) => ({ itemId: c.itemId, moduleTpCd: c.moduleTpCd })) }))) return res }) } @@ -286,6 +288,8 @@ export function useMasterController() { */ const getPcsVoltageChk = async (params = null) => { return await post({ url: '/api/v1/master/getPcsVoltageChk', data: params }).then((res) => { + console.log('[moduleTpCd] getPcsVoltageChk req:', params) + console.log('[moduleTpCd] getPcsVoltageChk res pcsItemList:', res?.data?.pcsItemList?.map((p) => ({ itemId: p.itemId, pcsTpCd: p.pcsTpCd, connList: p.connList?.map((c) => ({ itemId: c.itemId, moduleTpCd: c.moduleTpCd })) }))) return res }) } @@ -322,11 +326,15 @@ export function useMasterController() { } return await post({ url: '/api/v1/master/getPcsVoltageStepUpList', data: params }).then((res) => { + console.log('[moduleTpCd] getPcsVoltageStepUpList req:', params) + console.log('[moduleTpCd] getPcsVoltageStepUpList res pcsItemList:', res?.data?.pcsItemList?.map((p) => ({ itemId: p.itemId, pcsTpCd: p.pcsTpCd, connList: p.connList?.map((c) => ({ itemId: c.itemId, moduleTpCd: c.moduleTpCd })) }))) return res }) } const getQuotationItem = async (params) => { + console.log('[moduleTpCd] getQuotationItem req pcses.connections:', params?.pcses?.map((p) => ({ pcsItemId: p.pcsItemId, connections: p.connections }))) + console.log('[moduleTpCd] getQuotationItem full req:', params) return await post({ url: '/api/v1/master/getQuotationItem', data: params }) .then((res) => { return res @@ -355,6 +363,8 @@ export function useMasterController() { */ const getPcsConnOptionItemList = async (params = null) => { return await post({ url: '/api/v1/master/getPcsConnOptionItemList', data: params }).then((res) => { + console.log('[moduleTpCd] getPcsConnOptionItemList req:', params) + console.log('[moduleTpCd] getPcsConnOptionItemList res pcsItemList:', res?.data?.pcsItemList?.map((p) => ({ itemId: p.itemId, pcsTpCd: p.pcsTpCd, connList: p.connList?.map((c) => ({ itemId: c.itemId, moduleTpCd: c.moduleTpCd })) }))) return res }) } From a3e608f059cde6fe78e9f1b6a5b4a9c19aa7c064 Mon Sep 17 00:00:00 2001 From: ysCha Date: Wed, 22 Apr 2026 13:35:47 +0900 Subject: [PATCH 14/38] =?UTF-8?q?Cross=20Mix(DC3)=20=EC=A0=91=EC=86=8D?= =?UTF-8?q?=ED=95=A8=EC=9D=84=20PCS=20=EB=8B=B4=EB=8B=B9=20=EB=AA=A8?= =?UTF-8?q?=EB=93=88=20=ED=83=80=EC=9E=85=EC=9C=BC=EB=A1=9C=20=ED=95=84?= =?UTF-8?q?=ED=84=B0=EB=A7=81=ED=95=B4=201=EA=B1=B4=EB=A7=8C=20=EB=82=B4?= =?UTF-8?q?=EB=A0=A4=EA=B0=80=EB=8F=84=EB=A1=9D=20=ED=86=B5=ED=95=A9=20?= =?UTF-8?q?=EB=A1=9C=EC=A7=81=20=EC=A0=81=EC=9A=A9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../circuitTrestle/CircuitTrestleSetting.jsx | 27 +++++++++++--- .../modal/circuitTrestle/step/StepUp.jsx | 37 ++++++++++++++----- 2 files changed, 49 insertions(+), 15 deletions(-) diff --git a/src/components/floor-plan/modal/circuitTrestle/CircuitTrestleSetting.jsx b/src/components/floor-plan/modal/circuitTrestle/CircuitTrestleSetting.jsx index 24c5a44d..aa52d6f5 100644 --- a/src/components/floor-plan/modal/circuitTrestle/CircuitTrestleSetting.jsx +++ b/src/components/floor-plan/modal/circuitTrestle/CircuitTrestleSetting.jsx @@ -831,9 +831,23 @@ export default function CircuitTrestleSetting({ id }) { // 승압설정 목록 조회 const getStepUpListData = () => { + // PCS 인스턴스별 실제 담당 모듈 타입 집합 수집 (Cross Mix DC3 접속함 분리용) + const pcsModuleTpCds = {} + canvas + .getObjects() + .filter((obj) => obj.name === POLYGON_TYPE.MODULE && obj.circuit && obj.pcsItemId) + .forEach((module) => { + const pcsInstanceId = module.circuit?.circuitInfo?.id + const tpCd = module.moduleInfo?.moduleTpCd + if (!pcsInstanceId || !tpCd) return + if (!pcsModuleTpCds[pcsInstanceId]) pcsModuleTpCds[pcsInstanceId] = new Set() + pcsModuleTpCds[pcsInstanceId].add(tpCd) + }) + const pcs = [] console.log(stepUpListData) stepUpListData[0].pcsItemList.map((item, index) => { + const targetTpCds = pcsModuleTpCds[selectedModels[index]?.id] return item.serQtyList .filter((serQty) => serQty.selected && serQty.paralQty > 0) .forEach((serQty) => { @@ -843,14 +857,17 @@ export default function CircuitTrestleSetting({ id }) { pcsItemId: item.itemId, pscOptCd: getPcsOptCd(index), paralQty: serQty.paralQty, - // 접속함 중복 체크: (itemId, moduleTpCd) 조합 기준으로 dedupe (Cross Mix 구분) + // 접속함: PCS가 실제 담당하는 모듈 타입으로 필터 후 itemId 기준 dedupe + // (Cross Mix 시 다른 모듈 계열의 conn 제외 / 단일·동계열 Mix는 기존과 동일 결과) connections: item.connList?.length ? [ ...new Map( - item.connList.map((conn) => [ - `${conn.itemId}_${conn.moduleTpCd}`, - { connItemId: conn.itemId, connMaxParalCnt: conn.connMaxParalCnt, moduleTpCd: conn.moduleTpCd }, - ]), + 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/circuitTrestle/step/StepUp.jsx b/src/components/floor-plan/modal/circuitTrestle/step/StepUp.jsx index 60f44db3..58a8e190 100644 --- a/src/components/floor-plan/modal/circuitTrestle/step/StepUp.jsx +++ b/src/components/floor-plan/modal/circuitTrestle/step/StepUp.jsx @@ -577,7 +577,21 @@ export default function StepUp(props) { * 현재 선택된 값들을 가져오는 함수 추가 */ const getCurrentSelections = () => { - const selectedValues = stepUpListData[0].pcsItemList.forEach((item) => { + // PCS 인스턴스별 실제 담당 모듈 타입 집합 수집 (Cross Mix DC3 접속함 분리용) + const pcsModuleTpCds = {} + canvas + .getObjects() + .filter((obj) => obj.name === POLYGON_TYPE.MODULE && obj.circuit && obj.pcsItemId) + .forEach((module) => { + const pcsInstanceId = module.circuit?.circuitInfo?.id + const tpCd = module.moduleInfo?.moduleTpCd + if (!pcsInstanceId || !tpCd) return + if (!pcsModuleTpCds[pcsInstanceId]) pcsModuleTpCds[pcsInstanceId] = new Set() + pcsModuleTpCds[pcsInstanceId].add(tpCd) + }) + + const selectedValues = stepUpListData[0].pcsItemList.forEach((item, index) => { + const targetTpCds = pcsModuleTpCds[selectedModels[index]?.id] item.serQtyList.filter((serQty) => serQty.selected) return item.serQtyList.map((serQty) => { return { @@ -586,18 +600,21 @@ export default function StepUp(props) { pcsItemId: serQty.itemId, pcsOptCd: seletedOption, paralQty: serQty.paralQty, - // 접속함 중복 체크: (itemId, moduleTpCd) 조합 기준으로 dedupe + // 접속함: PCS가 실제 담당하는 모듈 타입으로 필터 후 itemId 기준 dedupe + // (Cross Mix 시 다른 모듈 계열의 conn 제외 / 단일·동계열 Mix는 기존과 동일 결과) connections: item.connList?.length ? [ ...new Map( - item.connList.map((conn) => [ - `${conn.itemId}_${conn.moduleTpCd}`, - { - connItemId: conn.itemId, - connMaxParalCnt: conn.connMaxParalCnt, - moduleTpCd: conn.moduleTpCd, - }, - ]), + 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(), ] : [], From 3d142cd95c9e9597d87ee360268eed4f9b3e4105 Mon Sep 17 00:00:00 2001 From: ysCha Date: Wed, 22 Apr 2026 16:39:28 +0900 Subject: [PATCH 15/38] =?UTF-8?q?sk=EB=9D=BC=EC=9D=B8=20=EA=B8=B0=EB=A1=9D?= =?UTF-8?q?=20=EB=B3=B4=EC=A1=B4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/util/skeleton-utils.js | 382 ++++++++++++++++++++++++++++++++++--- 1 file changed, 359 insertions(+), 23 deletions(-) diff --git a/src/util/skeleton-utils.js b/src/util/skeleton-utils.js index f713693b..56606bde 100644 --- a/src/util/skeleton-utils.js +++ b/src/util/skeleton-utils.js @@ -364,6 +364,264 @@ 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 }) + } + + 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 + + const matchingLines = baseLines.filter((line) => + (isSamePoint(line.startPoint, selectLine.startPoint) && isSamePoint(line.endPoint, selectLine.endPoint)) || + (isSamePoint(line.startPoint, selectLine.endPoint) && isSamePoint(line.endPoint, selectLine.startPoint)) + ) + + matchingLines.forEach((line) => { + const s = line.startPoint + const e = line.endPoint + newPoints.forEach((point, index) => { + const bp = basePoints[index] + if (!bp) return + const matchS = isSamePoint(bp, s) + const matchE = isSamePoint(bp, e) + if (!matchS && !matchE) 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() + } + }) + }) + + 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)) return 'unknown' + + const moveFlowLine = roof.moveFlowLine ?? 0 + const moveUpDown = roof.moveUpDown ?? 0 + if (moveFlowLine === 0 && moveUpDown === 0) return 'ok' + + const proposed = buildRawMovedPoints(roofId, canvas) + const orig = roof.points + if (!Array.isArray(proposed) || proposed.length !== orig.length) return 'unknown' + + const n = orig.length + if (n < 3) return 'unknown' + + const posTol = 0.5 + let worst = 'ok' + + 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 + + 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]) + + // 원래 골짜기(>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' + } + } + } + + 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,7 +780,17 @@ export const skeletonBuilder = (roofId, canvas, textMode) => { // 6. 마루이동(용마루 위치 수동 조정)이 설정된 경우 movingLineFromSkeleton 결과로 대체 if (moveFlowLine !== 0 || moveUpDown !== 0) { - const movedPoints = movingLineFromSkeleton(roofId, canvas) + // [보호 가드] 이동이 골짜기 경계를 넘어가는지 사전 판정 + // 'crossed' → 외곽선을 뒤집을 만큼 과한 이동 → 기존 SK/innerLines/skeletonLines 전부 보존 후 조용히 종료 + // 'ok' | 'on-boundary' | 'unknown' → 기존 흐름 그대로 진행 + // ※ 기존 로직은 전혀 수정하지 않고, 진입부에 한 블록만 추가 + const __moveVerdict = verifyMoveBoundary(roofId, canvas) + if (__moveVerdict === 'crossed') { + console.warn('[skeleton] 경계 넘음(crossed) → 재빌드 스킵 (기존 SK/innerLines/skeletonLines 유지)') + return + } + + const movedPoints = safeMovedPointsWithFallback(roofId, canvas) // console.log("movedPoints:::", movedPoints); // movedPoints를 roof 경계로 확장 @@ -637,7 +905,13 @@ export const skeletonBuilder = (roofId, canvas, textMode) => { if (isOverDetected) { // [예외] 오버 감지 시 → 별도 함수에서 스켈레톤 전용 보정 포인트 계산 try { - const corrected = calcOverCorrectedPoints(changRoofLinePoints, origPoints) + // 누적 이동 보호: 이전 SK 확정 상태(lastPoints)를 전달하여 + // 1차 이동에서 이미 확정된 점은 보정 대상에서 제외한다. + const corrected = calcOverCorrectedPointsSafe( + changRoofLinePoints, + origPoints, + canvas?.skeleton?.lastPoints ?? null + ) if (corrected && corrected.length >= 3) { skeletonInputPoints = corrected console.log('[SK_OVER] 보정 포인트 적용:', skeletonInputPoints.map((p, i) => `[${i}](${Math.round(p.x)},${Math.round(p.y)})`)) @@ -687,7 +961,15 @@ export const skeletonBuilder = (roofId, canvas, textMode) => { } canvas.skeleton = [] canvas.skeleton = cleanSkeleton - canvas.skeleton.lastPoints = roofLineContactPoints + // lastPoints 는 축소되지 않은 풀 길이(roof.points.length) 버전으로 저장해야 + // 다음 이동 시 인덱스 기반 매칭이 유지됨. raw 재구성 실패 시에만 기존 값 사용. + const rawMovedFull = buildRawMovedPoints(roofId, canvas) + if (rawMovedFull && rawMovedFull.length === (roof.points?.length ?? 0)) { + canvas.skeleton.lastPoints = rawMovedFull + console.log('[skeleton] lastPoints 저장: raw 풀 길이', rawMovedFull.length, '점') + } else { + canvas.skeleton.lastPoints = roofLineContactPoints + } canvas.set('skeleton', cleanSkeleton) canvas.renderAll() @@ -696,11 +978,12 @@ export const skeletonBuilder = (roofId, canvas, textMode) => { console.error('[SK_ERROR] 스켈레톤 생성 중 오류 발생:', e) console.error('[SK_ERROR] 입력 좌표:', changRoofLinePoints.map((p, i) => `[${i}] (${p.x.toFixed(2)},${p.y.toFixed(2)})`)) console.error('[SK_ERROR] geoJSON:', JSON.stringify(geoJSONPolygon)) + // [보호] 예외 시 상태 플래그만 복구하고, 기존 SK/innerLines는 유지한다. + // → 사용자가 힘들게 추가한 라인들이 순간적으로 사라지는 현상 방지. if (canvas.skeletonStates) { canvas.skeletonStates[roofId] = false - canvas.skeletonStates = {} - canvas.skeletonLines = [] } + // 주의: canvas.skeletonLines 는 의도적으로 비우지 않음 (이전 성공 상태 보존) } } @@ -1866,17 +2149,17 @@ function processEavesEdge(roofId, canvas, skeleton, edgeResult, skeletonLines) { return false } - console.log('📐 [processEavesEdge] face 분석:', { - hasOuterLine: !!outerLine, - outerLineType: outerLine?.attributes?.type, - pitch, - roofLineIndex, - edgeBegin: { x: Math.round(Begin.X), y: Math.round(Begin.Y) }, - edgeEnd: { x: Math.round(End.X), y: Math.round(End.Y) }, - polygonPointCount: polygonPoints.length, - polygonPoints: polygonPoints.map(p => ({ x: Math.round(p.x), y: Math.round(p.y) })), - skPtsCount: skPts.length, - }) + // console.log('📐 [processEavesEdge] face 분석:', { + // hasOuterLine: !!outerLine, + // outerLineType: outerLine?.attributes?.type, + // pitch, + // roofLineIndex, + // edgeBegin: { x: Math.round(Begin.X), y: Math.round(Begin.Y) }, + // edgeEnd: { x: Math.round(End.X), y: Math.round(End.Y) }, + // polygonPointCount: polygonPoints.length, + // polygonPoints: polygonPoints.map(p => ({ x: Math.round(p.x), y: Math.round(p.y) })), + // skPtsCount: skPts.length, + // }) for (let i = 0; i < polygonPoints.length; i++) { const p1 = polygonPoints[i]; @@ -1886,7 +2169,7 @@ function processEavesEdge(roofId, canvas, skeleton, edgeResult, skeletonLines) { // 확장된 외곽선에 해당하는 edge는 스킵 if (_isSkipOuter) { - console.log(' [edge', i, '] SKIP(outerEdge)', { p1: {x: Math.round(p1.x), y: Math.round(p1.y)}, p2: {x: Math.round(p2.x), y: Math.round(p2.y)} }) + // console.log(' [edge', i, '] SKIP(outerEdge)', { p1: {x: Math.round(p1.x), y: Math.round(p1.y)}, p2: {x: Math.round(p2.x), y: Math.round(p2.y)} }) continue } @@ -1895,10 +2178,10 @@ function processEavesEdge(roofId, canvas, skeleton, edgeResult, skeletonLines) { //console.log('clipped line', clippedLine.p1, clippedLine.p2); const isOuterLine = isOuterEdge(clippedLine.p1, clippedLine.p2, [edgeResult.Edge]) - const _dx = Math.abs(clippedLine.p2.x - clippedLine.p1.x) - const _dy = Math.abs(clippedLine.p2.y - clippedLine.p1.y) - const _lineType = (_dx < 0.5 && _dy > 0.5) ? '⚠️수직' : (_dy < 0.5 && _dx > 0.5) ? '수평' : '대각' - console.log(` [edge ${i}] ${_lineType} ${isOuterLine ? 'OUTER' : 'INNER'} (${Math.round(clippedLine.p1.x)},${Math.round(clippedLine.p1.y)})→(${Math.round(clippedLine.p2.x)},${Math.round(clippedLine.p2.y)}) dx:${Math.round(_dx)} dy:${Math.round(_dy)}`) + // const _dx = Math.abs(clippedLine.p2.x - clippedLine.p1.x) + // const _dy = Math.abs(clippedLine.p2.y - clippedLine.p1.y) + // const _lineType = (_dx < 0.5 && _dy > 0.5) ? '⚠️수직' : (_dy < 0.5 && _dx > 0.5) ? '수평' : '대각' + // console.log(` [edge ${i}] ${_lineType} ${isOuterLine ? 'OUTER' : 'INNER'} (${Math.round(clippedLine.p1.x)},${Math.round(clippedLine.p1.y)})→(${Math.round(clippedLine.p2.x)},${Math.round(clippedLine.p2.y)}) dx:${Math.round(_dx)} dy:${Math.round(_dy)}`) addRawLine(roof.id, skeletonLines, clippedLine.p1, clippedLine.p2, 'ridge', '#1083E3', 4, pitch, isOuterLine, targetWallId); // } @@ -1978,7 +2261,7 @@ function logDeadEndLines(skeletonLines, roof) { const dy = Math.abs(line.p2.y - line.p1.y); if (dx < 0.5 && dy < 0.5) { - console.log('⚠️ [logDeadEndLines] 제로 길이:', { idx, p1: { x: Math.round(line.p1.x), y: Math.round(line.p1.y) } }); + // console.log('⚠️ [logDeadEndLines] 제로 길이:', { idx, p1: { x: Math.round(line.p1.x), y: Math.round(line.p1.y) } }); return; } @@ -1997,7 +2280,7 @@ function logDeadEndLines(skeletonLines, roof) { } }); - console.log(`🔍 [logDeadEndLines] 전체: ${skeletonLines.length}, 유니크: ${uniqueLines.length}, dead end 꼭짓점: ${deadEndVertices.size}`); + // console.log(`🔍 [logDeadEndLines] 전체: ${skeletonLines.length}, 유니크: ${uniqueLines.length}, dead end 꼭짓점: ${deadEndVertices.size}`); } function findMatchingLine(edgePolygon, roof, roofPoints) { @@ -2106,6 +2389,59 @@ function processGableEdge(edgeResult, baseLines, skeletonLines, selectBaseLine, * @param {Array<{x,y}>} origPoints - roof.points (원본 폴리곤) * @returns {Array<{x,y}>} 보정된 스켈레톤 입력 포인트 (실패 시 원본 반환) */ +/** + * calcOverCorrectedPoints 의 안전 래퍼. + * + * 문제: + * 기존 calcOverCorrectedPoints 는 "원본(roof.points) 대비 이동"으로 moved 판정을 + * 하기 때문에, 누적 이동 시(1차 이동 후 2차 이동) 1차에서 이미 확정된 점까지도 + * moved=true 로 보고 clampToFixed 대상으로 잡아 원본으로 되돌려 버린다. + * + * 처리: + * 1) lastPoints 가 있으면 "이번 이동에서 실제로 변한 인덱스(changedNow)" 만 추림. + * 2) changedNow=false 인 인덱스는 "원본=lastPoints(직전 확정 상태)" 로 간주한 virtualOrig 구성. + * → 해당 인덱스는 기존 calcOverCorrectedPoints 내부에서 moved=false 로 판정되어 + * clampToFixed 대상에서 제외됨 (= 1차 이동 결과 보존). + * 3) changedNow=true 인 인덱스만 원본(origPoints) 대비로 오버 보정 그대로 수행. + * 4) lastPoints 가 없거나 길이가 안 맞으면 기존 함수를 그대로 호출 (동작 변화 없음). + * + * 기존 calcOverCorrectedPoints 는 수정하지 않는다. + * + * @param {Array<{x,y}>} points - changRoofLinePoints (수정 X) + * @param {Array<{x,y}>} origPoints - roof.points (원본) + * @param {Array<{x,y}>|null} lastPoints - 직전 SK 확정 상태 (canvas.skeleton.lastPoints) + * @returns {Array<{x,y}>} + */ +const calcOverCorrectedPointsSafe = (points, origPoints, lastPoints) => { + if (!Array.isArray(lastPoints) || !Array.isArray(origPoints)) { + return calcOverCorrectedPoints(points, origPoints) + } + if (lastPoints.length !== origPoints.length || points.length !== origPoints.length) { + return calcOverCorrectedPoints(points, origPoints) + } + + const tol = 1 + const changedNow = points.map((p, i) => { + const lp = lastPoints[i] + if (!lp) return true + return Math.abs(p.x - lp.x) > tol || Math.abs(p.y - lp.y) > tol + }) + + // 이번에 아무것도 안 바뀌었으면 그대로 반환 + if (!changedNow.some(Boolean)) return points + + // 이전 이동에서 확정된(이번엔 그대로) 점들은 lastPoints 를 원본으로 간주 + const virtualOrig = origPoints.map((op, i) => + changedNow[i] ? op : (lastPoints[i] ?? op) + ) + + console.log('[calcOverCorrectedPointsSafe] changedNow:', + changedNow.map((v, i) => v ? i : null).filter(v => v !== null)) + + return calcOverCorrectedPoints(points, virtualOrig) +} + + const calcOverCorrectedPoints = (points, origPoints) => { const n = points.length const skPoints = points.map(p => ({ x: p.x, y: p.y })) From b8db9355181afb46b86d7b45f670c2a747dfefac Mon Sep 17 00:00:00 2001 From: ysCha Date: Thu, 23 Apr 2026 12:24:21 +0900 Subject: [PATCH 16/38] =?UTF-8?q?=EB=B3=B4=EC=A1=B0=EB=9D=BC=EC=9D=B8=20?= =?UTF-8?q?=EB=A7=9E=EC=B6=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/util/skeleton-utils.js | 245 +++++++++++++++++++++++++++++-------- 1 file changed, 194 insertions(+), 51 deletions(-) diff --git a/src/util/skeleton-utils.js b/src/util/skeleton-utils.js index 56606bde..a882f630 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) @@ -419,6 +426,11 @@ const buildRawMovedPoints = (roofId, canvas) => { 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 @@ -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 } catch (e) { console.warn('[buildRawMovedPoints] 실패 → null fallback:', e) @@ -495,26 +515,44 @@ const buildRawMovedPoints = (roofId, canvas) => { export const verifyMoveBoundary = (roofId, canvas) => { try { 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 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 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 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 @@ -522,6 +560,10 @@ export const verifyMoveBoundary = (roofId, canvas) => { 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) @@ -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 } catch (e) { console.warn('[verifyMoveBoundary] 판정 실패 → unknown:', e) @@ -795,31 +841,34 @@ export const skeletonBuilder = (roofId, canvas, textMode) => { // 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 @@ -959,13 +1008,26 @@ 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 + 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 @@ -1226,9 +1288,36 @@ const createInnerLinesFromSkeleton = (roofId, canvas, skeleton, textMode, isOver // 각 라인 집합 정렬 const sortWallLines = ensureCounterClockwiseLines(wallLines) - const sortWallBaseLines = ensureCounterClockwiseLines(wall.baseLines) + // wall.baseLines를 독립적으로 CCW 정렬하면 ㅗ→붕괴 등으로 꼭짓점 집합이 달라질 때 + // 시작점(min-Y-then-min-X)이 wallLines와 어긋나 배열이 shift된다. + // wallLines[i]와 wall.baseLines[i]는 초기 생성 시부터 raw-index 1:1 페어링이 유지되므로, + // sortWallLines의 CCW 순서에 대응하는 원 인덱스를 복원하여 같은 순서로 wall.baseLines를 매핑한다. + const _keyOfEdge = (l) => { + const a = `${Math.round(l.x1 * 10) / 10},${Math.round(l.y1 * 10) / 10}` + const b = `${Math.round(l.x2 * 10) / 10},${Math.round(l.y2 * 10) / 10}` + return a < b ? `${a}|${b}` : `${b}|${a}` + } + const _rawIdxByKey = new Map() + wallLines.forEach((l, i) => _rawIdxByKey.set(_keyOfEdge(l), i)) + const sortWallBaseLines = sortWallLines.map((sl) => { + const origIdx = _rawIdxByKey.get(_keyOfEdge(sl)) + return origIdx != null && wall.baseLines[origIdx] ? wall.baseLines[origIdx] : sl + }) const sortRoofLines = ensureCounterClockwiseLines(roofLines) + // [MOVE-TRACE] 진입 스냅샷 (필요 시 주석 해제) + // console.log('[MOVE-TRACE] === getMoveUpDownLine entry ===', + // 'moveUpDown=', roof.moveUpDown, + // 'moveFlowLine=', roof.moveFlowLine) + // sortWallBaseLines.forEach((bl, i) => { + // console.log(`[MOVE-TRACE] sortWallBaseLines[${i}]`, + // `(${Math.round(bl?.x1)},${Math.round(bl?.y1)})→(${Math.round(bl?.x2)},${Math.round(bl?.y2)})`, + // 'vs sortWallLines', + // `(${Math.round(sortWallLines[i]?.x1)},${Math.round(sortWallLines[i]?.y1)})→(${Math.round(sortWallLines[i]?.x2)},${Math.round(sortWallLines[i]?.y2)})`, + // 'vs sortRoofLines', + // `(${Math.round(sortRoofLines[i]?.x1)},${Math.round(sortRoofLines[i]?.y1)})→(${Math.round(sortRoofLines[i]?.x2)},${Math.round(sortRoofLines[i]?.y2)})`) + // }) + // ===== 공통 헬퍼 함수 ===== // axis config: vertical(left/right) → moveAxis='x', lineAxis='y' // horizontal(top/bottom) → moveAxis='y', lineAxis='x' @@ -1248,6 +1337,19 @@ const createInnerLinesFromSkeleton = (roofId, canvas, skeleton, textMode, isOver console.log(`${condition}::::isStartEnd:::::`) + // [MOVE-TRACE] index 0 전용 (필요 시 주석 해제) + // if (index === 0) { + // console.log('[MOVE-TRACE] processInStartEnd idx=0', + // 'condition=', condition, + // 'isStart=', isStart, + // 'inSign=', inSign, + // 'moveAxis=', moveAxis, + // 'lineAxis=', lineAxis, + // 'wallLine=', `(${Math.round(wallLine.x1)},${Math.round(wallLine.y1)})→(${Math.round(wallLine.x2)},${Math.round(wallLine.y2)})`, + // 'wallBaseLine=', `(${Math.round(wallBaseLine.x1)},${Math.round(wallBaseLine.y1)})→(${Math.round(wallBaseLine.x2)},${Math.round(wallBaseLine.y2)})`, + // 'roofLine=', `(${Math.round(roofLine.x1)},${Math.round(roofLine.y1)})→(${Math.round(roofLine.x2)},${Math.round(roofLine.y2)})`) + // } + // 고정 끝점 설정 if (isStart) { newPEnd[lineAxis] = roofLine[`${lineAxis}2`] @@ -1257,7 +1359,7 @@ const createInnerLinesFromSkeleton = (roofId, canvas, skeleton, textMode, isOver newPStart[moveAxis] = roofLine[`${moveAxis}1`] } - const moveDist = Big(wallBaseLine[m]).minus(wallLine[m]).toNumber() + const moveDist = Big(wallBaseLine[m]).minus(wallLine[m]).abs().toNumber() const ePoint = { [moveAxis]: wallBaseLine[m], [lineAxis]: wallBaseLine[l] } if (isStart) { newPStart[lineAxis] = wallBaseLine[l] @@ -1267,7 +1369,9 @@ const createInnerLinesFromSkeleton = (roofId, canvas, skeleton, textMode, isOver findPoints.push({ ...ePoint, position: `${condition}_${isStart ? 'start' : 'end'}` }) - let newPointM = Big(roofLine[`${moveAxis}1`]).plus(moveDist).toNumber() + let newPointM = Big(roofLine[`${moveAxis}1`]) + [inSign > 0 ? 'plus' : 'minus'](moveDist) + .toNumber() const pLineL = roofLine[l] const prevIndex = (index - 1 + sortRoofLines.length) % sortRoofLines.length @@ -1423,6 +1527,18 @@ const createInnerLinesFromSkeleton = (roofId, canvas, skeleton, textMode, isOver const roofLine = sortRoofLines[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 외곽선 설정 // console.log('index::::', index) @@ -1469,6 +1585,17 @@ const createInnerLinesFromSkeleton = (roofId, canvas, skeleton, textMode, isOver const getAddLine = (p1, p2, stroke = '', lineType = 'eaveHelpLine') => { movedLines.push({ index, p1, p2 }) + // [진단] eaveHelpLine 생성 추적: 어느 index/어느 wallBaseLine-wallLine 쌍에서 만들어지는지 + console.log('🎯 [getAddLine]', { + index, + lineType, + p1: { x: Math.round(p1.x), y: Math.round(p1.y) }, + p2: { x: Math.round(p2.x), y: Math.round(p2.y) }, + wallLine: `(${Math.round(wallLine.x1)},${Math.round(wallLine.y1)})→(${Math.round(wallLine.x2)},${Math.round(wallLine.y2)})`, + wallBaseLine: `(${Math.round(wallBaseLine.x1)},${Math.round(wallBaseLine.y1)})→(${Math.round(wallBaseLine.x2)},${Math.round(wallBaseLine.y2)})`, + wallBaseLen: Math.round(Math.hypot(wallBaseLine.x2 - wallBaseLine.x1, wallBaseLine.y2 - wallBaseLine.y1)), + }) + const dx = Math.abs(p2.x - p1.x); const dy = Math.abs(p2.y - p1.y); const isDiagonal = dx > 0.5 && dy > 0.5; // x, y 변화가 모두 있으면 대각선 @@ -1530,6 +1657,16 @@ const createInnerLinesFromSkeleton = (roofId, canvas, skeleton, textMode, isOver const mLine = getSelectLinePosition(wall, wallBaseLine) + // [진단] 왜 bottom/top/left/right로 잡혔는지 추적 + // - wallBaseLine이 실제로는 어떤 방향인지, midpoint, 위/아래 안팎 결과 확인 + { + const midX = (wallBaseLine.x1 + wallBaseLine.x2) / 2 + const midY = (wallBaseLine.y1 + wallBaseLine.y2) / 2 + const isH = Math.abs(wallBaseLine.y1 - wallBaseLine.y2) < 0.5 + const isV = Math.abs(wallBaseLine.x1 - wallBaseLine.x2) < 0.5 + console.log(`🧭 [getSelectLinePosition 결과] idx=${index} position=${mLine.position} orient=${mLine.orientation} mid=(${Math.round(midX)},${Math.round(midY)}) isH=${isH} isV=${isV} wallLine=(${Math.round(wallLine.x1)},${Math.round(wallLine.y1)})→(${Math.round(wallLine.x2)},${Math.round(wallLine.y2)}) wallBaseLine=(${Math.round(wallBaseLine.x1)},${Math.round(wallBaseLine.y1)})→(${Math.round(wallBaseLine.x2)},${Math.round(wallBaseLine.y2)})`) + } + if (getOrientation(roofLine) === 'vertical') { if (['left', 'right'].includes(mLine.position)) { if (Math.abs(wallLine.x1 - wallBaseLine.x1) < 0.1 || Math.abs(wallLine.x2 - wallBaseLine.x2) < 0.1) { @@ -3682,22 +3819,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; } @@ -4122,14 +4259,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]; } From 936d490803bc6dd7880faf252ad419a99de7bf51 Mon Sep 17 00:00:00 2001 From: ysCha Date: Thu, 23 Apr 2026 15:24:21 +0900 Subject: [PATCH 17/38] =?UTF-8?q?[1806]=EC=84=9C=EA=B9=8C=EB=9E=98=20?= =?UTF-8?q?=EB=86=92=EC=9D=B4=20=EC=A1=B0=EC=A0=95=EC=97=90=20=EB=94=B0?= =?UTF-8?q?=EB=A5=B8=20=EC=A7=80=EB=B6=95=20=ED=98=95=EC=83=81=20=EB=B6=88?= =?UTF-8?q?=EC=9D=BC=EC=B9=98=20-=20=EB=9D=BC=EC=9D=B8=EC=98=A4=EB=B2=84?= =?UTF-8?q?=EC=B2=B4=ED=81=AC2?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/util/skeleton-utils.js | 219 +++++++++++++++++-------------------- 1 file changed, 100 insertions(+), 119 deletions(-) diff --git a/src/util/skeleton-utils.js b/src/util/skeleton-utils.js index a882f630..88096345 100644 --- a/src/util/skeleton-utils.js +++ b/src/util/skeleton-utils.js @@ -205,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(); + } }); /** @@ -446,35 +397,41 @@ const buildRawMovedPoints = (roofId, canvas) => { const position = roof.movePosition const moveDirection = roof.moveDirect - const matchingLines = baseLines.filter((line) => - (isSamePoint(line.startPoint, selectLine.startPoint) && isSamePoint(line.endPoint, selectLine.endPoint)) || - (isSamePoint(line.startPoint, selectLine.endPoint) && isSamePoint(line.endPoint, selectLine.startPoint)) - ) + // 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)) + ) - matchingLines.forEach((line) => { - const s = line.startPoint - const e = line.endPoint - newPoints.forEach((point, index) => { - const bp = basePoints[index] - if (!bp) return - const matchS = isSamePoint(bp, s) - const matchE = isSamePoint(bp, e) - if (!matchS && !matchE) 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() - } - }) + 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] 값 @@ -1406,6 +1363,22 @@ const createInnerLinesFromSkeleton = (roofId, canvas, skeleton, textMode, isOver getAddLine, sortRoofLines, findPoints, innerLines, moveAxis, lineAxis, inSign }) => { + // [SHOULDER-ADJACENT] 인접 pair 가 SHOULDER_ABSORBED 된 경우 HIP 빗변은 orphan 이 됨. + // 이유: processInBoth 는 roofLine(원본 roof.points 기반) 꼭짓점을 HIP 끝점으로 씀. + // 인접 pair 흡수 = 해당 꼭짓점이 현재 wallBaseLine 토폴로지에 없음 → 공중에 뜬 대각선. + // SHOULDER_ABSORBED 가드(line 1482 인근)와 같은 원인·패턴의 후속 증상. + { + const n = sortWallBaseLines.length + const prevBL = sortWallBaseLines[(index - 1 + n) % n] + const nextBL = sortWallBaseLines[(index + 1) % n] + const prevAbsorbed = (prevBL?.attributes?.planeSize ?? Infinity) < 1 + const nextAbsorbed = (nextBL?.attributes?.planeSize ?? Infinity) < 1 + if (prevAbsorbed || nextAbsorbed) { + console.log(`⏭️ [pair#${index}] ${condition} HIP_SKIP adj_absorbed prev=${prevAbsorbed} next=${nextAbsorbed}`) + return + } + } + console.log(`${condition}::::isStartEnd (both):::::`) // 원본 기준: moveDistY = abs(roofLine.y - wallBaseLine.y), moveDistX = abs(roofLine.x - wallBaseLine.x) @@ -1527,6 +1500,14 @@ const createInnerLinesFromSkeleton = (roofId, canvas, skeleton, textMode, isOver const roofLine = sortRoofLines[index] const wallBaseLine = sortWallBaseLines[index] + // [SHOULDER-ABSORBED] wall.baseLines[k] 가 이동 흡수로 zero-length(planeSize≈0)면 + // 해당 edge 는 흡수되어 사라진 상태. pair 생성 자체가 불필요(DIR_MISMATCH + eaveHelpLine 노이즈 유발). + // 참고: 같은 파일 line 666 의 filter(planeSize > 0) 선례와 동일 패턴. + if (!wallBaseLine || (wallBaseLine.attributes?.planeSize ?? Infinity) < 1) { + console.log(`⏭️ [pair#${index}] SHOULDER_ABSORBED skip: wb=(${Math.round(wallBaseLine?.x1)},${Math.round(wallBaseLine?.y1)})→(${Math.round(wallBaseLine?.x2)},${Math.round(wallBaseLine?.y2)}) planeSize=${wallBaseLine?.attributes?.planeSize}`) + return + } + // [진단] sortWallLines ↔ sortWallBaseLines 페어링 검증 // collapse 시 ensureCounterClockwiseLines 시작점이 달라지면 인덱스가 어긋남 // 정상: 같은 물리 벽 → 적어도 한쪽 끝점 공유 또는 방향 동일(수직/수평) From 0b0a1786daafe14fcb42d980832537f1592e7ad0 Mon Sep 17 00:00:00 2001 From: ysCha Date: Fri, 24 Apr 2026 20:02:39 +0900 Subject: [PATCH 18/38] =?UTF-8?q?=EB=9D=BC=EC=9D=B8=EC=98=A4=EB=B2=84?= =?UTF-8?q?=ED=95=98=EB=A9=B4=20=ED=8F=89=ED=96=89=EB=9D=BC=EC=9D=B8?= =?UTF-8?q?=EC=9C=BC=EB=A1=9C=20=EB=B3=80=EA=B2=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/components/fabric/QPolygon.js | 45 +++++--- src/hooks/roofcover/useMovementSetting.js | 59 ++++++++++ src/util/skeleton-utils.js | 130 +++++++++++++++++++--- 3 files changed, 204 insertions(+), 30 deletions(-) diff --git a/src/components/fabric/QPolygon.js b/src/components/fabric/QPolygon.js index 8e898d37..c3b330c2 100644 --- a/src/components/fabric/QPolygon.js +++ b/src/components/fabric/QPolygon.js @@ -6,7 +6,7 @@ import { calculateAngle, drawGableRoof, drawRoofByAttribute, drawShedRoof, toGeo import * as turf from '@turf/turf' import { LINE_TYPE, POLYGON_TYPE } from '@/common/common' import Big from 'big.js' -import { drawSkeletonRidgeRoof, verifyMoveBoundary } from '@/util/skeleton-utils' +import { drawSkeletonRidgeRoof, drawSkeletonRidgeRoofFromBaseLines, verifyMoveBoundary } from '@/util/skeleton-utils' export const QPolygon = fabric.util.createClass(fabric.Polygon, { type: 'QPolygon', @@ -380,21 +380,23 @@ export const QPolygon = fabric.util.createClass(fabric.Polygon, { /** * 보조선 그리기 * @param settingModalFirstOptions + * @param forceRedraw - true 면 verifyMoveBoundary 'crossed' 가드를 우회하고 강제로 재빌드. + * (예: 오버 이동 상태에서 wall.baseLines/roof.lines 는 그대로 두고 SK 만 재생성하고 싶을 때) */ - drawHelpLine(settingModalFirstOptions) { - // [보호 가드] 이번 이동이 골짜기 경계를 넘어가는 과한 이동이면 - // 아예 초기화/재빌드 자체를 수행하지 않고 현재 상태 그대로 유지한다. - // ※ 기존 drawHelpLine 로직은 건드리지 않고, 진입부에 가드 한 블록만 추가. - // ※ 최초 그리기·offset 변경 등 moveUpDown=0 / moveFlowLine=0 케이스는 - // verifyMoveBoundary 가 'ok' 를 돌려주므로 영향 없음. + drawHelpLine(settingModalFirstOptions, forceRedraw = false) { + // [정책] 오버 이동(verifyMoveBoundary='crossed') 처리: + // - roofLine 은 그대로, wall.baseLines 는 이미 오버된 좌표로 mutate 된 상태. + // - 보조선 싹 지우고 '오버된 wall.baseLines 좌표 그대로' SK 재빌드. + // - 일반 경로(drawSkeletonRidgeRoof) 는 45도 대각 확장/roof 경계 클램핑이 들어가 + // 오버 polygon 이 왜곡 → 용마루 지붕 케이스는 전용 함수(drawSkeletonRidgeRoofFromBaseLines)로 분기. + // - forceRedraw 인자는 명시적 수동 호출 의도 표현 용도로만 유지. + let __verdict = 'unknown' try { - const __verdict = verifyMoveBoundary(this.id, this.canvas) + __verdict = verifyMoveBoundary(this.id, this.canvas) if (__verdict === 'crossed') { - console.warn('[drawHelpLine] 경계 넘음(crossed) → 초기화/재빌드 스킵 (기존 innerLines/SK 유지)') - return + console.warn(`[drawHelpLine] 경계 넘음(crossed) → 오버 전용 경로(drawSkeletonRidgeRoofFromBaseLines) 사용 (forceRedraw=${forceRedraw})`) } } catch (e) { - // 판정기 자체 실패는 무시하고 기존 흐름 진행 (안전한 no-op) console.warn('[drawHelpLine] verifyMoveBoundary 예외 → 기존 흐름 진행:', e) } @@ -495,8 +497,12 @@ export const QPolygon = fabric.util.createClass(fabric.Polygon, { if (types.every((type) => type === LINE_TYPE.WALLLINE.EAVES)) { // 용마루 -- straight-skeleton - // console.log('용마루 지붕') - drawSkeletonRidgeRoof(this.id, this.canvas, textMode) + // 오버(crossed) 이동 시 wall.baseLines 좌표 그대로 사용하는 전용 경로로 분기. + if (__verdict === 'crossed') { + drawSkeletonRidgeRoofFromBaseLines(this.id, this.canvas, textMode) + } else { + drawSkeletonRidgeRoof(this.id, this.canvas, textMode) + } } else if (isGableRoof(types)) { // A형, B형 박공 지붕 // console.log('패턴 지붕') @@ -510,6 +516,19 @@ export const QPolygon = fabric.util.createClass(fabric.Polygon, { } }, + /** + * 오버 이동(crossed) 상태여도 wall.baseLines / roof.lines / outerLine / lengthText 는 그대로 두고 + * 보조선(innerLines: eaveHelpLine, HIP, extensionLine, SK ridge 등) 만 제거 후 SK 재빌드. + * + * drawHelpLine 의 진입부 verifyMoveBoundary 가드만 우회하는 wrapper. 이후 흐름은 동일. + * - 초기화 필터(line 405-411) 가 이미 wall/roof/baseLine/outerLine/lengthText 를 제외하고 있어 + * 해당 객체들은 보존됨. + * - 사용자가 오버된 상태에서 "SK 만 다시 그려보기" 의도로 명시적으로 호출할 때 사용. + */ + redrawHelpLineForced(settingModalFirstOptions) { + return this.drawHelpLine(settingModalFirstOptions, true) + }, + addLengthText() { if ([POLYGON_TYPE.MODULE, 'arrow', POLYGON_TYPE.MODULE_SETUP_SURFACE, POLYGON_TYPE.OBJECT_SURFACE].includes(this.name)) { return 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/util/skeleton-utils.js b/src/util/skeleton-utils.js index 88096345..fdf4ddb7 100644 --- a/src/util/skeleton-utils.js +++ b/src/util/skeleton-utils.js @@ -783,14 +783,17 @@ export const skeletonBuilder = (roofId, canvas, textMode) => { // 6. 마루이동(용마루 위치 수동 조정)이 설정된 경우 movingLineFromSkeleton 결과로 대체 if (moveFlowLine !== 0 || moveUpDown !== 0) { - // [보호 가드] 이동이 골짜기 경계를 넘어가는지 사전 판정 - // 'crossed' → 외곽선을 뒤집을 만큼 과한 이동 → 기존 SK/innerLines/skeletonLines 전부 보존 후 조용히 종료 - // 'ok' | 'on-boundary' | 'unknown' → 기존 흐름 그대로 진행 - // ※ 기존 로직은 전혀 수정하지 않고, 진입부에 한 블록만 추가 - const __moveVerdict = verifyMoveBoundary(roofId, canvas) - if (__moveVerdict === 'crossed') { - console.warn('[skeleton] 경계 넘음(crossed) → 재빌드 스킵 (기존 SK/innerLines/skeletonLines 유지)') - return + // [정책] 오버 이동(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) @@ -901,18 +904,16 @@ export const skeletonBuilder = (roofId, canvas, textMode) => { // ────────────────────────────────────────────────────────────────── // [분기] 스켈레톤 입력 포인트 결정 - // 정상: changRoofLinePoints 그대로 사용 (기존 로직 변경 없음) - // 오버: calcOverCorrectedPoints() 별도 함수로 보정 포인트 생성 - // ※ changRoofLinePoints 자체는 절대 수정하지 않음 - // ※ 오버 보정 실패 시 changRoofLinePoints 그대로 fallback + // 정상: changRoofLinePoints 그대로 사용 + // 오버(일반 경로로 들어온 에지 케이스): calcOverCorrectedPoints() 로 보정 포인트 생성 + // ※ verifyMoveBoundary === 'crossed' 케이스는 drawHelpLine 에서 drawSkeletonRidgeRoofFromBaseLines + // 로 사전 분기하므로, 여기 isOverDetected 분기는 일반 경로의 안전망 용도로만 작동. + // ※ changRoofLinePoints 자체는 절대 수정하지 않음. 실패 시 그대로 fallback. // ────────────────────────────────────────────────────────────────── - let skeletonInputPoints = changRoofLinePoints // 정상 경로 + let skeletonInputPoints = changRoofLinePoints if (isOverDetected) { - // [예외] 오버 감지 시 → 별도 함수에서 스켈레톤 전용 보정 포인트 계산 try { - // 누적 이동 보호: 이전 SK 확정 상태(lastPoints)를 전달하여 - // 1차 이동에서 이미 확정된 점은 보정 대상에서 제외한다. const corrected = calcOverCorrectedPointsSafe( changRoofLinePoints, origPoints, @@ -923,7 +924,6 @@ export const skeletonBuilder = (roofId, canvas, textMode) => { console.log('[SK_OVER] 보정 포인트 적용:', skeletonInputPoints.map((p, i) => `[${i}](${Math.round(p.x)},${Math.round(p.y)})`)) } } catch (e) { - // 보정 실패 시 기존 changRoofLinePoints로 fallback → 기존 동작 보장 console.error('[SK_OVER] 보정 실패 → fallback:', e) } } @@ -1006,6 +1006,102 @@ export const skeletonBuilder = (roofId, canvas, textMode) => { } } +/** + * 오버 이동(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) + + 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 + } +} + /** * 스켈레톤 결과와 외벽선 정보를 바탕으로 내부선(용마루, 추녀)을 생성합니다. * @param {object} skeleton - SkeletonBuilder로부터 반환된 스켈레톤 객체 From d810b89658535c74cd342a8cacb229daadc5a138 Mon Sep 17 00:00:00 2001 From: ysCha Date: Mon, 27 Apr 2026 13:09:28 +0900 Subject: [PATCH 19/38] =?UTF-8?q?=EB=9D=BC=EC=9D=B8=EC=A4=84=ED=91=9C?= =?UTF-8?q?=EC=8B=9C,=20=EC=9D=B8=EB=8D=B1=EC=8A=A4=20=EC=9E=AC=EC=84=A4?= =?UTF-8?q?=EC=B9=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/components/fabric/QPolygon.js | 4 ++++ src/util/skeleton-utils.js | 32 ++++++++++++++++++------------- 2 files changed, 23 insertions(+), 13 deletions(-) diff --git a/src/components/fabric/QPolygon.js b/src/components/fabric/QPolygon.js index c3b330c2..3cdd644b 100644 --- a/src/components/fabric/QPolygon.js +++ b/src/components/fabric/QPolygon.js @@ -7,6 +7,7 @@ import * as turf from '@turf/turf' import { LINE_TYPE, POLYGON_TYPE } from '@/common/common' import Big from 'big.js' import { drawSkeletonRidgeRoof, drawSkeletonRidgeRoofFromBaseLines, verifyMoveBoundary } from '@/util/skeleton-utils' +import { attachDebugLabels } from '@/util/debugLabels' export const QPolygon = fabric.util.createClass(fabric.Polygon, { type: 'QPolygon', @@ -514,6 +515,9 @@ export const QPolygon = fabric.util.createClass(fabric.Polygon, { // console.log('변별로 설정') drawRoofByAttribute(this.id, this.canvas, textMode) } + + // [DEBUG] URL ?debug=labels 일 때만 라벨 부착. 운영 빌드/평소엔 noop. + attachDebugLabels(this.canvas, this.id) }, /** diff --git a/src/util/skeleton-utils.js b/src/util/skeleton-utils.js index fdf4ddb7..958eb53e 100644 --- a/src/util/skeleton-utils.js +++ b/src/util/skeleton-utils.js @@ -1339,24 +1339,29 @@ const createInnerLinesFromSkeleton = (roofId, canvas, skeleton, textMode, isOver }) } - // 각 라인 집합 정렬 - const sortWallLines = ensureCounterClockwiseLines(wallLines) - // wall.baseLines를 독립적으로 CCW 정렬하면 ㅗ→붕괴 등으로 꼭짓점 집합이 달라질 때 - // 시작점(min-Y-then-min-X)이 wallLines와 어긋나 배열이 shift된다. - // wallLines[i]와 wall.baseLines[i]는 초기 생성 시부터 raw-index 1:1 페어링이 유지되므로, - // sortWallLines의 CCW 순서에 대응하는 원 인덱스를 복원하여 같은 순서로 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() - wallLines.forEach((l, i) => _rawIdxByKey.set(_keyOfEdge(l), i)) - const sortWallBaseLines = sortWallLines.map((sl) => { + 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 }) - const sortRoofLines = ensureCounterClockwiseLines(roofLines) // [MOVE-TRACE] 진입 스냅샷 (필요 시 주석 해제) // console.log('[MOVE-TRACE] === getMoveUpDownLine entry ===', @@ -1596,10 +1601,11 @@ const createInnerLinesFromSkeleton = (roofId, canvas, skeleton, textMode, isOver const roofLine = sortRoofLines[index] const wallBaseLine = sortWallBaseLines[index] - // [SHOULDER-ABSORBED] wall.baseLines[k] 가 이동 흡수로 zero-length(planeSize≈0)면 - // 해당 edge 는 흡수되어 사라진 상태. pair 생성 자체가 불필요(DIR_MISMATCH + eaveHelpLine 노이즈 유발). - // 참고: 같은 파일 line 666 의 filter(planeSize > 0) 선례와 동일 패턴. - if (!wallBaseLine || (wallBaseLine.attributes?.planeSize ?? Infinity) < 1) { + // [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 } From aab8ebbb051729fba36c663fdbff4b6455a08cdb Mon Sep 17 00:00:00 2001 From: ysCha Date: Mon, 27 Apr 2026 13:20:19 +0900 Subject: [PATCH 20/38] =?UTF-8?q?=EB=B9=8C=EB=93=9C=20=EC=98=A4=EB=A5=98?= =?UTF-8?q?=20=EC=88=98=EC=A0=95:=20debugLabels=20import=20=EC=A0=9C?= =?UTF-8?q?=EA=B1=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit dev-deploy 빌드에서 untracked 파일(@/util/debugLabels) 참조로 실패. 디버그 전용 라벨 부착 호출은 로컬 한정이므로 커밋 대상에서 제거. Co-Authored-By: Claude Opus 4.6 --- src/components/fabric/QPolygon.js | 4 ---- 1 file changed, 4 deletions(-) diff --git a/src/components/fabric/QPolygon.js b/src/components/fabric/QPolygon.js index 3cdd644b..c3b330c2 100644 --- a/src/components/fabric/QPolygon.js +++ b/src/components/fabric/QPolygon.js @@ -7,7 +7,6 @@ import * as turf from '@turf/turf' import { LINE_TYPE, POLYGON_TYPE } from '@/common/common' import Big from 'big.js' import { drawSkeletonRidgeRoof, drawSkeletonRidgeRoofFromBaseLines, verifyMoveBoundary } from '@/util/skeleton-utils' -import { attachDebugLabels } from '@/util/debugLabels' export const QPolygon = fabric.util.createClass(fabric.Polygon, { type: 'QPolygon', @@ -515,9 +514,6 @@ export const QPolygon = fabric.util.createClass(fabric.Polygon, { // console.log('변별로 설정') drawRoofByAttribute(this.id, this.canvas, textMode) } - - // [DEBUG] URL ?debug=labels 일 때만 라벨 부착. 운영 빌드/평소엔 noop. - attachDebugLabels(this.canvas, this.id) }, /** From 21c9f85a519a9e9309f61be7e06ed74d18b84727 Mon Sep 17 00:00:00 2001 From: ysCha Date: Mon, 27 Apr 2026 13:24:21 +0900 Subject: [PATCH 21/38] =?UTF-8?q?=EB=9D=BC=EC=9D=B8=EC=A4=84=ED=91=9C?= =?UTF-8?q?=EC=8B=9C,=20=EC=9D=B8=EB=8D=B1=EC=8A=A4=20=EC=9E=AC=EC=84=A4?= =?UTF-8?q?=EC=B9=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/components/fabric/QPolygon.js | 1 + 1 file changed, 1 insertion(+) diff --git a/src/components/fabric/QPolygon.js b/src/components/fabric/QPolygon.js index c3b330c2..9290e4f6 100644 --- a/src/components/fabric/QPolygon.js +++ b/src/components/fabric/QPolygon.js @@ -8,6 +8,7 @@ import { LINE_TYPE, POLYGON_TYPE } from '@/common/common' import Big from 'big.js' import { drawSkeletonRidgeRoof, drawSkeletonRidgeRoofFromBaseLines, verifyMoveBoundary } from '@/util/skeleton-utils' + export const QPolygon = fabric.util.createClass(fabric.Polygon, { type: 'QPolygon', // lines: [], From d9bea9dcfb0309cf378a7c90768af628bf0d81bf Mon Sep 17 00:00:00 2001 From: ysCha Date: Mon, 27 Apr 2026 15:11:39 +0900 Subject: [PATCH 22/38] =?UTF-8?q?=EC=9E=94=EC=97=AC=EC=84=A0=20=EC=82=AD?= =?UTF-8?q?=EC=A0=9C=20=EB=B0=8F=20=EB=9D=BC=EB=B2=A8=ED=91=9C=EC=8B=9C(lo?= =?UTF-8?q?cal)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/components/fabric/QPolygon.js | 83 ++++++++++++++++++++++++ src/util/skeleton-utils.js | 103 +++++++++++++++++++++++++++--- 2 files changed, 176 insertions(+), 10 deletions(-) diff --git a/src/components/fabric/QPolygon.js b/src/components/fabric/QPolygon.js index 9290e4f6..276a7287 100644 --- a/src/components/fabric/QPolygon.js +++ b/src/components/fabric/QPolygon.js @@ -8,6 +8,86 @@ import { LINE_TYPE, POLYGON_TYPE } from '@/common/common' import Big from 'big.js' import { drawSkeletonRidgeRoof, drawSkeletonRidgeRoofFromBaseLines, verifyMoveBoundary } from '@/util/skeleton-utils' +// ======================================================================== +// [DEBUG] 캔버스 라인 라벨 — 로컬 전용 (NEXT_PUBLIC_RUN_MODE === 'local') +// ---------------------------------------------------------------------- +// 별도 util 파일을 두지 않고 QPolygon.js 안에 인라인. +// dev/prd 에선 가드가 즉시 false → 코드 미실행. 외부 의존성 없음. +// 라인 자체의 stroke/fill 은 절대 손대지 않고 fabric.Text 만 추가. +// ======================================================================== +const DEBUG_LABEL_NAME = '__debugLabel' + +function __isDebugLabelsEnabled() { + return process.env.NEXT_PUBLIC_RUN_MODE === 'local' +} + +function __classifyLineForLabel(obj) { + const name = obj.name + const lineName = obj.lineName + const type = obj.attributes?.type + + if (name === 'baseLine') return 'B' + if (name === 'outerLine' || name === 'eaves' || lineName === 'roofLine') return 'R' + if (name === LINE_TYPE.SUBLINE.HIP || lineName === LINE_TYPE.SUBLINE.HIP) return 'H' + if (name === LINE_TYPE.SUBLINE.RIDGE || lineName === LINE_TYPE.SUBLINE.RIDGE) return 'RG' + if (name === LINE_TYPE.SUBLINE.VALLEY || lineName === LINE_TYPE.SUBLINE.VALLEY) return 'V' + if (name === LINE_TYPE.SUBLINE.GABLE || lineName === LINE_TYPE.SUBLINE.GABLE) return 'G' + if (name === LINE_TYPE.SUBLINE.VERGE || lineName === LINE_TYPE.SUBLINE.VERGE) return 'VG' + if ( + type === LINE_TYPE.WALLLINE.EAVE_HELP_LINE || + lineName === LINE_TYPE.WALLLINE.EAVE_HELP_LINE || + name === LINE_TYPE.WALLLINE.EAVE_HELP_LINE + ) return 'E' + if ( + typeof obj.x1 === 'number' && + typeof obj.y1 === 'number' && + typeof obj.x2 === 'number' && + typeof obj.y2 === 'number' + ) return 'SK' + return null +} + +function __attachDebugLabels(canvas, parentId) { + if (!__isDebugLabelsEnabled()) return + if (!canvas || !parentId) return + + const counters = {} + const objects = canvas.getObjects().filter((o) => o.parentId === parentId && o.name !== DEBUG_LABEL_NAME) + + objects.forEach((obj) => { + const prefix = __classifyLineForLabel(obj) + if (!prefix) return + + counters[prefix] = (counters[prefix] || 0) + 1 + const label = `${prefix}-${counters[prefix]}` + const mx = (obj.x1 + obj.x2) / 2 + const my = (obj.y1 + obj.y2) / 2 + + const text = new fabric.Text(label, { + left: mx, + top: my, + originX: 'center', + originY: 'center', + fontSize: 12, + fill: '#000', + fontFamily: 'monospace', + fontWeight: 'bold', + backgroundColor: 'rgba(255,255,0,0.85)', + selectable: false, + evented: false, + hasControls: false, + hasBorders: false, + name: DEBUG_LABEL_NAME, + parentId: parentId, + excludeFromExport: true, + }) + canvas.add(text) + text.bringToFront() + }) + + canvas.renderAll() +} + export const QPolygon = fabric.util.createClass(fabric.Polygon, { type: 'QPolygon', @@ -515,6 +595,9 @@ export const QPolygon = fabric.util.createClass(fabric.Polygon, { // console.log('변별로 설정') drawRoofByAttribute(this.id, this.canvas, textMode) } + + // [DEBUG] 로컬(NEXT_PUBLIC_RUN_MODE==='local') 에서만 라벨 부착. 그 외 즉시 return. + __attachDebugLabels(this.canvas, this.id) }, /** diff --git a/src/util/skeleton-utils.js b/src/util/skeleton-utils.js index 958eb53e..b0064ea0 100644 --- a/src/util/skeleton-utils.js +++ b/src/util/skeleton-utils.js @@ -940,7 +940,7 @@ export const skeletonBuilder = (roofId, canvas, textMode) => { // 스켈레톤 데이터를 기반으로 내부선 생성 roof.innerLines = roof.innerLines || [] - roof.innerLines = createInnerLinesFromSkeleton(roofId, canvas, skeleton, textMode, isOverDetected) + roof.innerLines = createInnerLinesFromSkeleton(roofId, canvas, skeleton, textMode, isOverDetected, changRoofLinePoints) //console.log("roofInnerLines:::", roof.innerLines); // 캔버스에 스켈레톤 상태 저장 if (!canvas.skeletonStates) { @@ -1067,7 +1067,7 @@ export const drawSkeletonRidgeRoofFromBaseLines = (roofId, canvas, textMode) => const skeleton = SkeletonBuilder.BuildFromGeoJSON([[geoJSONPolygon]]) roof.innerLines = roof.innerLines || [] - roof.innerLines = createInnerLinesFromSkeleton(roofId, canvas, skeleton, textMode, /* isOverDetected */ true) + roof.innerLines = createInnerLinesFromSkeleton(roofId, canvas, skeleton, textMode, /* isOverDetected */ true, rawPoints) if (!canvas.skeletonStates) { canvas.skeletonStates = {} @@ -1111,7 +1111,7 @@ export const drawSkeletonRidgeRoofFromBaseLines = (roofId, canvas, textMode) => * @param {string} textMode - 텍스트 표시 모드 ('plane', 'actual', 'none') * @param {Array} baseLines - 원본 외벽선 QLine 객체 배열 */ -const createInnerLinesFromSkeleton = (roofId, canvas, skeleton, textMode, isOverDetected = false) => { +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) @@ -1467,13 +1467,15 @@ const createInnerLinesFromSkeleton = (roofId, canvas, skeleton, textMode, isOver // [SHOULDER-ADJACENT] 인접 pair 가 SHOULDER_ABSORBED 된 경우 HIP 빗변은 orphan 이 됨. // 이유: processInBoth 는 roofLine(원본 roof.points 기반) 꼭짓점을 HIP 끝점으로 씀. // 인접 pair 흡수 = 해당 꼭짓점이 현재 wallBaseLine 토폴로지에 없음 → 공중에 뜬 대각선. - // SHOULDER_ABSORBED 가드(line 1482 인근)와 같은 원인·패턴의 후속 증상. + // 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) < 1 - const nextAbsorbed = (nextBL?.attributes?.planeSize ?? Infinity) < 1 + 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 @@ -1512,25 +1514,45 @@ const createInnerLinesFromSkeleton = (roofId, canvas, skeleton, textMode, isOver 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) } } } @@ -1794,6 +1816,21 @@ const createInnerLinesFromSkeleton = (roofId, canvas, skeleton, textMode, isOver } } 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() @@ -1903,6 +1940,21 @@ const createInnerLinesFromSkeleton = (roofId, canvas, skeleton, textMode, isOver } 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() @@ -2049,6 +2101,21 @@ const createInnerLinesFromSkeleton = (roofId, canvas, skeleton, textMode, isOver } } 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() @@ -2156,6 +2223,22 @@ const createInnerLinesFromSkeleton = (roofId, canvas, skeleton, textMode, isOver } } 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() From 6c0145ef30f22e953236b1ca07e4b26e17a61c78 Mon Sep 17 00:00:00 2001 From: ysCha Date: Mon, 27 Apr 2026 17:28:00 +0900 Subject: [PATCH 23/38] =?UTF-8?q?=EC=99=B8=EB=B2=BD=EC=84=A0=20=ED=99=95?= =?UTF-8?q?=EC=A0=95=20=EC=8B=9C=20=ED=99=95=EC=9D=B8=20=EB=8B=A4=EC=9D=B4?= =?UTF-8?q?=EC=96=BC=EB=A1=9C=EA=B7=B8=20=EC=B6=94=EA=B0=80=20(=EB=B2=84?= =?UTF-8?q?=ED=8A=BC/Enter=20=ED=82=A4)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../modal/outerlinesetting/WallLineSetting.jsx | 11 +++++++---- src/hooks/roofcover/useOuterLineWall.js | 10 +++++++++- src/locales/ja.json | 1 + src/locales/ko.json | 1 + 4 files changed, 18 insertions(+), 5 deletions(-) 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/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, + }) } } From 4e78429da85e851296c69cce7f37a07f1c8ccb33 Mon Sep 17 00:00:00 2001 From: ysCha Date: Thu, 7 May 2026 14:35:44 +0900 Subject: [PATCH 25/38] =?UTF-8?q?[=EA=B2=AC=EC=A0=81=EC=84=9C]=201?= =?UTF-8?q?=EC=B0=A8=20=ED=8C=90=EB=A7=A4=EC=A0=90=EB=AA=85=20=ED=96=89=20?= =?UTF-8?q?=EB=85=B8=EC=B6=9C=20=EC=A1=B0=EA=B1=B4=20=EB=B3=B4=EC=A0=95=20?= =?UTF-8?q?=E2=80=94=20storeLvl=20=3D=3D=3D=20'1'=20=EC=9D=BC=20=EB=95=8C?= =?UTF-8?q?=EB=A7=8C=20=ED=91=9C=EC=8B=9C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/components/estimate/Estimate.jsx | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/src/components/estimate/Estimate.jsx b/src/components/estimate/Estimate.jsx index 892646f3..7c7d4926 100644 --- a/src/components/estimate/Estimate.jsx +++ b/src/components/estimate/Estimate.jsx @@ -1363,7 +1363,8 @@ export default function Estimate({}) { - {session?.storeLvl !== '2' && ( + {/* [estimate-1차점 노출조건 2026-05-07] storeLvl === '1' 일 때만 1차 판매점명 행 노출 */} + {session?.storeLvl === '1' && ( {getMessage('estimate.detail.saleStoreId')} {estimateContextState?.firstSaleStoreName} @@ -1377,7 +1378,8 @@ export default function Estimate({}) { )} - {session?.storeLvl === '2' && ( + {/* [estimate-1차점-노출조건 2026-05-07] 1차점이 아닌 사용자(2차/그 외)는 1차 판매점명 숨기고 견적일자만 colSpan=3 */} + {session?.storeLvl !== '1' && ( {getMessage('estimate.detail.estimateDate')} * From 8e7a6af3dbb2d7b0fbc82e02ffd9aa154baf1c54 Mon Sep 17 00:00:00 2001 From: ysCha Date: Fri, 15 May 2026 12:28:32 +0900 Subject: [PATCH 26/38] =?UTF-8?q?=EC=86=8C=EC=8A=A4=EC=A0=95=EB=A6=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/util/skeleton-utils.js | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/util/skeleton-utils.js b/src/util/skeleton-utils.js index e46053c4..2329cc92 100644 --- a/src/util/skeleton-utils.js +++ b/src/util/skeleton-utils.js @@ -1994,11 +1994,11 @@ const createInnerLinesFromSkeleton = (roofId, canvas, skeleton, textMode, isOver let movedLines = [] // 조건에 맞는 라인들만 필터링 - const validWallLines = [...wallLines].sort((a, b) => a.idx - b.idx).filter((wallLine, index) => wallLine.idx - 1 === index) + const validWallLines = [...wallLines].sort((a, b) => a.idx - b.idx).filter((wallLine, index) => wallLine.idx - 1 === index); // [ASI-FIX 2026-05-15] 세미콜론 없으면 다음 줄 `(` 가 함수호출로 묶여 .filter(...)(...) → "is not a function" → SK 빌드 실패 + 확장선 누락 // logger.log('', sortRoofLines, sortWallLines, sortWallBaseLines); - (sortWallLines.length === sortWallBaseLines.length && sortWallBaseLines.length > 3) && + ;(sortWallLines.length === sortWallBaseLines.length && sortWallBaseLines.length > 3) && sortWallLines.forEach((wallLine, index) => { const roofLine = sortRoofLines[index] From e381ae0ebf8f3e2a443c704f1fffe1a6979b4b92 Mon Sep 17 00:00:00 2001 From: sangwook yoo Date: Mon, 18 May 2026 16:05:18 +0900 Subject: [PATCH 27/38] =?UTF-8?q?chore(pdf-gemini):=20Gemini=20API=20?= =?UTF-8?q?=ED=82=A4=20env=20=EC=B6=94=EA=B0=80=20+=20PDF=20=ED=8F=89?= =?UTF-8?q?=EB=A9=B4=EB=8F=84=20=EB=B6=84=EC=84=9D=20=EA=B5=AC=ED=98=84=20?= =?UTF-8?q?=EA=B3=84=ED=9A=8D=20=EB=AC=B8=EC=84=9C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - .env.development / .env.production 에 GEMINI_API_KEY 추가 (PDF 평면도 분석 기능용) - docs/ 에 PDF → Gemini → 캔버스 반영 기능 구현 계획서(html) 추가 Co-Authored-By: Claude Opus 4.7 (1M context) --- .env.development | 5 +- .env.production | 5 +- ...PDF Gemini 평면도 분석 기능 구현 계획.html | 1071 +++++++++++++++++ 3 files changed, 1079 insertions(+), 2 deletions(-) create mode 100644 docs/PDF Gemini 평면도 분석 기능 구현 계획.html diff --git a/.env.development b/.env.development index 357a2f7f..8f653e38 100644 --- a/.env.development +++ b/.env.development @@ -33,4 +33,7 @@ NEXT_PUBLIC_AWS_S3_BASE_URL="//files.hanasys.jp" S3_PROFILE="dev" #logging -NEXT_PUBLIC_ENABLE_LOGGING=true \ No newline at end of file +NEXT_PUBLIC_ENABLE_LOGGING=true + +#Gemini API Key +GEMINI_API_KEY="AIzaSyDDmMkTkNt12QTj7I3SUygYkOoeG_Sc28Q" \ No newline at end of file diff --git a/.env.production b/.env.production index 6e2981d9..a436dafe 100644 --- a/.env.production +++ b/.env.production @@ -32,4 +32,7 @@ NEXT_PUBLIC_AWS_S3_BASE_URL="//files.hanasys.jp" S3_PROFILE="prd" #logging -NEXT_PUBLIC_ENABLE_LOGGING=false \ No newline at end of file +NEXT_PUBLIC_ENABLE_LOGGING=false + +#Gemini API Key +GEMINI_API_KEY="AIzaSyDDmMkTkNt12QTj7I3SUygYkOoeG_Sc28Q" \ No newline at end of file diff --git a/docs/PDF Gemini 평면도 분석 기능 구현 계획.html b/docs/PDF Gemini 평면도 분석 기능 구현 계획.html new file mode 100644 index 00000000..a401e541 --- /dev/null +++ b/docs/PDF Gemini 평면도 분석 기능 구현 계획.html @@ -0,0 +1,1071 @@ + + + + + +PDF 평면도 → Gemini 분석 → 캔버스 반영 · 구현 계획 + + + + + + + + +
+
+ Q.CAST + docs / floor-plan / pdf-import + + v0.1 · draft + 2026 · 05 · 18 + +
+
+ +
+
Engineering Spec · Floor Plan / PDF Import
+

PDF 평면도 Gemini 분석 캔버스 반영 구현 계획

+

+ 사용자가 업로드한 평면도 PDF 를 Gemini 멀티모달 모델로 분석해 + 건물 외곽선 폴리곤 좌표(mm)를 얻고, Fabric.js 기반 floor-plan + 캔버스에 자동으로 반영한다. 업로드 파일은 분석 종료와 동시에 + Gemini File API 측에서 폐기한다. +

+ +
+
+ 모델 + gemini-3.1-pro-preview +
+
+ 좌표 단위 + mm · clockwise +
+
+ 진입 위치 + floor-plan toolbar +
+
+ 브랜치 + claude/lucid-shaw-91e51f +
+
+
+ +
+ + + +
+ + +
+
+
01
+
+
Requirements
+

기능 요구 사항

+
+
+
+
REQ-01

기본 프로세스 상에서 특정 버튼 클릭으로 사용자가 PDF 문서를 업로드한다.

+
REQ-02

Gemini API 로 해당 문서의 내용 중 평면도를 분석한다.

+
REQ-03

분석 내용을 응답으로 전달받고 현재 프로세스(캔버스)로 전달한다.

+
REQ-04

분석이 성공적으로 마무리되면 업로드한 파일은 폐기 처리한다.

+
+
+ + +
+
+
02
+
+
Decisions
+

결정사항

+
+
+
+
워크트리
현재 워크트리 lucid-shaw-91e51f 에서 진행
+
캔버스 반영 형태
외곽선 폴리곤 좌표(JSON)
+
버튼 위치
floor-plan 상단 툴바
+
API 호출 위치
서버 라우트 /api/gemini/floor-plan
+
Gemini 모델
gemini-3.1-pro-preview
+
좌표 단위
mm
+
툴바 아이콘
신규 PDF 아이콘 추가
+
+
+ + +
+
+
03
+
+
End-to-end Flow
+

전체 흐름

+
+
+ +
+
+
+ Client · UI + 툴바 버튼 클릭 +
+
+
+
+
+ Client · Modal + PdfImportModal — 파일 선택 및 업로드 +
+
+
+
+
+ Server · Route + POST /api/gemini/floor-plan (multipart) +
+
+
+
+
+ Gemini · File API + PDF 업로드 → fileUri 획득 +
+
+
+
+
+ Gemini · gemini-3.1-pro-preview + 외곽선 좌표 JSON 응답 (mm 단위) +
+
+
+
+
+ Server · finally + Gemini 파일 삭제 (성공 / 실패 무관) +
+
+
+
+
+ Client · Canvas + 좌표 검증 → outerLinePointsState + QLine / QPolygon 생성 +
+
+
+

∗ 클라이언트는 서버 라우트만 호출하며 Gemini API 키와 직접 통신하지 않는다.

+
+ + +
+
+
04
+
+
Files
+

핵심 파일 (수정 / 신규)

+
+
+ +
+
+
영역
경로
변경 내용
구분
+
+
+
서버 라우트
+
src/app/api/gemini/floor-plan/route.js
+
multipart 수신 + Gemini 호출 + finally 폐기
+
신규
+
+
+
환경설정
+
src/config/config.*.js
+
GEMINI_API_KEY, GEMINI_MODEL 추가
+
수정
+
+
+
의존성
+
package.json
+
@google/generative-ai 추가
+
수정
+
+
+
메뉴 등록
+
src/store/menuAtom.js (menusState)
+
{ type: 'pdf-import', name, icon } 항목
+
수정
+
+
+
툴바 분기
+
src/components/floor-plan/CanvasMenu.jsx (onClickNav)
+
case 'pdf-import' 추가
+
수정
+
+
+
업로드 모달
+
src/components/floor-plan/modal/pdfImport/PdfImportModal.jsx
+
파일 선택 / 진행 / 에러 상태머신
+
신규
+
+
+
캔버스 반영 hook
+
src/hooks/pdf-import/usePdfImport.js
+
좌표 검증 + 단위 변환 + 캔버스 반영
+
신규
+
+
+
다국어 메시지
+
메시지 키 추가 — pdf.import.*
+
ja / ko 양쪽
+
수정
+
+
+
아이콘
+
public/ 또는 기존 아이콘 디렉토리
+
PDF 임포트용 SVG 추가
+
신규
+
+
+
+ + +
+
+
05
+
+
Server Route · /api/gemini/floor-plan
+

서버 라우트 설계

+
+
+ +
+
+
5 · 1

입력 (Input)

+
    +
  • Content-Type · multipart/form-data
  • +
  • file · PDF (≤ 20MB, MIME application/pdf)
  • +
  • unitHint (옵션) · mm · auto
  • +
+
+ +
+
5 · 2

처리 (Pipeline)

+
    +
  • formData.get('file') → 사이즈 / MIME 검증
  • +
  • GoogleGenerativeAI(GEMINI_API_KEY) 클라이언트 생성
  • +
  • Gemini File API 업로드 → fileUri 획득
  • +
  • gemini-3.1-pro-preview 호출 · 외곽선 추출 프롬프트 + JSON 강제
  • +
  • 응답 JSON 파싱 + 좌표 검증 (vertex ≥ 3, 자기교차 / 0면적 거부)
  • +
  • finally · genai.files.delete(uploadedFile.name)
  • +
+
+ +
+
5 · 3

출력 (Output)

+
    +
  • 성공 · { outerline:[{x,y},…], unit:'mm', confidence }
  • +
  • 실패 · { error:{ code, message } }
  • +
  • 응답 스키마: { outerline, unit:'mm', scale?, confidence:0..1, notes? }
  • +
  • response_mime_type=application/json · response_schema 강제
  • +
+
+ +
+
5 · 4

보안 / 운영 (Ops)

+
    +
  • GEMINI_API_KEY 는 서버 env 만 사용 · 클라이언트 노출 금지
  • +
  • S3 등 영구 저장소에 절대 저장하지 않음 (REQ-04 폐기)
  • +
  • 로깅은 src/util/logger.js 만 사용
  • +
  • raw 응답 출력은 NEXT_PUBLIC_ENABLE_LOGGING=true 일 때만
  • +
+
+
+
+ + +
+
+
06
+
+
Client Flow
+

클라이언트 흐름

+
+
+ +
+
+

6 · 1모달 PdfImportModal

+
    +
  • 상태머신 · idle → uploading → analyzing → success | error
  • +
  • 호출 · fetch('/api/gemini/floor-plan', { method:'POST', body: formData })
  • +
  • 성공 시 usePdfImport.applyOuterline(points, unit) 호출 후 모달 닫기
  • +
  • 실패 시 useSwal 로 에러 메시지 표시
  • +
+
+
+

6 · 2캔버스 반영 hook usePdfImport

+
    +
  • 단위 변환 · unit === 'mm' → canvas px/mm 비율로 변환
  • +
  • 정규화 · 시계방향 정렬 + bounding box 중앙 정렬(translate)
  • +
  • 기존 외곽선 존재 시 useSwal confirm — 덮어쓰기 분기
  • +
  • 점 → name === 'outerLine' QLine 객체 add
  • +
  • setOuterLinePoints(points)addPolygonByLines(lines) 재사용
  • +
  • canvas.renderAll()
  • +
+
+
+
+ + +
+
+
07
+
+
Edge Cases
+

검증 / 엣지 케이스

+
+
+ +
+
PDF 가 아닌 파일
라우트에서 MIME 거부
+
평면도가 아닌 PDF
모델이 빈 outerline 반환 → 친화적 메시지
+
좌표 ≤ 2개 / 자기교차 / 0면적
라우트에서 거부 (SizeSetting 가드와 일관)
+
모델 JSON 파싱 실패
retry 1회 후 실패 메시지
+
Gemini 호출 실패
finally 에서 File API 삭제 보장
+
캔버스 기존 외곽선 존재
confirm 후 덮어쓰기
+
응답이 거대한 폴리곤(> 500 vertex)
Douglas–Peucker 단순화 또는 거부
+
업로드 파일 크기 초과
클라이언트 / 서버 양쪽 가드
+
네트워크 실패
모달에 재시도 버튼 + 파일 폐기 보장
+
+
+ + +
+
+
08
+
+
Conventions · Ops
+

컨벤션 / 운영

+
+
+ +
+
+

Formatting

+
    +
  • Prettier · single quote · no semicolons
  • +
  • tabWidth: 2 · printWidth: 150
  • +
  • 경로 alias @/... 우선 (상대경로 체이닝 지양)
  • +
+
+
+

Messaging / I18n

+
    +
  • 모든 알림 · 다이얼로그는 useSwal + useMessage
  • +
  • raw alert() 금지
  • +
  • 다국어 키 pdf.import.* ja / ko 양쪽 추가
  • +
+
+
+

Logging

+
    +
  • console.log 직접 사용 금지
  • +
  • src/util/logger.js · NEXT_PUBLIC_ENABLE_LOGGING 게이트
  • +
  • Gemini raw 응답은 로깅 플래그 ON 시에만 출력
  • +
+
+
+

Verification

+
    +
  • 마지막에 lint / type / build subagent 점검
  • +
  • dev 에서 실제 평면도 PDF 1~2건 manual test
  • +
+
+
+ +
+
[xxxx] feat(api) — Gemini 평면도 분석 라우트 신설
+
[xxxx] feat(floor-plan) — PDF 임포트 모달 / 툴바 진입
+
[xxxx] feat(floor-plan) — 분석 결과 외곽선 캔버스 반영
+
[xxxx] chore — 다국어 메시지 / lint 정리
+
+
+ + +
+
+
09
+
+
Work Order
+

작업 순서

+
+
+ +
+
+
@google/generative-ai 의존성 추가
+
GEMINI_API_KEY · GEMINI_MODEL env / config 매핑
+
+
+
서버 라우트 src/app/api/gemini/floor-plan/route.js 작성
+
Gemini 호출 + 파일 폐기 (try / finally)
+
+
+
menusStatepdf-import 항목 + PDF 아이콘 SVG 추가
+
기존 menusState 아이콘 컨벤션에 맞춤
+
+
+
CanvasMenu.jsx onClickNav switch 에 케이스 추가
+
modalAtom 으로 PdfImportModal 오픈
+
+
+
PdfImportModal.jsx 작성
+
idle → uploading → analyzing → success / error 상태머신
+
+
+
usePdfImport.js 작성
+
단위 / 방향 정규화 · 외곽선 덮어쓰기 분기 · QLine / QPolygon 반영
+
+
+
다국어 메시지 키 추가
+
pdf.import.* · ja / ko 양쪽
+
+
+
yarn lint + dev 환경 manual test
+
실제 평면도 PDF 1~2건으로 회귀 확인
+
+
+
의미 단위로 커밋
+
§ 8 의 commit 단위 표 참고
+
+
+
+ +
+
+ +
+ Q.CAST · Engineering Spec — floor-plan / pdf-import + worktree · claude/lucid-shaw-91e51f + 2026 · 05 · 18 +
+ + + + + From 51e6057c34f244e4460db44f696b6e9b3e6bcc6e Mon Sep 17 00:00:00 2001 From: sangwook yoo Date: Mon, 18 May 2026 16:15:12 +0900 Subject: [PATCH 28/38] chore(codex): Add PreToolUse hook for graphify search context Integrates a PreToolUse hook that provides context from the 'graphify' knowledge graph. When the AI is about to use search commands (e.g., `grep`, `find`, `ripgrep`), it will be prompted to consult `GRAPH_REPORT.md` if a graph exists, guiding it to leverage structured knowledge before searching raw files. --- .codex/hooks.json | 15 +++++++++++++++ 1 file changed, 15 insertions(+) create mode 100644 .codex/hooks.json diff --git a/.codex/hooks.json b/.codex/hooks.json new file mode 100644 index 00000000..6bf4e05e --- /dev/null +++ b/.codex/hooks.json @@ -0,0 +1,15 @@ +{ + "hooks": { + "PreToolUse": [ + { + "matcher": "Bash", + "hooks": [ + { + "type": "command", + "command": "CMD=$(python3 -c \"import json,sys; d=json.load(sys.stdin); print(d.get('tool_input',d).get('command',''))\" 2>/dev/null || true); set -- $CMD; case \"$1\" in grep|egrep|fgrep|rg|ripgrep|find|fd|ack|ag) [ -f graphify-out/graph.json ] && echo '{\"hookSpecificOutput\":{\"hookEventName\":\"PreToolUse\",\"additionalContext\":\"graphify: Knowledge graph exists. Read graphify-out/GRAPH_REPORT.md for god nodes and community structure before searching raw files.\"}}' || true ;; esac" + } + ] + } + ] + } +} From c1247a95db7e22a49e3354d12d2eee5886305559 Mon Sep 17 00:00:00 2001 From: sangwook yoo Date: Tue, 19 May 2026 16:20:48 +0900 Subject: [PATCH 29/38] Track docs markdown files --- .gitignore | 3 +- docs/PDF Gemini 평면도 분석 기능 구현 계획.md | 166 ++++++++++++++++++ 2 files changed, 168 insertions(+), 1 deletion(-) create mode 100644 docs/PDF Gemini 평면도 분석 기능 구현 계획.md diff --git a/.gitignore b/.gitignore index 7f720b6a..faa32996 100644 --- a/.gitignore +++ b/.gitignore @@ -57,11 +57,12 @@ sl graphify-out/ # [local-only 2026-05-08] 모든 markdown 문서는 git 추적 제외 (로컬 전용) -# 단, 루트 README.md / CLAUDE.md / AGENTS.md, .agents 및 .claude 하위의 md 는 추적 유지 +# 단, 루트 README.md / CLAUDE.md / AGENTS.md, docs, .agents 및 .claude 하위의 md 는 추적 유지 *.md !/README.md !/CLAUDE.md !/AGENTS.md +!/docs/**/*.md !.agents/**/*.md !.claude/**/*.md diff --git a/docs/PDF Gemini 평면도 분석 기능 구현 계획.md b/docs/PDF Gemini 평면도 분석 기능 구현 계획.md new file mode 100644 index 00000000..7a5db65a --- /dev/null +++ b/docs/PDF Gemini 평면도 분석 기능 구현 계획.md @@ -0,0 +1,166 @@ +# PDF 평면도 → Gemini 분석 → 캔버스 반영 구현 계획 + +> 작성일: 2026-05-18 +> 대상 브랜치: `claude/lucid-shaw-91e51f` (dev `f32f9187` 분기) +> 모델: `gemini-3.1-pro-preview` · 좌표 단위: `mm` · 진입 위치: floor-plan 상단 툴바 + +--- + +## 1. 기능 요구 사항 + +1. 기본 프로세스 상에서 특정 버튼 클릭으로 사용자가 PDF 문서를 업로드한다. +2. Gemini API 로 해당 문서의 내용 중 평면도를 분석한다. +3. 분석 내용을 응답으로 전달받고 현재 프로세스(캔버스)로 전달한다. +4. 분석이 성공적으로 마무리되면 업로드한 파일은 폐기 처리한다. + +--- + +## 2. 결정사항 + +| 항목 | 선택 | +|---|---| +| 워크트리 | 현재 워크트리(`lucid-shaw-91e51f`)에서 진행 | +| 캔버스 반영 형태 | 외곽선 폴리곤 좌표(JSON) | +| 버튼 위치 | floor-plan 상단 툴바 | +| API 호출 위치 | 서버 라우트 (`/api/gemini/floor-plan`) | +| Gemini 모델 | `gemini-3.1-pro-preview` | +| 좌표 단위 | `mm` | +| 툴바 아이콘 | 신규 PDF 아이콘 추가 | + +--- + +## 3. 전체 흐름 + +``` +[툴바 버튼] → [PdfImportModal] → POST /api/gemini/floor-plan (multipart) + ↓ + Gemini File API 업로드 + ↓ + 외곽선 좌표 JSON 응답 + ↓ + Gemini 파일 삭제 (finally) + ↓ + 응답 좌표 → 좌표 검증/정규화 → outerLinePointsState + QLine/QPolygon 생성 +``` + +--- + +## 4. 핵심 파일 (수정/신규) + +| 영역 | 경로 | 변경 | +|---|---|---| +| 서버 라우트 | `src/app/api/gemini/floor-plan/route.js` | **신규** — multipart 수신 + Gemini 호출 + finally 폐기 | +| 환경설정 | `src/config/config.*.js` | `GEMINI_API_KEY`, `GEMINI_MODEL` 추가 | +| 의존성 | `package.json` | `@google/generative-ai` 추가 | +| 메뉴 등록 | `src/store/menuAtom.js` (`menusState`) | `{ type: 'pdf-import', name, icon }` 항목 | +| 툴바 분기 | `src/components/floor-plan/CanvasMenu.jsx` (`onClickNav`) | `case 'pdf-import'` 추가 | +| 업로드 모달 | `src/components/floor-plan/modal/pdfImport/PdfImportModal.jsx` | **신규** — 파일 선택/진행/에러 | +| 캔버스 반영 hook | `src/hooks/pdf-import/usePdfImport.js` | **신규** — 좌표 검증 + 캔버스 반영 | +| 다국어 메시지 | 메시지 키 추가 | `pdf.import.*` (ja/ko) | +| 아이콘 | `public/` 또는 기존 아이콘 디렉토리 | PDF 임포트용 SVG 추가 | + +--- + +## 5. 서버 라우트 설계 (`/api/gemini/floor-plan`) + +### 5-1. 입력 +- `multipart/form-data` +- 필드: + - `file`: PDF (≤ 20MB, MIME `application/pdf`) + - `unitHint` (옵션): `mm` | `auto` + +### 5-2. 처리 단계 +1. `formData.get('file')` 로 PDF 수신 → 사이즈/MIME 검증 +2. `GoogleGenerativeAI(GEMINI_API_KEY)` 클라이언트 생성 +3. **Gemini File API 업로드** → `fileUri` 획득 +4. 모델 호출 (`gemini-3.1-pro-preview`) + - 프롬프트 핵심: + - 역할: "건축 평면도 외곽선 추출 전문가" + - 출력: JSON only, 단위 mm + - 스키마: `{ outerline: [{x,y}, ...], unit: 'mm', scale?: number, confidence: 0..1, notes?: string }` + - "평면도가 아니면 `{ outerline: [], notes: '...' }` 반환" + - `response_mime_type=application/json`, `response_schema` 강제 +5. JSON 파싱 → 좌표 검증 + - vertex ≥ 3 + - 자기교차 검출 (turf 활용) + - 0 면적 거부 +6. **finally** 블록에서 `genai.files.delete(uploadedFile.name)` — 성공/실패 무관 삭제 + +### 5-3. 출력 +- 성공: `{ outerline: [{x,y}, ...], unit: 'mm', confidence }` +- 실패: `{ error: { code, message } }` + +### 5-4. 보안 / 운영 +- `GEMINI_API_KEY` 는 서버 env 만 사용, 클라이언트 노출 금지 +- S3 등 영구 저장소에 절대 저장하지 않음 (요구 4번: 폐기) +- 로깅: `src/util/logger.js` 만 사용, raw 응답은 `NEXT_PUBLIC_ENABLE_LOGGING=true` 일 때만 출력 + +--- + +## 6. 클라이언트 흐름 + +### 6-1. 모달 (`PdfImportModal`) +- 상태머신: `idle → uploading → analyzing → success | error` +- 호출: `fetch('/api/gemini/floor-plan', { method: 'POST', body: formData })` +- 성공: `usePdfImport.applyOuterline(points, unit)` 호출 후 모달 닫기 +- 실패: `useSwal` 로 에러 메시지 표시 + +### 6-2. 캔버스 반영 hook (`usePdfImport`) +- **단위 변환**: `unit === 'mm'` 응답 → canvas px/mm 비율로 변환 +- **정규화**: 시계방향 정렬, bounding box 중앙 정렬(translate) +- **기존 외곽선 분기**: + - 존재 시 `useSwal` confirm: "기존 외곽선을 덮어쓰시겠습니까?" + - 빈 캔버스 시 즉시 적용 +- **적용**: + - 점들을 `name === 'outerLine'` QLine 객체들로 생성하여 캔버스에 add + - `setOuterLinePoints(points)` (recoil `outerLinePointsState`) + - `addPolygonByLines(lines)` 흐름으로 QPolygon 생성 (기존 `useRoofShapeSetting` 자산 재사용) + - `canvas.renderAll()` + +--- + +## 7. 검증 / 엣지 케이스 + +| 케이스 | 대응 | +|---|---| +| PDF 가 아닌 파일 | 라우트에서 MIME 거부 | +| 평면도가 아닌 PDF | 모델이 빈 outerline 반환 → 친화적 메시지 | +| 좌표 ≤ 2개 / 자기교차 / 0면적 | 라우트에서 거부 (`SizeSetting` 가드와 일관) | +| 모델 JSON 파싱 실패 | retry 1회 후 실패 메시지 | +| Gemini 호출 실패 | finally 에서 File API 삭제 보장 | +| 캔버스 기존 외곽선 존재 | confirm 후 덮어쓰기 | +| 응답이 거대한 폴리곤(>500 vertex) | Douglas–Peucker 단순화 또는 거부 | +| 업로드 파일 크기 초과 | 클라이언트/서버 양쪽 가드 | +| 네트워크 실패 | 모달에 재시도 버튼 + 파일 폐기 보장 | + +--- + +## 8. 컨벤션 / 운영 + +- Prettier: single quote, **no semicolons**, tabWidth 2, printWidth 150 +- 메시지/모달: `useSwal` + `useMessage` (raw `alert()` 금지) +- 다국어: `ja/ko` 양쪽 키 추가 (`pdf.import.*`) +- 경로 alias: `@/...` 우선 +- 로깅: `src/util/logger.js` 만 사용 + +### 커밋 단위 +1. `[xxxx] feat(api) — Gemini 평면도 분석 라우트 신설` +2. `[xxxx] feat(floor-plan) — PDF 임포트 모달/툴바 진입` +3. `[xxxx] feat(floor-plan) — 분석 결과 외곽선 캔버스 반영` +4. `[xxxx] chore — 다국어 메시지/lint 정리` + +마지막에 lint/type/build subagent 점검. + +--- + +## 9. 작업 순서 (To-Do) + +1. `@google/generative-ai` 의존성 추가, `GEMINI_API_KEY`/`GEMINI_MODEL` env·config 매핑 +2. 서버 라우트 `src/app/api/gemini/floor-plan/route.js` 작성 (Gemini 호출 + 파일 폐기) +3. `menusState` 에 `pdf-import` 항목 + PDF 아이콘 SVG 추가 +4. `CanvasMenu.jsx` `onClickNav` switch 에 케이스 추가, `modalAtom` 으로 모달 오픈 +5. `PdfImportModal.jsx` 작성 (idle→uploading→analyzing→success/error 상태머신) +6. `usePdfImport.js` 작성 (단위/방향 정규화, 외곽선 덮어쓰기 분기, QLine/QPolygon 반영) +7. 다국어 메시지 키 추가 (ja/ko) +8. `yarn lint` + dev에서 실제 PDF 한두 건 manual test +9. 의미 단위로 커밋 From 849f7a1dc921750f681ba1770a1c1afae1875264 Mon Sep 17 00:00:00 2001 From: sangwook yoo Date: Wed, 20 May 2026 09:39:41 +0900 Subject: [PATCH 30/38] =?UTF-8?q?feat(pdf-gemini):=20=EB=B0=B0=EC=B9=98?= =?UTF-8?q?=EB=A9=B4=20=EC=B4=88=EA=B8=B0=EC=84=A4=EC=A0=95=EC=97=90=20PDF?= =?UTF-8?q?=20=EB=8F=84=EB=A9=B4=20=EC=97=85=EB=A1=9C=EB=93=9C=20=EC=98=B5?= =?UTF-8?q?=EC=85=98=20=EC=B6=94=EA=B0=80=20=E2=80=94=20Gemini=20=EC=99=B8?= =?UTF-8?q?=EA=B3=BD=EC=84=A0=20=EB=B6=84=EC=84=9D?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 「배치면 초기설정」 모달 寸法入力方法 라디오에 '도면 파일 업로드' 옵션 추가 - 선택 시 PDF 파일 업로드 + 읽기 페이지 설정(전체/입면도·평면도 지정) UI 노출 - 保存 클릭 시 /api/gemini/floor-plan 호출 → 외곽선을 캔버스에 반영한 후 기존 저장 흐름 진행 - Gemini File API 업로드 + finally 파일 폐기, 정점/면적 검증, 페이지 힌트 프롬프트 주입 - usePdfImport 훅: mm→canvas 좌표 변환, CCW 정규화, 기존 외곽선 덮어쓰기 confirm 후 QLine/QPolygon 생성 - @google/generative-ai 의존성 추가, GEMINI_MODEL env 4개 환경에 동기화 - 다국어(ja/ko) 메시지 추가, .playwright-mcp/ gitignore Co-Authored-By: Claude Opus 4.7 (1M context) --- .env.development | 3 +- .env.local.dev | 6 +- .env.localhost | 4 + .env.production | 3 +- .gitignore | 3 + package.json | 1 + src/app/api/gemini/floor-plan/route.js | 210 ++ .../placementShape/PlacementShapeSetting.jsx | 275 +- src/hooks/pdf-import/usePdfImport.js | 176 + src/locales/ja.json | 27 + src/locales/ko.json | 27 + src/styles/_contents.scss | 2960 +++++++++-------- 12 files changed, 2219 insertions(+), 1476 deletions(-) create mode 100644 src/app/api/gemini/floor-plan/route.js create mode 100644 src/hooks/pdf-import/usePdfImport.js diff --git a/.env.development b/.env.development index 8f653e38..6b3844f8 100644 --- a/.env.development +++ b/.env.development @@ -36,4 +36,5 @@ S3_PROFILE="dev" NEXT_PUBLIC_ENABLE_LOGGING=true #Gemini API Key -GEMINI_API_KEY="AIzaSyDDmMkTkNt12QTj7I3SUygYkOoeG_Sc28Q" \ No newline at end of file +GEMINI_API_KEY="AIzaSyDDmMkTkNt12QTj7I3SUygYkOoeG_Sc28Q" +GEMINI_MODEL="gemini-3.1-pro-preview" \ No newline at end of file diff --git a/.env.local.dev b/.env.local.dev index ab789dbf..a53524ca 100644 --- a/.env.local.dev +++ b/.env.local.dev @@ -29,4 +29,8 @@ AWS_ACCESS_KEY_ID="AKIA3K4QWLZHFZRJOM2E" AWS_SECRET_ACCESS_KEY="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" NEXT_PUBLIC_AWS_S3_BASE_URL="//files.hanasys.jp" -S3_PROFILE="dev" \ No newline at end of file +S3_PROFILE="dev" + +#Gemini API Key +GEMINI_API_KEY="AIzaSyDDmMkTkNt12QTj7I3SUygYkOoeG_Sc28Q" +GEMINI_MODEL="gemini-3.1-pro-preview" \ No newline at end of file diff --git a/.env.localhost b/.env.localhost index 3532ed1f..7cab2757 100644 --- a/.env.localhost +++ b/.env.localhost @@ -35,3 +35,7 @@ S3_PROFILE="dev" #logging # [debug auto-capture 2026-05-06] true 로 켜면 debugCapture 가 debug/*.json/*.log 로 자동 저장. NEXT_PUBLIC_ENABLE_LOGGING=true + +#Gemini API Key +GEMINI_API_KEY="AIzaSyDDmMkTkNt12QTj7I3SUygYkOoeG_Sc28Q" +GEMINI_MODEL="gemini-3.1-pro-preview" diff --git a/.env.production b/.env.production index a436dafe..50904369 100644 --- a/.env.production +++ b/.env.production @@ -35,4 +35,5 @@ S3_PROFILE="prd" NEXT_PUBLIC_ENABLE_LOGGING=false #Gemini API Key -GEMINI_API_KEY="AIzaSyDDmMkTkNt12QTj7I3SUygYkOoeG_Sc28Q" \ No newline at end of file +GEMINI_API_KEY="AIzaSyDDmMkTkNt12QTj7I3SUygYkOoeG_Sc28Q" +GEMINI_MODEL="gemini-3.1-pro-preview" \ No newline at end of file diff --git a/.gitignore b/.gitignore index faa32996..7a1234d4 100644 --- a/.gitignore +++ b/.gitignore @@ -56,6 +56,9 @@ sl # [graphify 2026-05-12] graphify 지식 그래프 산출물 (로컬 캐시, post-commit hook 으로 자동 갱신) graphify-out/ +# [playwright-mcp] Playwright MCP 임시 산출물 (스크린샷, 콘솔 로그) +.playwright-mcp/ + # [local-only 2026-05-08] 모든 markdown 문서는 git 추적 제외 (로컬 전용) # 단, 루트 README.md / CLAUDE.md / AGENTS.md, docs, .agents 및 .claude 하위의 md 는 추적 유지 *.md diff --git a/package.json b/package.json index 1f96d08c..e21464e5 100644 --- a/package.json +++ b/package.json @@ -19,6 +19,7 @@ }, "dependencies": { "@aws-sdk/client-s3": "^3.772.0", + "@google/generative-ai": "^0.24.1", "ag-grid-react": "^32.0.2", "axios": "^1.7.8", "big.js": "^6.2.2", diff --git a/src/app/api/gemini/floor-plan/route.js b/src/app/api/gemini/floor-plan/route.js new file mode 100644 index 00000000..0d34a90e --- /dev/null +++ b/src/app/api/gemini/floor-plan/route.js @@ -0,0 +1,210 @@ +import { NextResponse } from 'next/server' +import { GoogleGenerativeAI, SchemaType } from '@google/generative-ai' +import { GoogleAIFileManager, FileState } from '@google/generative-ai/server' + +import { logger } from '@/util/logger' + +const MAX_FILE_BYTES = 20 * 1024 * 1024 // 20MB +const MAX_VERTICES = 500 +const DEFAULT_MODEL = 'gemini-3.1-pro-preview' + +const FLOOR_PLAN_SCHEMA = { + type: SchemaType.OBJECT, + properties: { + outerline: { + type: SchemaType.ARRAY, + items: { + type: SchemaType.OBJECT, + properties: { + x: { type: SchemaType.NUMBER }, + y: { type: SchemaType.NUMBER }, + }, + required: ['x', 'y'], + }, + }, + unit: { type: SchemaType.STRING }, + scale: { type: SchemaType.NUMBER }, + confidence: { type: SchemaType.NUMBER }, + notes: { type: SchemaType.STRING }, + }, + required: ['outerline', 'unit'], +} + +const BASE_PROMPT = `당신은 건축 평면도(주택 도면)의 외곽선 추출 전문가입니다. +첨부된 PDF에서 건물의 외곽(외벽선) 폴리곤 좌표를 추출하여 JSON 으로만 응답하세요. + +규칙: +1. 모든 좌표는 밀리미터(mm) 단위의 실측 치수로 환산해야 합니다. 도면의 축척(scale)을 식별하여 적용하세요. +2. 좌표계는 도면의 좌상단을 원점으로 하고, x 는 우측 양수, y 는 하단 양수로 합니다. +3. 외곽선은 시계 반대 방향(CCW) 으로 정렬된 정점 배열로 반환합니다. +4. 자기교차가 없는 단순 폴리곤이어야 하며, 정점은 3개 이상이어야 합니다. +5. PDF 가 평면도가 아니거나 외곽을 추출할 수 없으면 outerline 을 빈 배열로 반환하고 notes 에 사유를 적습니다. +6. 응답은 반드시 단일 JSON 객체만 포함하며, 다른 텍스트, 마크다운, 코드펜스를 포함하지 마세요. +7. 좌표는 정수 또는 소수 첫째 자리까지 허용합니다. + +스키마: +{ + "outerline": [{"x": number, "y": number}, ...], + "unit": "mm", + "scale": number (선택, 도면 1mm 당 실제 mm), + "confidence": number (0~1), + "notes": string (선택) +}` + +const buildPrompt = ({ pageMode, facadePages, floorPages } = {}) => { + if (pageMode === 'specify' && (facadePages || floorPages)) { + const hints = [] + if (floorPages) hints.push(`평면도(平面図) 페이지: ${floorPages}`) + if (facadePages) hints.push(`입면도(立面図) 페이지: ${facadePages}`) + return `${BASE_PROMPT}\n\n페이지 힌트: ${hints.join(' / ')}. 외곽선은 위에서 지정한 평면도 페이지 기준으로 추출하세요.` + } + return BASE_PROMPT +} + +const validateOuterline = (outerline) => { + if (!Array.isArray(outerline)) { + return { ok: false, code: 'INVALID_FORMAT', message: 'outerline 이 배열이 아닙니다.' } + } + if (outerline.length === 0) { + return { ok: true, empty: true } + } + if (outerline.length < 3) { + return { ok: false, code: 'TOO_FEW_VERTICES', message: '정점이 3개 미만입니다.' } + } + if (outerline.length > MAX_VERTICES) { + return { ok: false, code: 'TOO_MANY_VERTICES', message: `정점이 ${MAX_VERTICES} 개를 초과합니다.` } + } + for (const p of outerline) { + if (typeof p?.x !== 'number' || typeof p?.y !== 'number' || !Number.isFinite(p.x) || !Number.isFinite(p.y)) { + return { ok: false, code: 'INVALID_VERTEX', message: '정점에 숫자가 아닌 좌표가 포함되어 있습니다.' } + } + } + // 면적 0 거부 (signed area) + let area = 0 + for (let i = 0; i < outerline.length; i++) { + const cur = outerline[i] + const next = outerline[(i + 1) % outerline.length] + area += cur.x * next.y - next.x * cur.y + } + if (Math.abs(area) < 1) { + return { ok: false, code: 'ZERO_AREA', message: '폴리곤 면적이 0 입니다.' } + } + return { ok: true, empty: false } +} + +const waitForFileActive = async (fileManager, fileName, { timeoutMs = 60000, intervalMs = 1500 } = {}) => { + const started = Date.now() + let file = await fileManager.getFile(fileName) + while (file.state === FileState.PROCESSING) { + if (Date.now() - started > timeoutMs) { + throw new Error('Gemini 파일 처리 타임아웃') + } + await new Promise((r) => setTimeout(r, intervalMs)) + file = await fileManager.getFile(fileName) + } + if (file.state !== FileState.ACTIVE) { + throw new Error(`Gemini 파일 처리 실패: ${file.state}`) + } + return file +} + +export async function POST(req) { + const apiKey = process.env.GEMINI_API_KEY + const modelName = process.env.GEMINI_MODEL || DEFAULT_MODEL + + if (!apiKey) { + return NextResponse.json({ error: { code: 'NO_API_KEY', message: 'GEMINI_API_KEY 가 설정되지 않았습니다.' } }, { status: 500 }) + } + + let uploadedFileName = null + const fileManager = new GoogleAIFileManager(apiKey) + + try { + const formData = await req.formData() + const file = formData.get('file') + + if (!file || typeof file === 'string') { + return NextResponse.json({ error: { code: 'NO_FILE', message: '파일이 전달되지 않았습니다.' } }, { status: 400 }) + } + if (file.type !== 'application/pdf') { + return NextResponse.json({ error: { code: 'INVALID_MIME', message: 'PDF 파일만 업로드할 수 있습니다.' } }, { status: 400 }) + } + if (file.size > MAX_FILE_BYTES) { + return NextResponse.json( + { error: { code: 'FILE_TOO_LARGE', message: `파일 크기가 ${Math.round(MAX_FILE_BYTES / 1024 / 1024)}MB 를 초과합니다.` } }, + { status: 400 }, + ) + } + + const pageMode = formData.get('pageMode') || 'all' + const facadePages = formData.get('facadePages') || '' + const floorPages = formData.get('floorPages') || '' + + const buffer = Buffer.from(await file.arrayBuffer()) + + const uploadResult = await fileManager.uploadFile(buffer, { + mimeType: 'application/pdf', + displayName: file.name || 'floor-plan.pdf', + }) + uploadedFileName = uploadResult.file.name + + await waitForFileActive(fileManager, uploadedFileName) + + const genAI = new GoogleGenerativeAI(apiKey) + const model = genAI.getGenerativeModel({ + model: modelName, + generationConfig: { + responseMimeType: 'application/json', + responseSchema: FLOOR_PLAN_SCHEMA, + temperature: 0.1, + }, + }) + + const prompt = buildPrompt({ pageMode, facadePages, floorPages }) + + const result = await model.generateContent([ + { + fileData: { + fileUri: uploadResult.file.uri, + mimeType: uploadResult.file.mimeType, + }, + }, + { text: prompt }, + ]) + + const raw = result.response.text() + logger.debug('[gemini/floor-plan] raw response', raw) + + let parsed + try { + parsed = JSON.parse(raw) + } catch (e) { + return NextResponse.json({ error: { code: 'PARSE_FAILED', message: '응답 JSON 파싱 실패' } }, { status: 502 }) + } + + const validation = validateOuterline(parsed.outerline) + if (!validation.ok) { + return NextResponse.json({ error: { code: validation.code, message: validation.message } }, { status: 422 }) + } + + return NextResponse.json({ + outerline: parsed.outerline, + unit: parsed.unit || 'mm', + scale: parsed.scale ?? null, + confidence: parsed.confidence ?? null, + notes: parsed.notes ?? null, + empty: validation.empty, + }) + } catch (error) { + logger.error('[gemini/floor-plan] error', error?.message || error) + return NextResponse.json({ error: { code: 'GEMINI_FAILED', message: error?.message || 'Gemini 호출에 실패했습니다.' } }, { status: 500 }) + } finally { + if (uploadedFileName) { + try { + await fileManager.deleteFile(uploadedFileName) + } catch (deleteError) { + logger.warn('[gemini/floor-plan] deleteFile failed', deleteError?.message || deleteError) + } + } + } +} diff --git a/src/components/floor-plan/modal/placementShape/PlacementShapeSetting.jsx b/src/components/floor-plan/modal/placementShape/PlacementShapeSetting.jsx index ee75ea20..fc4f9e08 100644 --- a/src/components/floor-plan/modal/placementShape/PlacementShapeSetting.jsx +++ b/src/components/floor-plan/modal/placementShape/PlacementShapeSetting.jsx @@ -23,12 +23,27 @@ import { useRoofFn } from '@/hooks/common/useRoofFn' import { usePlan } from '@/hooks/usePlan' import { normalizeDecimal } from '@/util/input-utils' import { CalculatorInput } from '@/components/common/input/CalcInput' +import { usePdfImport } from '@/hooks/pdf-import/usePdfImport' +import { logger } from '@/util/logger' // [LOW-PITCH-WARN 2026-05-06] 저구배 + 특정 기와 사용 시 시공 매뉴얼 안내 alert import { useSwal } from '@/hooks/useSwal' import { notifyLowPitchRestrictionForRoofs, isLowPitchRestricted, LOW_PITCH_RESTRICTED_ROOF_IDS } from '@/util/roof-pitch-warning' // [LOW-PITCH-DIAG 2026-05-06 TEMP] 진단용 — 검증 후 import + 호출 함께 제거 import { debugCapture } from '@/util/debugCapture' +const INPUT_MODE = { + SIZE_ROOF: '1', + SIZE_ACTUAL: '2', + PDF: 'pdf', +} + +const PDF_MAX_FILE_BYTES = 20 * 1024 * 1024 + +const PDF_PAGE_MODE = { + ALL: 'all', + SPECIFY: 'specify', +} + /** * 지붕 레이아웃 */ @@ -76,15 +91,112 @@ export default function PlacementShapeSetting({ id, pos = { x: 50, y: 180 }, pla const { saveCanvas } = usePlan() // [LOW-PITCH-WARN 2026-05-06] const { swalFire } = useSwal() + const { applyOuterline } = usePdfImport() + + // PDF 업로드 관련 상태 + const [inputMode, setInputMode] = useState(INPUT_MODE.SIZE_ROOF) + const [pdfFile, setPdfFile] = useState(null) + const [pdfPageMode, setPdfPageMode] = useState(PDF_PAGE_MODE.ALL) + const [pdfFacadePages, setPdfFacadePages] = useState('') + const [pdfFloorPages, setPdfFloorPages] = useState('') + const [pdfAnalyzing, setPdfAnalyzing] = useState(false) + const pdfInputRef = useRef(null) /** - * 치수 입력방법(복시도입력/실측값입력/육지붕) + * 치수 입력방법(복시도입력/실측값입력/도면 파일 업로드) */ const roofSizeSetArray = [ - { id: 'ra01', name: 'roofSizeSet', value: '1', message: 'modal.placement.initial.setting.size.roof' }, - { id: 'ra02', name: 'roofSizeSet', value: '2', message: 'modal.placement.initial.setting.size.actual' }, - // { id: 'ra03', name: 'roofSizeSet', value: '3', message: 'modal.placement.initial.setting.size.none.pitch' }, + { id: 'ra01', name: 'roofSizeSet', value: INPUT_MODE.SIZE_ROOF, message: 'modal.placement.initial.setting.size.roof' }, + { id: 'ra02', name: 'roofSizeSet', value: INPUT_MODE.SIZE_ACTUAL, message: 'modal.placement.initial.setting.size.actual' }, + { id: 'ra-pdf', name: 'roofSizeSet', value: INPUT_MODE.PDF, message: 'modal.placement.initial.setting.size.pdf' }, ] + const handleInputModeChange = (value) => { + setInputMode(value) + if (value === INPUT_MODE.PDF) { + // PDF 모드는 내부적으로 복시도(1) 로 저장된다. + setCurrentRoof((prev) => ({ ...prev, roofSizeSet: INPUT_MODE.SIZE_ROOF })) + } else { + setCurrentRoof((prev) => ({ ...prev, roofSizeSet: value })) + } + } + + const resetPdfFile = () => { + setPdfFile(null) + if (pdfInputRef.current) pdfInputRef.current.value = '' + } + + const handlePdfFileChange = (e) => { + const next = e.target.files?.[0] + if (!next) return + if (next.type !== 'application/pdf') { + swalFire({ text: getMessage('modal.placement.initial.setting.size.pdf.error.mime'), type: 'alert' }) + e.target.value = '' + return + } + if (next.size > PDF_MAX_FILE_BYTES) { + swalFire({ text: getMessage('modal.placement.initial.setting.size.pdf.error.too.large'), type: 'alert' }) + e.target.value = '' + return + } + setPdfFile(next) + } + + const analyzePdfAndApply = async () => { + if (!pdfFile) { + swalFire({ text: getMessage('modal.placement.initial.setting.size.pdf.error.no.file'), type: 'alert' }) + return false + } + + const formData = new FormData() + formData.append('file', pdfFile) + formData.append('unitHint', 'mm') + formData.append('pageMode', pdfPageMode) + if (pdfPageMode === PDF_PAGE_MODE.SPECIFY) { + formData.append('facadePages', pdfFacadePages.trim()) + formData.append('floorPages', pdfFloorPages.trim()) + } + + setPdfAnalyzing(true) + let response + try { + response = await fetch('/api/gemini/floor-plan', { method: 'POST', body: formData }) + } catch (e) { + logger.error('[PlacementShapeSetting] PDF analyze fetch failed', e?.message || e) + setPdfAnalyzing(false) + swalFire({ text: getMessage('pdf.import.error.network'), type: 'alert' }) + return false + } + + let payload + try { + payload = await response.json() + } catch (e) { + logger.error('[PlacementShapeSetting] PDF analyze response parse failed', e?.message || e) + setPdfAnalyzing(false) + swalFire({ text: getMessage('pdf.import.error.analyze'), type: 'alert' }) + return false + } + + setPdfAnalyzing(false) + + if (!response.ok) { + const msg = payload?.error?.message || getMessage('pdf.import.error.analyze') + swalFire({ text: msg, type: 'alert' }) + return false + } + if (payload.empty || !Array.isArray(payload.outerline) || payload.outerline.length === 0) { + swalFire({ text: payload.notes || getMessage('pdf.import.error.no.floorplan'), type: 'alert' }) + return false + } + + const applied = await applyOuterline(payload.outerline, { unit: payload.unit || 'mm' }) + if (!applied) { + swalFire({ text: getMessage('pdf.import.error.apply'), type: 'alert' }) + return false + } + return true + } + /** * 지붕각도 설정(경사/각도) */ @@ -138,7 +250,7 @@ export default function PlacementShapeSetting({ id, pos = { x: 50, y: 180 }, pla */ useEffect(() => { if (currentCanvasPlan?.planNo && currentRoof) { - setCurrentRoof(prev => ({ ...prev, planNo: currentCanvasPlan.planNo })) + setCurrentRoof((prev) => ({ ...prev, planNo: currentCanvasPlan.planNo })) } }, [currentCanvasPlan?.planNo]) @@ -238,6 +350,13 @@ export default function PlacementShapeSetting({ id, pos = { x: 50, y: 180 }, pla * 배치면초기설정 저장 버튼 클릭 */ const handleSaveBtn = async () => { + if (pdfAnalyzing) return + + if (inputMode === INPUT_MODE.PDF) { + const ok = await analyzePdfAndApply() + if (!ok) return + } + const roofInfo = { ...currentRoof, planNo: currentCanvasPlan?.planNo || basicSetting.planNo, @@ -273,16 +392,19 @@ export default function PlacementShapeSetting({ id, pos = { x: 50, y: 180 }, pla /** * 배치면초기설정 저장 (메뉴 변경/useEffect 트리거 없이) */ - basicSettingSave({ - ...basicSetting, - planNo: currentCanvasPlan?.planNo || basicSetting.planNo, - /** - * 선택된 지붕재 정보 - */ - selectedRoofMaterial: { - ...newAddedRoofs[0], + basicSettingSave( + { + ...basicSetting, + planNo: currentCanvasPlan?.planNo || basicSetting.planNo, + /** + * 선택된 지붕재 정보 + */ + selectedRoofMaterial: { + ...newAddedRoofs[0], + }, }, - }, { skipSideEffects: true }) + { skipSideEffects: true }, + ) const roofs = canvas.getObjects().filter((obj) => obj.roofMaterial?.index === 0) @@ -297,7 +419,7 @@ export default function PlacementShapeSetting({ id, pos = { x: 50, y: 180 }, pla /** 지붕면 존재 여부에 따라 메뉴 설정 */ const hasRoofs = canvas.getObjects().some((obj) => obj.name === POLYGON_TYPE.ROOF) - + // roofSizeSet에 따라 메뉴 설정 if (currentRoof?.roofSizeSet === '2') { setSelectedMenu('surface') @@ -354,15 +476,119 @@ export default function PlacementShapeSetting({ id, pos = { x: 50, y: 180 }, pla id={item.id} name={item.name} value={item.value} - checked={String(currentRoof?.roofSizeSet) === item.value} - onChange={(e) => setCurrentRoof({ ...currentRoof, roofSizeSet: e.target.value })} + checked={inputMode === item.value} + onChange={(e) => handleInputModeChange(e.target.value)} /> ))} + {inputMode === INPUT_MODE.PDF && ( +
+ {getMessage('modal.placement.initial.setting.size.pdf.info')} +
+ )} + {inputMode === INPUT_MODE.PDF && ( + <> + + {getMessage('modal.placement.initial.setting.size.pdf.file.label')} + +
+
+ + +
+
+ + {pdfFile && !pdfAnalyzing && } +
+
+
+ {getMessage('modal.placement.initial.setting.size.pdf.file.info')} +
+ + + + {getMessage('modal.placement.initial.setting.size.pdf.page.label')} + +
+
+ setPdfPageMode(e.target.value)} + /> + +
+
+ setPdfPageMode(e.target.value)} + /> + +
+ {pdfPageMode === PDF_PAGE_MODE.SPECIFY && ( + <> + {getMessage('modal.placement.initial.setting.size.pdf.page.facade.label')} + setPdfFacadePages(e.target.value)} + /> + {getMessage('modal.placement.initial.setting.size.pdf.page.floor.label')} + setPdfFloorPages(e.target.value)} + /> + + )} +
+
+ {pdfPageMode === PDF_PAGE_MODE.SPECIFY + ? getMessage('modal.placement.initial.setting.size.pdf.page.specify.info') + : getMessage('modal.placement.initial.setting.size.pdf.page.all.info')} +
+ + + + )}
@@ -630,10 +856,19 @@ export default function PlacementShapeSetting({ id, pos = { x: 50, y: 180 }, pla
-
diff --git a/src/hooks/pdf-import/usePdfImport.js b/src/hooks/pdf-import/usePdfImport.js new file mode 100644 index 00000000..01fa4fb2 --- /dev/null +++ b/src/hooks/pdf-import/usePdfImport.js @@ -0,0 +1,176 @@ +import { useSetRecoilState, useRecoilValue } from 'recoil' +import { canvasState } from '@/store/canvasAtom' +import { outerLinePointsState } from '@/store/outerLineAtom' +import { POLYGON_TYPE } from '@/common/common' +import { QLine } from '@/components/fabric/QLine' +import { usePolygon } from '@/hooks/usePolygon' +import { useSwal } from '@/hooks/useSwal' +import { useMessage } from '@/hooks/useMessage' +import { logger } from '@/util/logger' + +const MM_PER_CANVAS_UNIT = 10 +const MAX_DIM = 1500 +const MIN_CANVAS_UNIT_LENGTH = 0.5 + +const toCanvasUnits = (mmPoints) => mmPoints.map((p) => ({ x: p.x / MM_PER_CANVAS_UNIT, y: p.y / MM_PER_CANVAS_UNIT })) + +const polygonArea = (points) => { + let area = 0 + for (let i = 0; i < points.length; i++) { + const cur = points[i] + const next = points[(i + 1) % points.length] + area += cur.x * next.y - next.x * cur.y + } + return area +} + +const ensureCounterClockwise = (points) => { + // canvas 좌표계 (y 가 아래로 증가) 에서 CCW 는 signed area 가 음수가 된다. + return polygonArea(points) > 0 ? [...points].reverse() : points +} + +const fitAndCenter = (points, canvas) => { + const xs = points.map((p) => p.x) + const ys = points.map((p) => p.y) + const minX = Math.min(...xs) + const minY = Math.min(...ys) + const maxX = Math.max(...xs) + const maxY = Math.max(...ys) + const width = maxX - minX + const height = maxY - minY + + const targetWidth = (canvas?.getWidth?.() ?? 1000) * 0.6 + const targetHeight = (canvas?.getHeight?.() ?? 1000) * 0.6 + const maxAllowed = Math.min(MAX_DIM, Math.max(targetWidth, targetHeight)) + + let scale = 1 + if (width > maxAllowed || height > maxAllowed) { + scale = Math.min(maxAllowed / width, maxAllowed / height) + } + + const cx = (canvas?.getWidth?.() ?? 1000) / 2 + const cy = (canvas?.getHeight?.() ?? 1000) / 2 + + return points.map((p) => ({ + x: cx + (p.x - (minX + width / 2)) * scale, + y: cy + (p.y - (minY + height / 2)) * scale, + })) +} + +export function usePdfImport() { + const canvas = useRecoilValue(canvasState) + const setOuterLinePoints = useSetRecoilState(outerLinePointsState) + const { addPolygonByLines } = usePolygon() + const { swalFire } = useSwal() + const { getMessage } = useMessage() + + const hasExistingOuterLine = () => { + if (!canvas) return false + return canvas.getObjects().some((obj) => obj.name === 'outerLine' || obj.name === POLYGON_TYPE.WALL || obj.name === POLYGON_TYPE.ROOF) + } + + const clearExistingCanvasState = () => { + if (!canvas) return + const removable = canvas.getObjects().filter((obj) => { + return ( + obj.name === 'outerLine' || + obj.name === 'outerLinePoint' || + obj.name === 'startPoint' || + obj.name === 'helpGuideLine' || + obj.name === 'lengthText' || + obj.name === 'pitchText' || + obj.name === POLYGON_TYPE.WALL || + obj.name === POLYGON_TYPE.ROOF + ) + }) + removable.forEach((obj) => canvas.remove(obj)) + } + + const drawOuterLines = (points) => { + points.forEach((point, idx) => { + if (idx === 0) return + const prev = points[idx - 1] + const line = new QLine([prev.x, prev.y, point.x, point.y], { + stroke: '#000000', + strokeWidth: 3, + idx, + selectable: true, + name: 'outerLine', + }) + canvas.add(line) + }) + } + + const applyOuterline = async (mmPoints, options = {}) => { + if (!canvas) { + logger.warn('[usePdfImport] canvas 가 준비되지 않았습니다.') + return false + } + + const { unit = 'mm' } = options + if (unit !== 'mm') { + logger.warn('[usePdfImport] 지원하지 않는 단위:', unit) + swalFire({ text: getMessage('pdf.import.error.unsupported.unit'), type: 'alert' }) + return false + } + + if (!Array.isArray(mmPoints) || mmPoints.length < 3) { + swalFire({ text: getMessage('pdf.import.error.too.few.vertices'), type: 'alert' }) + return false + } + + let canvasPoints = toCanvasUnits(mmPoints) + canvasPoints = canvasPoints.filter((p, idx, arr) => { + if (idx === 0) return true + const prev = arr[idx - 1] + const dx = p.x - prev.x + const dy = p.y - prev.y + return Math.hypot(dx, dy) >= MIN_CANVAS_UNIT_LENGTH + }) + + if (canvasPoints.length < 3) { + swalFire({ text: getMessage('pdf.import.error.too.few.vertices'), type: 'alert' }) + return false + } + + canvasPoints = fitAndCenter(canvasPoints, canvas) + canvasPoints = ensureCounterClockwise(canvasPoints) + + const proceed = async () => { + clearExistingCanvasState() + const closedPoints = [...canvasPoints, canvasPoints[0]] + setOuterLinePoints(closedPoints) + drawOuterLines(closedPoints) + + try { + const lines = canvas.getObjects().filter((obj) => obj.name === 'outerLine') + if (lines.length >= 3) { + addPolygonByLines(lines, { name: POLYGON_TYPE.WALL, fill: 'transparent', stroke: 'black' }) + } + } catch (e) { + logger.warn('[usePdfImport] wall polygon 생성 실패', e?.message || e) + } + + canvas.renderAll() + } + + if (hasExistingOuterLine()) { + return await new Promise((resolve) => { + swalFire({ + text: getMessage('pdf.import.confirm.overwrite'), + type: 'confirm', + confirmFn: async () => { + await proceed() + resolve(true) + }, + denyFn: () => resolve(false), + }) + }) + } + + await proceed() + return true + } + + return { applyOuterline, hasExistingOuterLine } +} diff --git a/src/locales/ja.json b/src/locales/ja.json index ba535999..88e88a2a 100644 --- a/src/locales/ja.json +++ b/src/locales/ja.json @@ -16,6 +16,33 @@ "header.stem": "Stem", "plan.menu.plan.drawing": "物件情報", "plan.menu.placement.surface.initial.setting": "配置面初期設定", + "pdf.import.button.analyzing": "解析中...", + "pdf.import.success": "外壁線をキャンバスに反映しました。", + "pdf.import.confirm.overwrite": "既存の外壁線があります。新しい外壁線で上書きしますか?", + "pdf.import.error.network": "ネットワークエラーで解析リクエストに失敗しました。", + "pdf.import.error.analyze": "PDF 解析に失敗しました。", + "pdf.import.error.no.floorplan": "平面図を認識できませんでした。別の PDF で再度お試しください。", + "pdf.import.error.too.few.vertices": "外壁線の頂点が不足しています。別の PDF で再度お試しください。", + "pdf.import.error.unsupported.unit": "未対応の座標単位です。", + "pdf.import.error.apply": "解析結果をキャンバスに反映できませんでした。", + "modal.placement.initial.setting.size.pdf": "図面ファイルアップロード", + "modal.placement.initial.setting.size.pdf.info": "住宅図面ファイルをアップロード後、全ページ解析または立面図·平面図ページ指定方式で外壁線と屋根形状を作成できます。\n住宅図面ファイルアップロードによる自動図面作成は、必ずしも正確な結果を保証しません。\n見積作成後、必ず住宅図面と出力結果が一致するか確認してください。", + "modal.placement.initial.setting.size.pdf.file.label": "住宅図面ファイルアップロード", + "modal.placement.initial.setting.size.pdf.file.upload": "アップロード", + "modal.placement.initial.setting.size.pdf.file.placeholder": "ファイルをドラッグまたはクリックでアップロード (.PDF)", + "modal.placement.initial.setting.size.pdf.file.info": "住宅図面の解析結果に応じて外壁線と屋根形状が一緒に生成されます。屋根形状を認識できない場合は外壁線のみ生成され、屋根面は手動で作成します。", + "modal.placement.initial.setting.size.pdf.page.label": "読み込みページ設定", + "modal.placement.initial.setting.size.pdf.page.all": "全ページ", + "modal.placement.initial.setting.size.pdf.page.specify": "ページ指定", + "modal.placement.initial.setting.size.pdf.page.facade.label": "立面図", + "modal.placement.initial.setting.size.pdf.page.floor.label": "平面図", + "modal.placement.initial.setting.size.pdf.page.example.facade": "例) 1, 2", + "modal.placement.initial.setting.size.pdf.page.example.floor": "例) 3, 4", + "modal.placement.initial.setting.size.pdf.page.all.info": "全ページを解析するため処理時間が長くなる場合があります。図面内の立面図·平面図ページが特定できる場合はページ指定を推奨します。", + "modal.placement.initial.setting.size.pdf.page.specify.info": "立面図·平面図が含まれるページ番号をそれぞれ入力してください。指定したページのみ読み込み素早く屋根を自動作成します。", + "modal.placement.initial.setting.size.pdf.error.mime": "PDF ファイルのみアップロード可能です。", + "modal.placement.initial.setting.size.pdf.error.too.large": "ファイルサイズが 20MB を超えています。", + "modal.placement.initial.setting.size.pdf.error.no.file": "先に PDF ファイルをアップロードしてください。", "modal.placement.initial.setting.plan.drawing": "図面の作成方法", "modal.placement.initial.setting.plan.drawing.size.stuff": "寸法入力による物件作成", "modal.placement.initial.setting.plan.drawing.size.info": "※数字は[半角]入力のみ可能です。", diff --git a/src/locales/ko.json b/src/locales/ko.json index a596bffe..956d552d 100644 --- a/src/locales/ko.json +++ b/src/locales/ko.json @@ -16,6 +16,33 @@ "header.stem": "Stem", "plan.menu.plan.drawing": "물건정보", "plan.menu.placement.surface.initial.setting": "배치면 초기설정", + "pdf.import.button.analyzing": "분석 중...", + "pdf.import.success": "외곽선이 캔버스에 반영되었습니다.", + "pdf.import.confirm.overwrite": "기존 외곽선이 있습니다. 새 외곽선으로 덮어쓰시겠습니까?", + "pdf.import.error.network": "네트워크 오류로 분석 요청에 실패했습니다.", + "pdf.import.error.analyze": "PDF 분석에 실패했습니다.", + "pdf.import.error.no.floorplan": "평면도를 인식하지 못했습니다. 다른 PDF 로 다시 시도해 주세요.", + "pdf.import.error.too.few.vertices": "외곽선 정점이 부족합니다. 다른 PDF 로 다시 시도해 주세요.", + "pdf.import.error.unsupported.unit": "지원하지 않는 좌표 단위입니다.", + "pdf.import.error.apply": "분석 결과를 캔버스에 반영하지 못했습니다.", + "modal.placement.initial.setting.size.pdf": "도면 파일 업로드", + "modal.placement.initial.setting.size.pdf.info": "주택 도면 파일 업로드 후 전체 페이지 분석 또는 입면도·평면도 페이지 지정 방식으로 외벽선과 지붕 형상을 작성할 수 있습니다.\n주택 도면 파일 업로드를 통한 자동 도면 작성은 반드시 정확한 결과라고 보장할 수 없습니다.\n견적 작성 후 반드시 주택 도면과 출력 결과가 일치하는지 확인해 주십시오.", + "modal.placement.initial.setting.size.pdf.file.label": "주택 도면 파일 업로드", + "modal.placement.initial.setting.size.pdf.file.upload": "업로드", + "modal.placement.initial.setting.size.pdf.file.placeholder": "파일을 드래그하거나 클릭하여 업로드 (.PDF)", + "modal.placement.initial.setting.size.pdf.file.info": "주택 도면 분석 결과에 따라 외벽선과 지붕형상이 함께 생성됩니다. 지붕형상을 인식하지 못한 경우 외벽선만 생성되며, 지붕면은 수동으로 작성합니다.", + "modal.placement.initial.setting.size.pdf.page.label": "읽기 페이지 설정", + "modal.placement.initial.setting.size.pdf.page.all": "전체 페이지", + "modal.placement.initial.setting.size.pdf.page.specify": "페이지 지정", + "modal.placement.initial.setting.size.pdf.page.facade.label": "입면도", + "modal.placement.initial.setting.size.pdf.page.floor.label": "평면도", + "modal.placement.initial.setting.size.pdf.page.example.facade": "예) 1, 2", + "modal.placement.initial.setting.size.pdf.page.example.floor": "예) 3, 4", + "modal.placement.initial.setting.size.pdf.page.all.info": "모든 페이지를 분석하므로 처리 시간이 길어질 수 있습니다. 도면 내 입면도·평면도 페이지가 확인되면 페이지 지정을 권장합니다.", + "modal.placement.initial.setting.size.pdf.page.specify.info": "입면도·평면도가 포함된 페이지 번호를 각각 입력해주세요. 지정한 페이지만 읽어 빠르게 지붕을 자동 작성합니다.", + "modal.placement.initial.setting.size.pdf.error.mime": "PDF 파일만 업로드할 수 있습니다.", + "modal.placement.initial.setting.size.pdf.error.too.large": "파일 크기가 20MB 를 초과합니다.", + "modal.placement.initial.setting.size.pdf.error.no.file": "PDF 파일을 먼저 업로드해 주세요.", "modal.placement.initial.setting.plan.drawing": "도면 작성방법", "modal.placement.initial.setting.plan.drawing.size.stuff": "치수 입력에 의한 물건 작성", "modal.placement.initial.setting.plan.drawing.size.info": "※숫자는 [반각] 입력만 가능합니다.", diff --git a/src/styles/_contents.scss b/src/styles/_contents.scss index 58f9a3ca..14c24fbc 100644 --- a/src/styles/_contents.scss +++ b/src/styles/_contents.scss @@ -17,1539 +17,1593 @@ // } // } // CanvasMenu -.canvas-menu-wrap{ - position: fixed; - top: 46px; - left: 0; - display: block; - width: 100%; - min-width: 1280px; - padding-bottom: 0; - background-color: #383838; - transition: padding .17s ease-in-out; +.canvas-menu-wrap { + position: fixed; + top: 46px; + left: 0; + display: block; + width: 100%; + min-width: 1280px; + padding-bottom: 0; + background-color: #383838; + transition: padding 0.17s ease-in-out; + z-index: 999; + .canvas-menu-inner { + position: relative; + display: flex; + align-items: center; + padding: 0 40px 0 20px; + background-color: #2c2c2c; + height: 46.8px; z-index: 999; - .canvas-menu-inner{ - position: relative; + .canvas-menu-list { + display: flex; + align-items: center; + height: 100%; + .canvas-menu-item { display: flex; align-items: center; - padding: 0 40px 0 20px; - background-color: #2C2C2C; - height: 46.8px; - z-index: 999; - .canvas-menu-list{ - display: flex; - align-items: center; - height: 100%; - .canvas-menu-item{ - display: flex; - align-items: center; - height: 100%; - button{ - display: flex; - align-items: center; - font-size: 12px; - height: 100%; - color: #fff; - font-weight: 600; - padding: 15px 20px; - opacity: 0.55; - transition: all .17s ease-in-out; - .menu-icon{ - display: block; - width: 14px; - height: 14px; - background-repeat: no-repeat; - background-position: center; - background-size: contain; - margin-right: 10px; - &.con00{background-image: url(/static/images/canvas/menu_icon00.svg);} - &.con01{background-image: url(/static/images/canvas/menu_icon01.svg);} - &.con02{background-image: url(/static/images/canvas/menu_icon02.svg);} - &.con03{background-image: url(/static/images/canvas/menu_icon03.svg);} - &.con04{background-image: url(/static/images/canvas/menu_icon04.svg);} - &.con05{background-image: url(/static/images/canvas/menu_icon05.svg);} - &.con06{background-image: url(/static/images/canvas/menu_icon06.svg);} - } - } - &.active{ - background-color: #383838; - button{ - opacity: 1; - } - } + height: 100%; + button { + display: flex; + align-items: center; + font-size: 12px; + height: 100%; + color: #fff; + font-weight: 600; + padding: 15px 20px; + opacity: 0.55; + transition: all 0.17s ease-in-out; + .menu-icon { + display: block; + width: 14px; + height: 14px; + background-repeat: no-repeat; + background-position: center; + background-size: contain; + margin-right: 10px; + &.con00 { + background-image: url(/static/images/canvas/menu_icon00.svg); } + &.con01 { + background-image: url(/static/images/canvas/menu_icon01.svg); + } + &.con02 { + background-image: url(/static/images/canvas/menu_icon02.svg); + } + &.con03 { + background-image: url(/static/images/canvas/menu_icon03.svg); + } + &.con04 { + background-image: url(/static/images/canvas/menu_icon04.svg); + } + &.con05 { + background-image: url(/static/images/canvas/menu_icon05.svg); + } + &.con06 { + background-image: url(/static/images/canvas/menu_icon06.svg); + } + } } - .canvas-side-btn-wrap{ - display: flex; - align-items: center; - margin-left: auto; - .select-box{ - width: 124px; - margin: 0 5px; - height: 30px; - > div{ - width: 100%; - } - } - .btn-from{ - display: flex; - align-items: center; - gap: 5px; - button{ - display: block; - width: 30px; - height: 30px; - border-radius: 2px; - background-color: #3D3D3D; - background-position: center; - background-repeat: no-repeat; - background-size: 15px 15px; - transition: all .17s ease-in-out; - &.btn01{background-image: url(../../public/static/images/canvas/side_icon03.svg);} - &.btn02{background-image: url(../../public/static/images/canvas/side_icon02.svg);} - &.btn03{background-image: url(../../public/static/images/canvas/side_icon01.svg);} - &.btn04{background-image: url(../../public/static/images/canvas/side_icon04.svg);} - &.btn05{background-image: url(../../public/static/images/canvas/side_icon05.svg);} - &.btn06{background-image: url(../../public/static/images/canvas/side_icon06.svg);} - &.btn07{background-image: url(../../public/static/images/canvas/side_icon07.svg);} - &.btn08{background-image: url(../../public/static/images/canvas/side_icon08.svg);} - &.btn09{background-image: url(../../public/static/images/canvas/side_icon09.svg);} - &.btn10{background-image: url(../../public/static/images/canvas/side_icon10.svg); background-size: 15px 14px;} - &:hover{ - background-color: #1083E3; - } - &.active{ - background-color: #1083E3; - } - } - } - .ico-btn-from{ - display: flex; - align-items: center; - gap: 5px; - button{ - .ico{ - display: block; - width: 15px; - height: 15px; - background-repeat: no-repeat; - background-position: center; - background-size: contain; - &.ico01{background-image: url(../../public/static/images/canvas/ico-flx01.svg);} - &.ico02{background-image: url(../../public/static/images/canvas/ico-flx02.svg);} - &.ico03{background-image: url(../../public/static/images/canvas/ico-flx03.svg);} - &.ico04{background-image: url(../../public/static/images/canvas/ico-flx04.svg);} - &.ico05{background-image: url(../../public/static/images/canvas/ico-flx05.svg);} - } - .name{ - font-size: 12px; - color: #fff; - } - } - &.form06{ - .name{ - font-size: 13px; - } - } - } - .vertical-horizontal{ - display: flex; - min-width: 170px; - height: 28px; - margin-right: 5px; - border-radius: 2px; - background: #373737; - line-height: 28px; - overflow: hidden; - span{ - padding: 0 10px; - font-size: 13px; - color: #fff; - } - button{ - margin-left: auto; - height: 100%; - background-color: #4B4B4B; - font-size: 13px; - font-weight: 400; - color: #fff; - padding: 0 7.5px; - transition: all .17s ease-in-out; - } - &.on{ - button{ - background-color: #1083E3; - } - } - } - .size-control{ - display: flex; - align-items: center; - justify-content: center; - gap: 10px; - background-color: #3D3D3D; - border-radius: 2px; - width: 100px; - height: 30px; - margin: 0 5px; - span{ - font-size: 13px; - color: #fff; - cursor: pointer; - } - .control-btn{ - display: block; - width: 12px; - height: 12px; - background-repeat: no-repeat; - background-size: cover; - background-position: center; - &.minus{ - background-image: url(../../public/static/images/canvas/minus.svg); - } - &.plus{ - background-image: url(../../public/static/images/canvas/plus.svg); - } - } - } + &.active { + background-color: #383838; + button { + opacity: 1; + } } + } } - .canvas-depth2-wrap{ - position: absolute; - top: -100%; - left: 0; - background-color: #383838; - width: 100%; - height: 50px; - transition: all .17s ease-in-out; - .canvas-depth2-inner{ - display: flex; - align-items: center; - padding: 0 40px; - height: 100%; - .canvas-depth2-list{ - display: flex; - align-items: center ; - height: 100%; - .canvas-depth2-item{ - display: flex; - align-items: center; - margin-right: 26px; - height: 100%; - button{ - position: relative; - opacity: 0.55; - color: #fff; - font-size: 12px; - font-weight: normal; - height: 100%; - padding-right: 12px; - } - &.active{ - button{ - opacity: 1; - font-weight: 600; - &:after{ - content: ''; - position: absolute; - top: 50%; - right: 0; - transform: translateY(-50%); - width: 5px; - height: 8px; - background: url(../../public/static/images/canvas/depth2-arr.svg) no-repeat center; - } - } - } - } - } - .canvas-depth2-btn-list{ - display: flex; - align-items: center; - margin-left: auto; - height: 100%; - .depth2-btn-box{ - display: flex; - align-items: center; - margin-right: 34px; - height: 100%; - transition: all .17s ease-in-out; - button{ - position: relative; - font-size: 12px; - font-weight: 400; - height: 100%; - color: #fff; - padding-right: 12px; - &:after{ - content: ''; - position: absolute; - top: 50%; - right: 0; - transform: translateY(-50%); - width: 5px; - height: 8px; - background: url(../../public/static/images/canvas/depth2-arr.svg) no-repeat center; - } - } - &:last-child{ - margin-right: 0; - } - &.mouse{ - opacity: 0.55; - } - } - } + .canvas-side-btn-wrap { + display: flex; + align-items: center; + margin-left: auto; + .select-box { + width: 124px; + margin: 0 5px; + height: 30px; + > div { + width: 100%; } - &.active{ - top: 47px; - } - } - &.active{ - padding-bottom: 50px; - } -} - -// canvas-layout -.canvas-content{ - padding-top: 46.8px; - transition: all .17s ease-in-out; - .canvas-frame{ - height: calc(100vh - 129.3px); - } - &.active{ - padding-top: calc(46.8px + 50px); - .canvas-frame{ - height: calc(100vh - 179.4px); - } - } -} -.canvas-layout{ - padding-top: 37px; - .canvas-page-list{ - position: fixed; - top: 92.8px; - left: 0; + } + .btn-from { display: flex; - background-color: #1C1C1C; - border-top: 1px solid #000; - width: 100%; - min-width: 1280px; - transition: all .17s ease-in-out; - z-index: 99; - &.active{ - top: calc(92.8px + 50px); + align-items: center; + gap: 5px; + button { + display: block; + width: 30px; + height: 30px; + border-radius: 2px; + background-color: #3d3d3d; + background-position: center; + background-repeat: no-repeat; + background-size: 15px 15px; + transition: all 0.17s ease-in-out; + &.btn01 { + background-image: url(../../public/static/images/canvas/side_icon03.svg); + } + &.btn02 { + background-image: url(../../public/static/images/canvas/side_icon02.svg); + } + &.btn03 { + background-image: url(../../public/static/images/canvas/side_icon01.svg); + } + &.btn04 { + background-image: url(../../public/static/images/canvas/side_icon04.svg); + } + &.btn05 { + background-image: url(../../public/static/images/canvas/side_icon05.svg); + } + &.btn06 { + background-image: url(../../public/static/images/canvas/side_icon06.svg); + } + &.btn07 { + background-image: url(../../public/static/images/canvas/side_icon07.svg); + } + &.btn08 { + background-image: url(../../public/static/images/canvas/side_icon08.svg); + } + &.btn09 { + background-image: url(../../public/static/images/canvas/side_icon09.svg); + } + &.btn10 { + background-image: url(../../public/static/images/canvas/side_icon10.svg); + background-size: 15px 14px; + } + &:hover { + background-color: #1083e3; + } + &.active { + background-color: #1083e3; + } } - .canvas-id{ - display: flex; - align-items: center; - padding: 9.6px 20px; + } + .ico-btn-from { + display: flex; + align-items: center; + gap: 5px; + button { + .ico { + display: block; + width: 15px; + height: 15px; + background-repeat: no-repeat; + background-position: center; + background-size: contain; + &.ico01 { + background-image: url(../../public/static/images/canvas/ico-flx01.svg); + } + &.ico02 { + background-image: url(../../public/static/images/canvas/ico-flx02.svg); + } + &.ico03 { + background-image: url(../../public/static/images/canvas/ico-flx03.svg); + } + &.ico04 { + background-image: url(../../public/static/images/canvas/ico-flx04.svg); + } + &.ico05 { + background-image: url(../../public/static/images/canvas/ico-flx05.svg); + } + } + .name { font-size: 12px; color: #fff; - background-color: #1083E3; + } } - .canvas-plane-wrap{ - display: flex; - align-items: center; - max-width: calc(100% - 45px); - .canvas-page-box{ - display: flex; - align-items: center; - background-color: #1c1c1c; - padding: 9.6px 20px; - border-right:1px solid #000; - min-width: 0; - transition: all .17s ease-in-out; - span{ - display: flex; - align-items: center; - width: 100%; - font-size: 12px; - font-family: 'Pretendard', sans-serif; - color: #AAA; - white-space: nowrap; - text-overflow: ellipsis; - overflow: hidden; - } - .close{ - flex: none; - display: block; - width: 7px; - height: 8px; - margin-left: 15px; - background: url(../../public/static/images/canvas/plan_close_gray.svg)no-repeat center; - background-size: cover; - } - &.on{ - background-color: #fff; - span{ - font-weight: 600; - color: #101010; - } - .close{ - background: url(../../public/static/images/canvas/plan_close_black.svg)no-repeat center; - } - &:hover{ - background-color: #fff; - } - } - &:hover{ - background-color: #000; - } - } + &.form06 { + .name { + font-size: 13px; + } } - .plane-add{ - display: flex; - align-items: center; - justify-content: center; - width: 45px; - padding: 13.5px 0; - background-color: #1C1C1C; - border-right: 1px solid #000; - transition: all .17s ease-in-out; - span{ - display: block; - width: 9px; - height: 9px; - background: url(../../public/static/images/canvas/plane_add.svg)no-repeat center; - background-size: cover; - } - &:hover{ - background-color: #000; - } - } - } -} - -.canvas-frame{ - position: relative; - // height: calc(100% - 36.5px); - background-color: #F4F4F7; - overflow: auto; - transition: all .17s ease-in-out; - // &::-webkit-scrollbar { - // width: 10px; - // height: 10px; - // background-color: #fff; - // } - // &::-webkit-scrollbar-thumb { - // background-color: #C1CCD7; - // border-radius: 30px; - // } - // &::-webkit-scrollbar-track { - // background-color: #fff; - // } - .canvas-container{ - margin: 0 auto; - background-color: #fff; - } - canvas{ - position: absolute; - top: 0; - left: 0; - width: 100%; - height: 100%; - } -} - -// sub-page -.sub-header{ - position: fixed; - top: 46px; - left: 0; - width: 100%; - min-width: 1280px; - height: 46px; - border-bottom: 1px solid #000; - background: #2C2C2C; - z-index: 999; - .sub-header-inner{ + } + .vertical-horizontal { display: flex; - align-items: center; - height: 100%; - padding: 0 100px; - .sub-header-title-wrap{ - display: flex; - align-items: center; - .title-item{ - position: relative; - padding: 0 24px; - a{ - display: flex; - align-items: center; - .icon{ - width: 22px; - height: 22px; - margin-right: 8px; - background-repeat: no-repeat; - background-position: center; - background-size: cover; - &.drawing{background-image: url(../../public/static/images/main/drawing_icon.svg);} - } - } - &:after{ - content: ''; - position: absolute; - top: 50%; - right: 0; - transform: translateY(-50%); - width: 1px; - height: 16px; - background-color: #D9D9D9; - } - &:first-child{ - padding-left: 0; - } - &:last-child{ - padding-right: 0; - &:after{ - display: none; - } - } - } + min-width: 170px; + height: 28px; + margin-right: 5px; + border-radius: 2px; + background: #373737; + line-height: 28px; + overflow: hidden; + span { + padding: 0 10px; + font-size: 13px; + color: #fff; } - .sub-header-title{ - font-size: 16px; - color: #fff; - font-weight: 600; + button { + margin-left: auto; + height: 100%; + background-color: #4b4b4b; + font-size: 13px; + font-weight: 400; + color: #fff; + padding: 0 7.5px; + transition: all 0.17s ease-in-out; } - .sub-header-location{ - margin-left: auto; - display: flex; - align-items: center; - .location-item{ - position: relative; - display: flex; - align-items: center; - padding: 0 10px; - span{ - display: flex; - font-size: 12px; - color: #AAA; - font-weight: normal; - cursor: default; - } - &:after{ - content: ''; - position: absolute; - top: 50%; - right: 0; - transform: translateY(-50%); - width: 4px; - height: 6px; - background: url(../../public/static/images/main/loaction_arr.svg)no-repeat center; - } - &:first-child{ - padding-left: 0; - } - &:last-child{ - padding-right: 0; - span{ - color: #fff; - } - &:after{ - display: none; - } - } - } + &.on { + button { + background-color: #1083e3; + } } - } -} - -// sub content -.sub-content{ - padding-top: 46px; - .sub-content-inner{ - max-width: 1760px; - margin: 0 auto; - padding: 20px 20px 0; - .sub-content-box{ - margin-bottom: 20px; - &:last-child{ - margin-bottom: 0; - } - } - } - &.estimate{ - display: flex; - flex-direction: column; - padding-top: 0; - .sub-content-inner{ - flex: 1; - width: 100%; - } - } -} -.sub-table-box{ - padding: 20px; - border-radius: 6px; - border: 1px solid #E9EAED; - background: #FFF; - box-shadow: 0px 3px 30px 0px rgba(0, 0, 0, 0.02); - .table-box-title-wrap{ - display: flex; - align-items: center; - margin-bottom: 15px; - .title-wrap{ - display: flex; - align-items: center; - h3{ - display: block; - font-size: 15px; - color: #101010; - font-weight: 600; - margin-right: 14px; - &.product{ - margin-right: 10px; - } - } - .estimate-check-btn{ - position: relative; - display: flex; - align-items: center; - padding-left: 10px; - &::before{ - content: ''; - position: absolute; - top: 50%; - left: 0; - transform: translateY(-50%); - width: 1px; - height: 11px; - background-color: #D9D9D9; - } - } - .product_tit{ - position: relative; - font-size: 15px; - font-weight: 600; - color: #1083E3; - padding-left: 10px; - &::before{ - content: ''; - position: absolute; - top: 50%; - left: 0; - transform: translateY(-50%); - width: 1px; - height: 11px; - background-color: #D9D9D9; - } - } - .option{ - padding-left: 5px; - font-size: 13px; - color: #101010; - font-weight: 400; - } - .info-wrap{ - display: flex; - align-items: center; - li{ - position: relative; - padding: 0 6px; - font-size: 12px; - color: #101010; - font-weight: normal; - span{ - font-weight: 600; - &.red{ - color: #E23D70; - } - } - &:after{ - content: ''; - position: absolute; - top: 50%; - right: 0; - transform: translateY(-50%); - width: 1px; - height: 11px; - background-color: #D9D9D9; - } - &:first-child{padding-left: 0;} - &:last-child{padding-right: 0;&::after{display: none;}} - } - } - } - } - .left-unit-box{ - margin-left: auto; - display: flex; - align-items: center; - } - .promise-title-wrap{ - display: flex; - align-items: center; - margin-bottom: 15px; - .promise-gudie{ - margin-bottom: 0; - } - } - .promise-gudie{ - display: block; - font-size: 13px; - font-weight: 700; - color: #101010; - margin-bottom: 20px; - } - .important{ - color: #f00; - } - .sub-center-footer{ + } + .size-control { display: flex; align-items: center; justify-content: center; - margin-top: 20px; - } - .sub-right-footer{ - display: flex; - align-items: center; - justify-content: flex-end; - margin-top: 20px; - } -} -.pagination-wrap{ - margin-top: 24px; -} - -.infomation-wrap{ - margin-bottom: 30px; -} - -.infomation-box-wrap{ - display: flex; - gap: 10px; - .sub-table-box{ - flex: 1 ; - } - .info-title{ - font-size: 14px; - font-weight: 500; - color: #344356; - margin-bottom: 10px; - } - .info-inner{ - position: relative; - font-size: 13px; - color: #344356; - .copy-ico{ - position: absolute; - bottom: 0; - right: 0; - width: 16px; - height: 16px; - background: url(../../public/static/images/sub/copy_ico.svg)no-repeat center; - background-size: cover; - } - } -} - -// 견적서 -.estimate-list-wrap{ - display: flex; - align-items: center; - margin-bottom: 10px; - &.one{ - .estimate-box{ - &:last-child{ - flex: 1; - min-width: unset; - } - } - } - .estimate-box{ - flex: 1 ; - display: flex; - align-items: center; - &:last-child{ - flex: none; - min-width: 220px; - } - .estimate-tit{ - width: 105px; - height: 30px; - line-height: 30px; - background-color: #F4F4F7; - border-radius: 100px; - text-align: center; - font-size: 13px; - font-weight: 500; - color: #344356; - } - .estimate-name{ - font-size: 13px; - color: #344356; - margin-left: 14px; - font-weight: 400; - &.blue{ - font-size: 16px; - font-weight: 700; - color: #1083E3; - } - &.red{ - font-size: 16px; - font-weight: 700; - color: #D72A2A; - } - } - } - &:last-child{ - margin-bottom: 0; - } -} - -// file drag box -.drag-file-guide{ - font-size: 13px; - font-weight: 400; - color: #45576F; - margin-left: 5px; -} -.btn-area{ - padding-bottom: 15px; - border-bottom: 1px solid #ECF0F4; - .file-upload{ - display: inline-block; - height: 30px; - background-color: #94A0AD; - padding: 0 10px; + gap: 10px; + background-color: #3d3d3d; border-radius: 2px; - font-size: 13px; - line-height: 30px; - color: #fff; - font-weight: 500; - cursor: pointer; - transition: background .15s ease-in-out; - &:hover{ - background-color: #607F9A; + width: 100px; + height: 30px; + margin: 0 5px; + span { + font-size: 13px; + color: #fff; + cursor: pointer; } - } -} -.drag-file-box{ - padding: 10px; - .drag-file-area{ - position: relative; - margin-top: 15px; - p{ - position: absolute; - top: 50%; - left: 50%; - transform: translate(-50%, -50%); - font-size: 13px; - color: #ccc; - font-weight: 400; - cursor: default; + .control-btn { + display: block; + width: 12px; + height: 12px; + background-repeat: no-repeat; + background-size: cover; + background-position: center; + &.minus { + background-image: url(../../public/static/images/canvas/minus.svg); + } + &.plus { + background-image: url(../../public/static/images/canvas/plus.svg); + } } + } } - .file-list{ - min-height: 52px; - .file-item{ - margin-bottom: 15px; - span{ - position: relative; - font-size: 13px; - color: #45576F; - font-weight: 400; - white-space: nowrap; - padding-right: 55px; - cursor: pointer; - button{ - position: absolute; - top: 50%; - right: 0; - transform: translateY(-50%); - width: 15px; - height: 15px; - background: url(../../public/static/images/sub/file_delete.svg)no-repeat center; - background-size: cover; - } - } - &:last-child{ - margin-bottom: 0; - } - .file-item-wrap{ - display: flex; - align-items: center; - gap: 30px; - .return-wrap{ - display: flex; - align-items: center; - } - .return{ - padding: 0; - font-size: 13px; - color: #B0BCCD; - text-decoration: line-through; - } - .return-btn{ - flex: none; - position: relative; - top: 0; - left: 0; - transform: none; - display: flex; - align-items: center; - height: 24px; - padding: 0 9px; - margin-left: 10px; - background: none; - border: 1px solid #B0BCCD; - border-radius: 2px; - font-size: 12px; - color: #B0BCCD; - font-weight: 500; - .return-ico{ - display: block; - width: 14px; - height: 14px; - background: url(../../public/static/images/canvas/return-btn.svg)no-repeat center; - background-size: contain; - margin-right: 5px; - } - } - } - } - } -} - -.estimate-arr-btn{ - display: block; - width: 20px; - height: 20px; - background-color: #94A0AD; - border: 1px solid #94A0AD; - background-position: center; - background-repeat: no-repeat; - background-image: url(../../public/static/images/canvas/estiment_arr.svg); - background-size: 11px 7px; - border-radius: 2px; - &.up{ - rotate: 180deg; - } - &.on{ - background-color: #fff; - border-color: #C2D0DD; - background-image: url(../../public/static/images/canvas/estiment_arr_color.svg) - } -} -.estimate-check-wrap{ - .estimate-check-inner{ - display: block; - } - &.hide{ - border-bottom: 1px solid #ECF0F4; - margin-bottom: 15px; - .estimate-check-inner{ - display: none; - } - } -} - -.special-note-check-wrap{ - display: grid; - grid-template-columns: repeat(5, 1fr); - border-radius: 3px; - margin-bottom: 30px; - .special-note-check-item{ - padding: 14px 10px; - border: 1px solid #ECF0F4; - margin-top: -1px; - margin-right: -1px; - &.act{ - background-color: #F7F9FA; - } - .special-note-check-box{ - display: flex; - align-items: center; - .check-name{ - font-size: 13px; - color: #45576F; - cursor: pointer; - line-height: 1.3; - } - } - } -} - -.calculation-estimate{ - border: 1px solid #ECF0F4; - border-radius: 3px; - padding: 24px; - height: 170px; - overflow-y: auto; - margin-bottom: 30px; - dl{ - margin-bottom: 35px; - &:last-child{ - margin-bottom: 0; - } - dt{ - font-size: 13px; - font-weight: 600; - color: #1083E3; - margin-bottom: 15px; - } - dd{ - font-size: 12px; - font-weight: 400; - color: #45576F; - margin-bottom: 8px; - &:last-child{ - margin-bottom: 0; - } - } - } - &::-webkit-scrollbar { - width: 4px; - background-color: transparent; - } - &::-webkit-scrollbar-thumb { - background-color: #d9dee2; - } - &::-webkit-scrollbar-track { - background-color: transparent; - } -} -.esimate-wrap{ - margin-bottom: 20px; -} - -.estimate-product-option{ - display: flex; - align-items: center; - margin-bottom: 15px; - .product-price-wrap{ - display: flex; - align-items: center; - .product-price-tit{ - font-size: 13px; - font-weight: 400; - color: #45576F; - margin-right: 10px; - } - .select-wrap{ - width: 150px; - } - } - .product-edit-wrap{ - display: flex; - align-items: center; - margin-left: auto; - .product-edit-explane{ - display: flex; - align-items: center; - margin-right: 15px; - .explane-item{ - position: relative; - display: flex; - align-items: center; - padding: 0 10px; - font-size: 12px; - font-weight: 400; - span{ - width: 20px; - height: 20px; - margin-right: 5px; - background-size: cover; - background-repeat: no-repeat; - background-position: center; - } - &:before{ - content: ''; - position: absolute; - top: 50%; - left: 0; - transform: translateY(-50%); - width: 1px; - height: 12px; - background-color: #D9D9D9; - } - &:first-child{ - padding-left: 0; - &::before{ - display: none; - } - } - &:last-child{ - padding-right: 0; - } - &.item01{ - color: #3BBB48; - span{ - background-image: url(../../public/static/images/sub/open_ico.svg); - } - } - &.item02{ - color: #909000; - span{ - background-image: url(../../public/static/images/sub/change_ico.svg); - } - } - &.item03{ - color: #0191C9; - span{ - background-image: url(../../public/static/images/sub/attachment_ico.svg); - } - } - &.item04{ - color: #F16A6A; - span{ - background-image: url(../../public/static/images/sub/click_check_ico.svg); - } - } - } - } - .product-edit-btn{ - display: flex; - align-items: center; - button{ - display: flex; - align-items: center; - span{ - width: 13px; - height: 13px; - margin-right: 5px; - background-size: cover; - &.plus{ - background: url(../../public/static/images/sub/plus_btn.svg)no-repeat center; - } - &.minus{ - background: url(../../public/static/images/sub/minus_btn.svg)no-repeat center; - } - } - } - } - } -} - -// 발전시물레이션 -.chart-wrap{ - display: flex; - gap: 20px; + } + .canvas-depth2-wrap { + position: absolute; + top: -100%; + left: 0; + background-color: #383838; width: 100%; - .sub-table-box{ - height: 100%; - } - .chart-inner{ - flex: 1; - .chart-box{ - margin-bottom: 30px; - } - } - .chart-table-wrap{ + height: 50px; + transition: all 0.17s ease-in-out; + .canvas-depth2-inner { + display: flex; + align-items: center; + padding: 0 40px; + height: 100%; + .canvas-depth2-list { display: flex; - flex-direction: column; - flex: none; - width: 650px; - .sub-table-box{ - flex: 1; - &:first-child{ - margin-bottom: 20px; - } - } - } -} - -.chart-month-table{ - table{ - table-layout: fixed; - border-collapse:collapse; - border: 1px solid #ECF0F4; - border-radius: 4px; - thead{ - th{ - padding: 4.5px 0; - border-bottom: 1px solid #ECF0F4; - text-align: center; - font-size: 13px; - color: #45576F; - font-weight: 500; - background-color: #F8F9FA; - } - } - tbody{ - td{ - font-size: 13px; - color: #45576F; - text-align: center; - padding: 4.5px 0; - } - } - } -} - -.simulation-guide-wrap{ - display: flex; - padding: 20px; - .simulation-tit-wrap{ - flex: none; - padding-right: 40px; - border-right: 1px solid #EEEEEE; - span{ - display: block; + align-items: center; + height: 100%; + .canvas-depth2-item { + display: flex; + align-items: center; + margin-right: 26px; + height: 100%; + button { position: relative; - padding-left: 60px; - font-size: 15px; - color: #14324F; - font-weight: 600; - &::before{ + opacity: 0.55; + color: #fff; + font-size: 12px; + font-weight: normal; + height: 100%; + padding-right: 12px; + } + &.active { + button { + opacity: 1; + font-weight: 600; + &:after { content: ''; position: absolute; top: 50%; - left: 0; + right: 0; transform: translateY(-50%); - width: 40px; - height: 40px; - background: url(../../public/static/images/sub/simulation_guide.svg)no-repeat center; - background-size: cover; + width: 5px; + height: 8px; + background: url(../../public/static/images/canvas/depth2-arr.svg) no-repeat center; + } } + } } - } - .simulation-guide-box{ - flex: 1; - padding-left: 40px; - dl{ - margin-bottom: 25px; - dt{ - font-size: 13px; - color: #101010; - font-weight: 600; - margin-bottom: 5px; - } - dd{ - font-size: 12px; - color: #45576F; - font-weight: 400; - line-height: 24px; - } - &:last-child{ - margin-bottom: 0; - } - } - ul, ol{ - list-style: unset; - } - } -} - -.module-total{ - display: flex; - align-items: center; - background-color: #F8F9FA; - padding: 9px 0; - margin-right: 4px; - border: 1px solid #ECF0F4; - border-top: none; - .total-title{ - flex: 1; - text-align: center; - font-size: 13px; - color: #344356; - font-weight: 500; - } - .total-num{ - flex: none; - width: 121px; - text-align: center; - font-size: 15px; - color: #344356; - font-weight: 500; - } -} - -// 물건상세 -.information-help-wrap{ - display: flex; - padding: 24px; - background-color: #F4F4F4; - border-radius: 4px; - margin-bottom: 15px; - .information-help-tit-wrap{ - position: relative; + } + .canvas-depth2-btn-list { display: flex; align-items: center; - padding-right: 40px; - border-right: 1px solid #E0E0E3; - .help-tit-icon{ - width: 40px; - height: 40px; - border-radius: 50%; - margin-right: 10px; - background: #fff url(../../public/static/images/sub/information_help.svg)no-repeat center; - background-size: 20px 20px; - } - .help-tit{ - font-size: 13px; - font-weight: 600; - color: #45576F; - } - } - .information-help-guide{ - padding-left: 40px; - span{ - display: block; + margin-left: auto; + height: 100%; + .depth2-btn-box { + display: flex; + align-items: center; + margin-right: 34px; + height: 100%; + transition: all 0.17s ease-in-out; + button { + position: relative; font-size: 12px; font-weight: 400; - color: #45576F; - margin-bottom: 7px; - &:last-child{ - margin-bottom: 0; + height: 100%; + color: #fff; + padding-right: 12px; + &:after { + content: ''; + position: absolute; + top: 50%; + right: 0; + transform: translateY(-50%); + width: 5px; + height: 8px; + background: url(../../public/static/images/canvas/depth2-arr.svg) no-repeat center; } + } + &:last-child { + margin-right: 0; + } + &.mouse { + opacity: 0.55; + } } + } } + &.active { + top: 47px; + } + } + &.active { + padding-bottom: 50px; + } } -.community-search-warp{ +// canvas-layout +.canvas-content { + padding-top: 46.8px; + transition: all 0.17s ease-in-out; + .canvas-frame { + height: calc(100vh - 129.3px); + } + &.active { + padding-top: calc(46.8px + 50px); + .canvas-frame { + height: calc(100vh - 179.4px); + } + } +} +.canvas-layout { + padding-top: 37px; + .canvas-page-list { + position: fixed; + top: 92.8px; + left: 0; + display: flex; + background-color: #1c1c1c; + border-top: 1px solid #000; + width: 100%; + min-width: 1280px; + transition: all 0.17s ease-in-out; + z-index: 99; + &.active { + top: calc(92.8px + 50px); + } + .canvas-id { + display: flex; + align-items: center; + padding: 9.6px 20px; + font-size: 12px; + color: #fff; + background-color: #1083e3; + } + .canvas-plane-wrap { + display: flex; + align-items: center; + max-width: calc(100% - 45px); + .canvas-page-box { + display: flex; + align-items: center; + background-color: #1c1c1c; + padding: 9.6px 20px; + border-right: 1px solid #000; + min-width: 0; + transition: all 0.17s ease-in-out; + span { + display: flex; + align-items: center; + width: 100%; + font-size: 12px; + font-family: 'Pretendard', sans-serif; + color: #aaa; + white-space: nowrap; + text-overflow: ellipsis; + overflow: hidden; + } + .close { + flex: none; + display: block; + width: 7px; + height: 8px; + margin-left: 15px; + background: url(../../public/static/images/canvas/plan_close_gray.svg) no-repeat center; + background-size: cover; + } + &.on { + background-color: #fff; + span { + font-weight: 600; + color: #101010; + } + .close { + background: url(../../public/static/images/canvas/plan_close_black.svg) no-repeat center; + } + &:hover { + background-color: #fff; + } + } + &:hover { + background-color: #000; + } + } + } + .plane-add { + display: flex; + align-items: center; + justify-content: center; + width: 45px; + padding: 13.5px 0; + background-color: #1c1c1c; + border-right: 1px solid #000; + transition: all 0.17s ease-in-out; + span { + display: block; + width: 9px; + height: 9px; + background: url(../../public/static/images/canvas/plane_add.svg) no-repeat center; + background-size: cover; + } + &:hover { + background-color: #000; + } + } + } +} + +.canvas-frame { + position: relative; + // height: calc(100% - 36.5px); + background-color: #f4f4f7; + overflow: auto; + transition: all 0.17s ease-in-out; + // &::-webkit-scrollbar { + // width: 10px; + // height: 10px; + // background-color: #fff; + // } + // &::-webkit-scrollbar-thumb { + // background-color: #C1CCD7; + // border-radius: 30px; + // } + // &::-webkit-scrollbar-track { + // background-color: #fff; + // } + .canvas-container { + margin: 0 auto; + background-color: #fff; + } + canvas { + position: absolute; + top: 0; + left: 0; + width: 100%; + height: 100%; + } +} + +// sub-page +.sub-header { + position: fixed; + top: 46px; + left: 0; + width: 100%; + min-width: 1280px; + height: 46px; + border-bottom: 1px solid #000; + background: #2c2c2c; + z-index: 999; + .sub-header-inner { display: flex; - flex-direction: column; align-items: center; - padding: 10px 0 30px 0; - border-bottom: 1px solid #E5E5E5; - margin-bottom: 24px; - .community-search-box{ + height: 100%; + padding: 0 100px; + .sub-header-title-wrap { + display: flex; + align-items: center; + .title-item { + position: relative; + padding: 0 24px; + a { + display: flex; + align-items: center; + .icon { + width: 22px; + height: 22px; + margin-right: 8px; + background-repeat: no-repeat; + background-position: center; + background-size: cover; + &.drawing { + background-image: url(../../public/static/images/main/drawing_icon.svg); + } + } + } + &:after { + content: ''; + position: absolute; + top: 50%; + right: 0; + transform: translateY(-50%); + width: 1px; + height: 16px; + background-color: #d9d9d9; + } + &:first-child { + padding-left: 0; + } + &:last-child { + padding-right: 0; + &:after { + display: none; + } + } + } + } + .sub-header-title { + font-size: 16px; + color: #fff; + font-weight: 600; + } + .sub-header-location { + margin-left: auto; + display: flex; + align-items: center; + .location-item { position: relative; display: flex; align-items: center; - width: 580px; - height: 45px; - padding: 0 45px 0 20px; - margin-bottom: 20px; - border-radius: 2px; - border: 1px solid #101010; - .community-input{ - width: 100%; - height: 100%; - font-size: 13px; - font-weight: 400; - color: #101010; - &::placeholder{ - color: #C8C8C8; - } + padding: 0 10px; + span { + display: flex; + font-size: 12px; + color: #aaa; + font-weight: normal; + cursor: default; } - .community-search-ico{ - position: absolute; - top: 50%; - right: 20px; - transform: translateY(-50%); - flex: none; - width: 21px; - height: 100%; - background: url(../../public/static/images/sub/community_search.svg)no-repeat center; - background-size: 21px 21px; - z-index: 3; - } - } - .community-search-keyword{ - font-size: 13px; - font-weight: 400; - color: #45576F; - span{ - font-weight: 600; - color: #F16A6A; + &:after { + content: ''; + position: absolute; + top: 50%; + right: 0; + transform: translateY(-50%); + width: 4px; + height: 6px; + background: url(../../public/static/images/main/loaction_arr.svg) no-repeat center; } + &:first-child { + padding-left: 0; + } + &:last-child { + padding-right: 0; + span { + color: #fff; + } + &:after { + display: none; + } + } + } } + } } -// 자료 다운로드 -.file-down-list{ - display: grid; - grid-template-columns: repeat(3, 1fr); - gap: 14px; - .file-down-item{ +// sub content +.sub-content { + padding-top: 46px; + .sub-content-inner { + max-width: 1760px; + margin: 0 auto; + padding: 20px 20px 0; + .sub-content-box { + margin-bottom: 20px; + &:last-child { + margin-bottom: 0; + } + } + } + &.estimate { + display: flex; + flex-direction: column; + padding-top: 0; + .sub-content-inner { + flex: 1; + width: 100%; + } + } +} +.sub-table-box { + padding: 20px; + border-radius: 6px; + border: 1px solid #e9eaed; + background: #fff; + box-shadow: 0px 3px 30px 0px rgba(0, 0, 0, 0.02); + .table-box-title-wrap { + display: flex; + align-items: center; + margin-bottom: 15px; + .title-wrap { + display: flex; + align-items: center; + h3 { + display: block; + font-size: 15px; + color: #101010; + font-weight: 600; + margin-right: 14px; + &.product { + margin-right: 10px; + } + } + .estimate-check-btn { + position: relative; display: flex; align-items: center; - padding: 24px; - border-radius: 4px; - border: 1px solid #E5E5E5; - background: #FFF; - transition: all .15s ease-in-out; - .file-item-info{ - .item-num{ - display: inline-block; - padding: 6px 17.5px; - border-radius: 60px; - background-color: #F4F4F7; - font-size: 13px; - font-weight: 600; - color: #101010; - margin-bottom: 15px; - } - .item-name{ - font-size: 16px; - color: #101010; - font-weight: 500; - margin-bottom: 13px; - } - .item-date{ - font-size: 13px; - font-weight: 400; - color: #344356; - } + padding-left: 10px; + &::before { + content: ''; + position: absolute; + top: 50%; + left: 0; + transform: translateY(-50%); + width: 1px; + height: 11px; + background-color: #d9d9d9; } - .file-down-box{ - display: flex; - align-items: center; - flex: none; - margin-left: auto; - height: 100%; - .file-down-btn{ - width: 36px; - height: 36px; - background: url(../../public/static/images/sub/file_down_btn.svg)no-repeat center; - background-size: cover; + } + .product_tit { + position: relative; + font-size: 15px; + font-weight: 600; + color: #1083e3; + padding-left: 10px; + &::before { + content: ''; + position: absolute; + top: 50%; + left: 0; + transform: translateY(-50%); + width: 1px; + height: 11px; + background-color: #d9d9d9; + } + } + .option { + padding-left: 5px; + font-size: 13px; + color: #101010; + font-weight: 400; + } + .info-wrap { + display: flex; + align-items: center; + li { + position: relative; + padding: 0 6px; + font-size: 12px; + color: #101010; + font-weight: normal; + span { + font-weight: 600; + &.red { + color: #e23d70; } + } + &:after { + content: ''; + position: absolute; + top: 50%; + right: 0; + transform: translateY(-50%); + width: 1px; + height: 11px; + background-color: #d9d9d9; + } + &:first-child { + padding-left: 0; + } + &:last-child { + padding-right: 0; + &::after { + display: none; + } + } } - &:hover{ - background-color: #F4F4F7; - } + } } -} - -.file-down-nodata{ + } + .left-unit-box { + margin-left: auto; + display: flex; + align-items: center; + } + .promise-title-wrap { + display: flex; + align-items: center; + margin-bottom: 15px; + .promise-gudie { + margin-bottom: 0; + } + } + .promise-gudie { + display: block; + font-size: 13px; + font-weight: 700; + color: #101010; + margin-bottom: 20px; + } + .important { + color: #f00; + } + .sub-center-footer { display: flex; align-items: center; justify-content: center; - width: 100%; - height: 148px; - padding: 24px; - border-radius: 4px; - border: 1px solid #E5E5E5; - font-size: 16px; + margin-top: 20px; + } + .sub-right-footer { + display: flex; + align-items: center; + justify-content: flex-end; + margin-top: 20px; + } +} +.pagination-wrap { + margin-top: 24px; +} + +.infomation-wrap { + margin-bottom: 30px; +} + +.infomation-box-wrap { + display: flex; + gap: 10px; + .sub-table-box { + flex: 1; + } + .info-title { + font-size: 14px; font-weight: 500; color: #344356; + margin-bottom: 10px; + } + .info-inner { + position: relative; + font-size: 13px; + color: #344356; + .copy-ico { + position: absolute; + bottom: 0; + right: 0; + width: 16px; + height: 16px; + background: url(../../public/static/images/sub/copy_ico.svg) no-repeat center; + background-size: cover; + } + } +} + +// 견적서 +.estimate-list-wrap { + display: flex; + align-items: center; + margin-bottom: 10px; + &.one { + .estimate-box { + &:last-child { + flex: 1; + min-width: unset; + } + } + } + .estimate-box { + flex: 1; + display: flex; + align-items: center; + &:last-child { + flex: none; + min-width: 220px; + } + .estimate-tit { + width: 105px; + height: 30px; + line-height: 30px; + background-color: #f4f4f7; + border-radius: 100px; + text-align: center; + font-size: 13px; + font-weight: 500; + color: #344356; + } + .estimate-name { + font-size: 13px; + color: #344356; + margin-left: 14px; + font-weight: 400; + &.blue { + font-size: 16px; + font-weight: 700; + color: #1083e3; + } + &.red { + font-size: 16px; + font-weight: 700; + color: #d72a2a; + } + } + } + &:last-child { + margin-bottom: 0; + } +} + +// file drag box +.drag-file-guide { + font-size: 13px; + font-weight: 400; + color: #45576f; + margin-left: 5px; +} +.btn-area { + padding-bottom: 15px; + border-bottom: 1px solid #ecf0f4; + .file-upload { + display: inline-block; + height: 30px; + background-color: #94a0ad; + padding: 0 10px; + border-radius: 2px; + font-size: 13px; + line-height: 30px; + color: #fff; + font-weight: 500; + cursor: pointer; + transition: background 0.15s ease-in-out; + &:hover { + background-color: #607f9a; + } + } +} +.drag-file-box { + padding: 10px; + .drag-file-area { + position: relative; + margin-top: 15px; + p { + position: absolute; + top: 50%; + left: 50%; + transform: translate(-50%, -50%); + font-size: 13px; + color: #ccc; + font-weight: 400; + cursor: default; + } + } + .file-list { + min-height: 52px; + .file-item { + margin-bottom: 15px; + span { + position: relative; + font-size: 13px; + color: #45576f; + font-weight: 400; + white-space: nowrap; + padding-right: 55px; + cursor: pointer; + button { + position: absolute; + top: 50%; + right: 0; + transform: translateY(-50%); + width: 15px; + height: 15px; + background: url(../../public/static/images/sub/file_delete.svg) no-repeat center; + background-size: cover; + } + } + &:last-child { + margin-bottom: 0; + } + .file-item-wrap { + display: flex; + align-items: center; + gap: 30px; + .return-wrap { + display: flex; + align-items: center; + } + .return { + padding: 0; + font-size: 13px; + color: #b0bccd; + text-decoration: line-through; + } + .return-btn { + flex: none; + position: relative; + top: 0; + left: 0; + transform: none; + display: flex; + align-items: center; + height: 24px; + padding: 0 9px; + margin-left: 10px; + background: none; + border: 1px solid #b0bccd; + border-radius: 2px; + font-size: 12px; + color: #b0bccd; + font-weight: 500; + .return-ico { + display: block; + width: 14px; + height: 14px; + background: url(../../public/static/images/canvas/return-btn.svg) no-repeat center; + background-size: contain; + margin-right: 5px; + } + } + } + } + } +} + +.estimate-arr-btn { + display: block; + width: 20px; + height: 20px; + background-color: #94a0ad; + border: 1px solid #94a0ad; + background-position: center; + background-repeat: no-repeat; + background-image: url(../../public/static/images/canvas/estiment_arr.svg); + background-size: 11px 7px; + border-radius: 2px; + &.up { + rotate: 180deg; + } + &.on { + background-color: #fff; + border-color: #c2d0dd; + background-image: url(../../public/static/images/canvas/estiment_arr_color.svg); + } +} +.estimate-check-wrap { + .estimate-check-inner { + display: block; + } + &.hide { + border-bottom: 1px solid #ecf0f4; + margin-bottom: 15px; + .estimate-check-inner { + display: none; + } + } +} + +.special-note-check-wrap { + display: grid; + grid-template-columns: repeat(5, 1fr); + border-radius: 3px; + margin-bottom: 30px; + .special-note-check-item { + padding: 14px 10px; + border: 1px solid #ecf0f4; + margin-top: -1px; + margin-right: -1px; + &.act { + background-color: #f7f9fa; + } + .special-note-check-box { + display: flex; + align-items: center; + .check-name { + font-size: 13px; + color: #45576f; + cursor: pointer; + line-height: 1.3; + } + } + } +} + +.calculation-estimate { + border: 1px solid #ecf0f4; + border-radius: 3px; + padding: 24px; + height: 170px; + overflow-y: auto; + margin-bottom: 30px; + dl { + margin-bottom: 35px; + &:last-child { + margin-bottom: 0; + } + dt { + font-size: 13px; + font-weight: 600; + color: #1083e3; + margin-bottom: 15px; + } + dd { + font-size: 12px; + font-weight: 400; + color: #45576f; + margin-bottom: 8px; + &:last-child { + margin-bottom: 0; + } + } + } + &::-webkit-scrollbar { + width: 4px; + background-color: transparent; + } + &::-webkit-scrollbar-thumb { + background-color: #d9dee2; + } + &::-webkit-scrollbar-track { + background-color: transparent; + } +} +.esimate-wrap { + margin-bottom: 20px; +} + +.estimate-product-option { + display: flex; + align-items: center; + margin-bottom: 15px; + .product-price-wrap { + display: flex; + align-items: center; + .product-price-tit { + font-size: 13px; + font-weight: 400; + color: #45576f; + margin-right: 10px; + } + .select-wrap { + width: 150px; + } + } + .product-edit-wrap { + display: flex; + align-items: center; + margin-left: auto; + .product-edit-explane { + display: flex; + align-items: center; + margin-right: 15px; + .explane-item { + position: relative; + display: flex; + align-items: center; + padding: 0 10px; + font-size: 12px; + font-weight: 400; + span { + width: 20px; + height: 20px; + margin-right: 5px; + background-size: cover; + background-repeat: no-repeat; + background-position: center; + } + &:before { + content: ''; + position: absolute; + top: 50%; + left: 0; + transform: translateY(-50%); + width: 1px; + height: 12px; + background-color: #d9d9d9; + } + &:first-child { + padding-left: 0; + &::before { + display: none; + } + } + &:last-child { + padding-right: 0; + } + &.item01 { + color: #3bbb48; + span { + background-image: url(../../public/static/images/sub/open_ico.svg); + } + } + &.item02 { + color: #909000; + span { + background-image: url(../../public/static/images/sub/change_ico.svg); + } + } + &.item03 { + color: #0191c9; + span { + background-image: url(../../public/static/images/sub/attachment_ico.svg); + } + } + &.item04 { + color: #f16a6a; + span { + background-image: url(../../public/static/images/sub/click_check_ico.svg); + } + } + } + } + .product-edit-btn { + display: flex; + align-items: center; + button { + display: flex; + align-items: center; + span { + width: 13px; + height: 13px; + margin-right: 5px; + background-size: cover; + &.plus { + background: url(../../public/static/images/sub/plus_btn.svg) no-repeat center; + } + &.minus { + background: url(../../public/static/images/sub/minus_btn.svg) no-repeat center; + } + } + } + } + } +} + +// 발전시물레이션 +.chart-wrap { + display: flex; + gap: 20px; + width: 100%; + .sub-table-box { + height: 100%; + } + .chart-inner { + flex: 1; + .chart-box { + margin-bottom: 30px; + } + } + .chart-table-wrap { + display: flex; + flex-direction: column; + flex: none; + width: 650px; + .sub-table-box { + flex: 1; + &:first-child { + margin-bottom: 20px; + } + } + } +} + +.chart-month-table { + table { + table-layout: fixed; + border-collapse: collapse; + border: 1px solid #ecf0f4; + border-radius: 4px; + thead { + th { + padding: 4.5px 0; + border-bottom: 1px solid #ecf0f4; + text-align: center; + font-size: 13px; + color: #45576f; + font-weight: 500; + background-color: #f8f9fa; + } + } + tbody { + td { + font-size: 13px; + color: #45576f; + text-align: center; + padding: 4.5px 0; + } + } + } +} + +.simulation-guide-wrap { + display: flex; + padding: 20px; + .simulation-tit-wrap { + flex: none; + padding-right: 40px; + border-right: 1px solid #eeeeee; + span { + display: block; + position: relative; + padding-left: 60px; + font-size: 15px; + color: #14324f; + font-weight: 600; + &::before { + content: ''; + position: absolute; + top: 50%; + left: 0; + transform: translateY(-50%); + width: 40px; + height: 40px; + background: url(../../public/static/images/sub/simulation_guide.svg) no-repeat center; + background-size: cover; + } + } + } + .simulation-guide-box { + flex: 1; + padding-left: 40px; + dl { + margin-bottom: 25px; + dt { + font-size: 13px; + color: #101010; + font-weight: 600; + margin-bottom: 5px; + } + dd { + font-size: 12px; + color: #45576f; + font-weight: 400; + line-height: 24px; + } + &:last-child { + margin-bottom: 0; + } + } + ul, + ol { + list-style: unset; + } + } +} + +.module-total { + display: flex; + align-items: center; + background-color: #f8f9fa; + padding: 9px 0; + margin-right: 4px; + border: 1px solid #ecf0f4; + border-top: none; + .total-title { + flex: 1; + text-align: center; + font-size: 13px; + color: #344356; + font-weight: 500; + } + .total-num { + flex: none; + width: 121px; + text-align: center; + font-size: 15px; + color: #344356; + font-weight: 500; + } +} + +// 물건상세 +.information-help-wrap { + display: flex; + padding: 24px; + background-color: #f4f4f4; + border-radius: 4px; + margin-bottom: 15px; + .information-help-tit-wrap { + position: relative; + display: flex; + align-items: center; + padding-right: 40px; + border-right: 1px solid #e0e0e3; + .help-tit-icon { + width: 40px; + height: 40px; + border-radius: 50%; + margin-right: 10px; + background: #fff url(../../public/static/images/sub/information_help.svg) no-repeat center; + background-size: 20px 20px; + } + .help-tit { + font-size: 13px; + font-weight: 600; + color: #45576f; + } + } + .information-help-guide { + padding-left: 40px; + span { + display: block; + font-size: 12px; + font-weight: 400; + color: #45576f; + margin-bottom: 7px; + &:last-child { + margin-bottom: 0; + } + } + } +} + +.community-search-warp { + display: flex; + flex-direction: column; + align-items: center; + padding: 10px 0 30px 0; + border-bottom: 1px solid #e5e5e5; + margin-bottom: 24px; + .community-search-box { + position: relative; + display: flex; + align-items: center; + width: 580px; + height: 45px; + padding: 0 45px 0 20px; + margin-bottom: 20px; + border-radius: 2px; + border: 1px solid #101010; + .community-input { + width: 100%; + height: 100%; + font-size: 13px; + font-weight: 400; + color: #101010; + &::placeholder { + color: #c8c8c8; + } + } + .community-search-ico { + position: absolute; + top: 50%; + right: 20px; + transform: translateY(-50%); + flex: none; + width: 21px; + height: 100%; + background: url(../../public/static/images/sub/community_search.svg) no-repeat center; + background-size: 21px 21px; + z-index: 3; + } + } + .community-search-keyword { + font-size: 13px; + font-weight: 400; + color: #45576f; + span { + font-weight: 600; + color: #f16a6a; + } + } +} + +// 자료 다운로드 +.file-down-list { + display: grid; + grid-template-columns: repeat(3, 1fr); + gap: 14px; + .file-down-item { + display: flex; + align-items: center; + padding: 24px; + border-radius: 4px; + border: 1px solid #e5e5e5; + background: #fff; + transition: all 0.15s ease-in-out; + .file-item-info { + .item-num { + display: inline-block; + padding: 6px 17.5px; + border-radius: 60px; + background-color: #f4f4f7; + font-size: 13px; + font-weight: 600; + color: #101010; + margin-bottom: 15px; + } + .item-name { + font-size: 16px; + color: #101010; + font-weight: 500; + margin-bottom: 13px; + } + .item-date { + font-size: 13px; + font-weight: 400; + color: #344356; + } + } + .file-down-box { + display: flex; + align-items: center; + flex: none; + margin-left: auto; + height: 100%; + .file-down-btn { + width: 36px; + height: 36px; + background: url(../../public/static/images/sub/file_down_btn.svg) no-repeat center; + background-size: cover; + } + } + &:hover { + background-color: #f4f4f7; + } + } +} + +.file-down-nodata { + display: flex; + align-items: center; + justify-content: center; + width: 100%; + height: 148px; + padding: 24px; + border-radius: 4px; + border: 1px solid #e5e5e5; + font-size: 16px; + font-weight: 500; + color: #344356; } //신규물건 등록 -.product-input-wrap{ - display: flex; - align-items: center; - width: 200px; - height: 30px; - background-color: #FAFAFA; - border: 1px solid #EEE; - padding: 0 10px; - input{ - font-size: 13px; - font-weight: 400; - color: #999999; - padding: 0; - width: 100%; - height: 100%; - flex: 1 ; - background-color: inherit; - } - .product-delete{ - flex: none; - display: block; - width: 15px; - height: 100%; - background: url(../../public/static/images/sub/product-del.svg)no-repeat center; - background-size: 15px 15px; - } +.product-input-wrap { + display: flex; + align-items: center; + width: 200px; + height: 30px; + background-color: #fafafa; + border: 1px solid #eee; + padding: 0 10px; + input { + font-size: 13px; + font-weight: 400; + color: #999999; + padding: 0; + width: 100%; + height: 100%; + flex: 1; + background-color: inherit; + } + .product-delete { + flex: none; + display: block; + width: 15px; + height: 100%; + background: url(../../public/static/images/sub/product-del.svg) no-repeat center; + background-size: 15px 15px; + } } @media screen and (max-width: 1800px) { - .canvas-menu-wrap{ - .canvas-menu-inner{ - .canvas-menu-list{ - .canvas-menu-item button{ - .menu-icon{ - margin-right: 5px; - } - } - .canvas-menu-item{ - button{ - padding: 15px 15px; - font-size: 11px; - } - } - } + .canvas-menu-wrap { + .canvas-menu-inner { + .canvas-menu-list { + .canvas-menu-item button { + .menu-icon { + margin-right: 5px; + } } - .canvas-depth2-wrap{ - .canvas-depth2-inner{ - .canvas-depth2-list{ - .canvas-depth2-item{ - button{ - font-size: 11px; - } - } - } - } + .canvas-menu-item { + button { + padding: 15px 15px; + font-size: 11px; + } } - } + } + } + .canvas-depth2-wrap { + .canvas-depth2-inner { + .canvas-depth2-list { + .canvas-depth2-item { + button { + font-size: 11px; + } + } + } + } + } + } } @media screen and (max-width: 1600px) { - .canvas-menu-wrap{ - .canvas-menu-inner{ - .canvas-menu-list{ - .canvas-menu-item button{ - .menu-icon{ - display: none; - } - } - } + .canvas-menu-wrap { + .canvas-menu-inner { + .canvas-menu-list { + .canvas-menu-item button { + .menu-icon { + display: none; + } } - } - .canvas-content{ - .canvas-frame{ - height: calc(100vh - 129.5px); - } - &.active{ - .canvas-frame{ - height: calc(100vh - 179.5px); - } - } - } + } + } + } + .canvas-content { + .canvas-frame { + height: calc(100vh - 129.5px); + } + &.active { + .canvas-frame { + height: calc(100vh - 179.5px); + } + } + } } @media screen and (max-width: 1500px) { - .canvas-menu-wrap{ - .canvas-menu-inner{ - .canvas-menu-list{ - .canvas-menu-item{ - button{ - padding: 15px 10px; - font-size: 10px; - } - } - } - .canvas-side-btn-wrap{ - .btn-from{ - gap: 3px; - } - .vertical-horizontal{ - margin-right: 3px; - min-width: 150px; - } - .select-box{ - width: 100px; - margin: 0 3px; - } - .size-control{ - width: 90px; - margin: 0 3px; - } - } + .canvas-menu-wrap { + .canvas-menu-inner { + .canvas-menu-list { + .canvas-menu-item { + button { + padding: 15px 10px; + font-size: 10px; + } } + } + .canvas-side-btn-wrap { + .btn-from { + gap: 3px; + } + .vertical-horizontal { + margin-right: 3px; + min-width: 150px; + } + .select-box { + width: 100px; + margin: 0 3px; + } + .size-control { + width: 90px; + margin: 0 3px; + } + } } - .sub-header{ - .sub-header-inner{ - .sub-header-title{ - font-size: 15px; + } + .sub-header { + .sub-header-inner { + .sub-header-title { + font-size: 15px; + } + .sub-header-title-wrap { + .title-item { + a { + .icon { + width: 20px; + height: 20px; } - .sub-header-title-wrap{ - .title-item{ - a{ - .icon{ - width: 20px; - height: 20px; - } - } - } - } - } - } - + } + } + } + } + } } From f5f8eb9460051697db0bd74c4b5e06add1becae2 Mon Sep 17 00:00:00 2001 From: ysCha Date: Wed, 27 May 2026 10:03:56 +0900 Subject: [PATCH 31/38] =?UTF-8?q?[2173]=20=EC=B2=98=EB=A7=88/=EC=BC=80?= =?UTF-8?q?=EB=9D=BC=EB=B0=94=20=ED=86=A0=EA=B8=80=20KERAB-SIMPLE=20?= =?UTF-8?q?=EC=95=8C=EA=B3=A0=EB=A6=AC=EC=A6=98=20+=20offset-surgical=20he?= =?UTF-8?q?lper(flag=20off)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/components/common/input/CalcInput.jsx | 26 +- src/hooks/roofcover/useAuxiliaryDrawing.js | 4 + src/hooks/roofcover/useEavesGableEdit.js | 2097 ++++++++++++++++++-- src/hooks/useEvent.js | 4 + src/hooks/useMode.js | 4 + src/util/kerab-offset-surgical.js | 157 ++ src/util/skeleton-utils.js | 4 +- 7 files changed, 2117 insertions(+), 179 deletions(-) create mode 100644 src/util/kerab-offset-surgical.js diff --git a/src/components/common/input/CalcInput.jsx b/src/components/common/input/CalcInput.jsx index a92b86c7..6a26174d 100644 --- a/src/components/common/input/CalcInput.jsx +++ b/src/components/common/input/CalcInput.jsx @@ -77,7 +77,7 @@ export const CalculatorInput = forwardRef( newDisplayValue = calculator.currentOperand setDisplayValue(newDisplayValue) - onChange(newDisplayValue) + onChange?.(newDisplayValue) requestAnimationFrame(() => { if (inputRef.current) { @@ -145,7 +145,7 @@ export const CalculatorInput = forwardRef( newDisplayValue = calculator.currentOperand setDisplayValue(newDisplayValue) if (!hasOperation) { - onChange(calculator.currentOperand) + onChange?.(calculator.currentOperand) } } else if (num === '.') { if (!calculator.currentOperand.includes('.')) { @@ -153,7 +153,7 @@ export const CalculatorInput = forwardRef( newDisplayValue = calculator.currentOperand setDisplayValue(newDisplayValue) if (!hasOperation) { - onChange(newDisplayValue) + onChange?.(newDisplayValue) } } } else if (!shouldPreventInput(calculator.currentOperand)) { @@ -161,7 +161,7 @@ export const CalculatorInput = forwardRef( newDisplayValue = calculator.currentOperand setDisplayValue(newDisplayValue) if (!hasOperation) { - onChange(newDisplayValue) + onChange?.(newDisplayValue) } } // else { @@ -169,7 +169,7 @@ export const CalculatorInput = forwardRef( // newDisplayValue = calculator.currentOperand // setDisplayValue(newDisplayValue) // if (!hasOperation) { - // onChange(newDisplayValue) + // onChange?.(newDisplayValue) // } // } } @@ -236,7 +236,7 @@ export const CalculatorInput = forwardRef( const displayValue = newValue || '0' setDisplayValue(displayValue) setHasOperation(false) - onChange(displayValue) + onChange?.(displayValue) // 커서를 텍스트 끝으로 이동하고 스크롤 처리 requestAnimationFrame(() => { if (inputRef.current) { @@ -260,7 +260,7 @@ export const CalculatorInput = forwardRef( setDisplayValue(resultStr) setHasOperation(false) // Only call onChange with the final result - onChange(resultStr) + onChange?.(resultStr) // 엔터키로 호출된 경우 포커스 설정하지 않음 if (!fromEnterKey) { @@ -282,7 +282,7 @@ export const CalculatorInput = forwardRef( const displayValue = newValue || '0' setDisplayValue(displayValue) setHasOperation(!!calculator.operation) - onChange(displayValue) + onChange?.(displayValue) // 커서를 텍스트 끝으로 이동하고 스크롤 처리 requestAnimationFrame(() => { if (inputRef.current) { @@ -337,10 +337,10 @@ export const CalculatorInput = forwardRef( calculator.currentOperand = filteredValue setHasOperation(false) // 연산자가 없는 순수 숫자일 때만 부모 컴포넌트의 onChange 호출 - onChange(filteredValue) + onChange?.(filteredValue) } - //onChange(filteredValue) + //onChange?.(filteredValue) } } @@ -485,7 +485,7 @@ export const CalculatorInput = forwardRef( onClick={() => { // const newValue = calculatorRef.current.clear() // setDisplayValue(newValue || '0') - // onChange(newValue || '0') + // onChange?.(newValue || '0') handleClear() setHasOperation(false) }} @@ -497,7 +497,7 @@ export const CalculatorInput = forwardRef( onClick={() => { // const newValue = calculatorRef.current.deleteNumber() // setDisplayValue(newValue || '0') - // onChange(newValue || '0') + // onChange?.(newValue || '0') //setHasOperation(!!calculatorRef.current.operation) handleDelete() }} @@ -532,7 +532,7 @@ export const CalculatorInput = forwardRef(