diff --git a/src/components/estimate/Estimate.jsx b/src/components/estimate/Estimate.jsx index 9107dde0..6e10a1de 100644 --- a/src/components/estimate/Estimate.jsx +++ b/src/components/estimate/Estimate.jsx @@ -1360,11 +1360,10 @@ export default function Estimate({}) { + {session?.storeLvl !== '2' && ( - {/* 1차 판매점명 */} {getMessage('estimate.detail.saleStoreId')} {estimateContextState?.firstSaleStoreName} - {/* 견적일 */} {getMessage('estimate.detail.estimateDate')} * @@ -1374,6 +1373,19 @@ export default function Estimate({}) { + )} + {session?.storeLvl === '2' && ( + + + {getMessage('estimate.detail.estimateDate')} * + + +
+ +
+ + + )} {/* 2차 판매점명 */} {getMessage('estimate.detail.otherSaleStoreId')} diff --git a/src/components/floor-plan/modal/basic/step/Trestle.jsx b/src/components/floor-plan/modal/basic/step/Trestle.jsx index e208a286..4725962d 100644 --- a/src/components/floor-plan/modal/basic/step/Trestle.jsx +++ b/src/components/floor-plan/modal/basic/step/Trestle.jsx @@ -7,6 +7,7 @@ import { currentAngleTypeSelector, pitchTextSelector } from '@/store/canvasAtom' import { roofsState } from '@/store/roofAtom' import { moduleSelectionDataState, selectedModuleState } from '@/store/selectedModuleOptions' import { forwardRef, useContext, useEffect, useImperativeHandle, useRef, useState } from 'react' +import { logger } from '@/util/logger' import { useRecoilState, useRecoilValue } from 'recoil' import Swal from 'sweetalert2' import { normalizeDigits } from '@/util/input-utils' @@ -175,6 +176,19 @@ const Trestle = forwardRef((props, ref) => { const existingConstruction = constructionList.find((construction) => construction.constTp === trestleState.constTp) if (existingConstruction) { setSelectedConstruction(existingConstruction) + // 설치 불가능한 공법이면 강제 해제 + if (existingConstruction.cvrYn !== 'Y') setCvrChecked(false) + if (existingConstruction.snowGdPossYn !== 'Y') setSnowGdChecked(false) + const resolvedCvrChecked = existingConstruction.cvrYn === 'Y' ? cvrChecked : false + const resolvedSnowGdChecked = existingConstruction.snowGdPossYn === 'Y' ? snowGdChecked : false + logger.debug('[Trestle handleConstruction] 복원', { + index: constructionList.findIndex((c) => c.constTp === existingConstruction.constTp), + constNm: existingConstruction.constNm ?? existingConstruction.constTp, + 'cvrYn (설치가능여부)': existingConstruction.cvrYn, + 'cvrChecked (사용자선택)': resolvedCvrChecked, + 'snowGdPossYn (설치가능여부)': existingConstruction.snowGdPossYn, + 'snowGdChecked (사용자선택)': resolvedSnowGdChecked, + }) } else if (autoSelectStep === 'construction') { // 자동 선택: 첫 번째 가능한 construction 선택 const availableConstructions = constructionList.filter((construction) => construction.constPossYn === 'Y') @@ -411,10 +425,24 @@ const Trestle = forwardRef((props, ref) => { }, }) - setCvrYn(constructionList[index].cvrYn) - setSnowGdPossYn(constructionList[index].snowGdPossYn) - setCvrChecked(true) - setSnowGdChecked(false) + const newCvrYn = constructionList[index].cvrYn + const newCvrChecked = newCvrYn === 'Y' + const newSnowGdPossYn = constructionList[index].snowGdPossYn + const newSnowGdChecked = newSnowGdPossYn === 'Y' + + setCvrYn(newCvrYn) + setSnowGdPossYn(newSnowGdPossYn) + setCvrChecked(newCvrChecked) + setSnowGdChecked(newSnowGdChecked) + + logger.debug('[Trestle handleConstruction]', { + index, + constNm: constructionList[index].constNm, + 'cvrYn (설치가능여부)': newCvrYn, + 'cvrChecked (사용자선택)': newCvrChecked, + 'snowGdPossYn (설치가능여부)': newSnowGdPossYn, + 'snowGdChecked (사용자선택)': newSnowGdChecked, + }) } } @@ -442,10 +470,10 @@ const Trestle = forwardRef((props, ref) => { ...constructionList.find((data) => data.constTp === trestleState.constTp), cvrYn: cvrYn, snowGdPossYn: snowGdPossYn, - cvrChecked: cvrChecked, - snowGdChecked: snowGdChecked, - setupCover: cvrChecked ?? false, - setupSnowCover: snowGdChecked ?? false, + cvrChecked: cvrYn === 'Y' ? cvrChecked : false, + snowGdChecked: snowGdPossYn === 'Y' ? snowGdChecked : false, + setupCover: cvrYn === 'Y' ? (cvrChecked ?? false) : false, + setupSnowCover: snowGdPossYn === 'Y' ? (snowGdChecked ?? false) : false, }, trestleDetail: trestleDetail, } @@ -481,10 +509,10 @@ const Trestle = forwardRef((props, ref) => { ...constructionList.find((data) => trestleState.constTp === data.constTp), cvrYn, snowGdPossYn, - cvrChecked, - snowGdChecked, - setupCover: cvrChecked ?? false, - setupSnowCover: snowGdChecked ?? false, + cvrChecked: cvrYn === 'Y' ? cvrChecked : false, + snowGdChecked: snowGdPossYn === 'Y' ? snowGdChecked : false, + setupCover: cvrYn === 'Y' ? (cvrChecked ?? false) : false, + setupSnowCover: snowGdPossYn === 'Y' ? (snowGdChecked ?? false) : false, }, trestleDetail: trestleDetail, } @@ -861,7 +889,11 @@ const Trestle = forwardRef((props, ref) => { disabled={!cvrYn || cvrYn === 'N'} checked={!cvrYn || cvrYn === 'N' ? false : cvrChecked ?? true} // onChange={() => dispatch({ type: 'SET_TRESTLE_DETAIL', roof: { ...trestleState, cvrChecked: !trestleState.cvrChecked } })} - onChange={() => setCvrChecked(!cvrChecked)} + onChange={() => { + const next = !cvrChecked + setCvrChecked(next) + logger.debug('[처마커버 체크 변경]', { 'cvrYn (설치가능여부)': cvrYn, 'cvrChecked (사용자선택)': next }) + }} /> @@ -872,7 +904,11 @@ const Trestle = forwardRef((props, ref) => { disabled={!snowGdPossYn || snowGdPossYn === 'N'} checked={snowGdChecked || false} // onChange={() => dispatch({ type: 'SET_TRESTLE_DETAIL', roof: { ...trestleState, snowGdChecked: !trestleState.snowGdChecked } })} - onChange={() => setSnowGdChecked(!snowGdChecked)} + onChange={() => { + const next = !snowGdChecked + setSnowGdChecked(next) + logger.debug('[눈막이 체크 변경]', { 'snowGdPossYn (설치가능여부)': snowGdPossYn, 'snowGdChecked (사용자선택)': next }) + }} /> diff --git a/src/components/floor-plan/modal/circuitTrestle/CircuitTrestleSetting.jsx b/src/components/floor-plan/modal/circuitTrestle/CircuitTrestleSetting.jsx index c98b75f0..fe7b8e11 100644 --- a/src/components/floor-plan/modal/circuitTrestle/CircuitTrestleSetting.jsx +++ b/src/components/floor-plan/modal/circuitTrestle/CircuitTrestleSetting.jsx @@ -149,9 +149,9 @@ export default function CircuitTrestleSetting({ id }) { return { x, y, - width: Math.floor(width), - height: Math.floor(height), - area: Math.floor(width) * Math.floor(height), + width: Math.round(width), + height: Math.round(height), + area: Math.round(width) * Math.round(height), } }) @@ -227,7 +227,7 @@ export default function CircuitTrestleSetting({ id }) { // 인접 모듈 여부 확인 const isAdjacent = (current, target) => { const gap = getGap(current, target) - return gap >= 0 && gap <= primaryInterval + 1 + return gap >= 0 && gap <= primaryInterval + 2 } // 정렬된 모듈 순회 @@ -238,7 +238,7 @@ export default function CircuitTrestleSetting({ id }) { const directTargets = findTargetModules(current, secondaryInterval) const closestTarget = getClosestTarget(directTargets) - if (closestTarget && isAdjacent(current, closestTarget) && closestTarget.area > current.area) { + if (closestTarget && isAdjacent(current, closestTarget) && closestTarget.area - current.area > 1) { return false // 검증 실패 } @@ -264,7 +264,7 @@ export default function CircuitTrestleSetting({ id }) { }) const closestHalf = getClosestTarget(findHalfTarget) - if (closestHalf && isAdjacent(current, closestHalf) && closestHalf.area > current.area) { + if (closestHalf && isAdjacent(current, closestHalf) && closestHalf.area - current.area > 1) { return false // 검증 실패 } } diff --git a/src/hooks/module/useModuleTabContents.js b/src/hooks/module/useModuleTabContents.js index f6c46314..f7484bdb 100644 --- a/src/hooks/module/useModuleTabContents.js +++ b/src/hooks/module/useModuleTabContents.js @@ -5,6 +5,7 @@ import { useMasterController } from '@/hooks/common/useMasterController' import { useCommonCode } from '@/hooks/common/useCommonCode' import { moduleSelectionDataState, moduleSelectionInitParamsState, selectedModuleState } from '@/store/selectedModuleOptions' import { isObjectNotEmpty, isEqualObjects } from '@/util/common-utils' +import { logger } from '@/util/logger' export function useModuleTabContents({ tabIndex, addRoof, setAddedRoofs, roofTab, tempModuleSelectionData, setTempModuleSelectionData }) { const globalPitchText = useRecoilValue(pitchTextSelector) //피치 텍스트 @@ -285,17 +286,58 @@ export function useModuleTabContents({ tabIndex, addRoof, setAddedRoofs, roofTab selectedConstruction.setupSnowCover = false //눈막이금구 설치 여부 selectedConstruction.selectedIndex = index + logger.debug('[handleConstruction] 공법 선택', { + index, + constNm: selectedConstruction.constNm, + cvrYn: selectedConstruction.cvrYn, + snowGdPossYn: selectedConstruction.snowGdPossYn, + moduleConstructionSelectionData, + }) + setCvrYn(selectedConstruction.cvrYn) setSnowGdPossYn(selectedConstruction.snowGdPossYn) - //기존에 선택된 데이터가 있으면 체크한다 + //기존에 선택된 데이터가 있으면 복원 if (moduleConstructionSelectionData && moduleConstructionSelectionData.construction) { selectedConstruction.setupCover = moduleConstructionSelectionData.construction.setupCover || false selectedConstruction.setupSnowCover = moduleConstructionSelectionData.construction.setupSnowCover || false setCvrChecked(selectedConstruction.setupCover) setSnowGdChecked(selectedConstruction.setupSnowCover) + logger.debug('[handleConstruction] 기존 데이터 복원', { + setupCover: selectedConstruction.setupCover, + setupSnowCover: selectedConstruction.setupSnowCover, + }) + } else { + // 최초 선택: 설치 가능하면 디폴트 체크 + const defaultCvr = selectedConstruction.cvrYn === 'Y' + const defaultSnowGd = selectedConstruction.snowGdPossYn === 'Y' + selectedConstruction.setupCover = defaultCvr + selectedConstruction.setupSnowCover = defaultSnowGd + setCvrChecked(defaultCvr) + setSnowGdChecked(defaultSnowGd) + logger.debug('[handleConstruction] 최초 선택 디폴트', { + defaultCvr, + defaultSnowGd, + }) } + // 설치 불가능한 공법이면 강제 해제 + if (selectedConstruction.cvrYn !== 'Y') { + selectedConstruction.setupCover = false + setCvrChecked(false) + logger.debug('[handleConstruction] 처마커버 강제 해제 (cvrYn !== Y)') + } + if (selectedConstruction.snowGdPossYn !== 'Y') { + selectedConstruction.setupSnowCover = false + setSnowGdChecked(false) + logger.debug('[handleConstruction] 눈막이 강제 해제 (snowGdPossYn !== Y)') + } + + logger.debug('[handleConstruction] 최종 상태', { + setupCover: selectedConstruction.setupCover, + setupSnowCover: selectedConstruction.setupSnowCover, + }) + setSelectedConstruction(selectedConstruction) } else { constructionRef.current.forEach((ref) => { diff --git a/src/hooks/surface/useSurfaceShapeBatch.js b/src/hooks/surface/useSurfaceShapeBatch.js index 2283bcb5..ac9d5fd1 100644 --- a/src/hooks/surface/useSurfaceShapeBatch.js +++ b/src/hooks/surface/useSurfaceShapeBatch.js @@ -1,22 +1,16 @@ 'use client' import { useRecoilState, useRecoilValue, useResetRecoilState } from 'recoil' -import { - canvasSettingState, - canvasState, - currentCanvasPlanState, - currentObjectState, - globalPitchState, -} from '@/store/canvasAtom' +import { canvasSettingState, canvasState, currentCanvasPlanState, currentObjectState, globalPitchState } from '@/store/canvasAtom' import { LINE_TYPE, MENU, POLYGON_TYPE } from '@/common/common' -import { getIntersectionPoint } from '@/util/canvas-util' +import { getDegreeByChon, getIntersectionPoint } from '@/util/canvas-util' import { degreesToRadians } from '@turf/turf' import { QPolygon } from '@/components/fabric/QPolygon' import { useSwal } from '@/hooks/useSwal' import { useMessage } from '@/hooks/useMessage' import { useEvent } from '@/hooks/useEvent' import { usePopup } from '@/hooks/usePopup' -import { roofDisplaySelector } from '@/store/settingAtom' +import { basicSettingState, roofDisplaySelector } from '@/store/settingAtom' import { usePolygon } from '@/hooks/usePolygon' import { fontSelector } from '@/store/fontAtom' import { slopeSelector } from '@/store/commonAtom' @@ -31,10 +25,12 @@ import SizeSetting from '@/components/floor-plan/modal/object/SizeSetting' import { v4 as uuidv4 } from 'uuid' import { useState } from 'react' import { useUndoRedo } from '@/hooks/useUndoRedo' +import { fabric } from 'fabric' export function useSurfaceShapeBatch({ isHidden, setIsHidden }) { const { getMessage } = useMessage() const { drawDirectionArrow, addPolygon, addLengthText, setPolygonLinesActualSize } = usePolygon() + const roofSizeSet = useRecoilValue(basicSettingState).roofSizeSet const lengthTextFont = useRecoilValue(fontSelector('lengthText')) const resetOuterLinePoints = useResetRecoilState(outerLinePointsState) const resetPlacementShapeDrawingPoints = useResetRecoilState(placementShapeDrawingPointsState) @@ -94,9 +90,76 @@ export function useSurfaceShapeBatch({ isHidden, setIsHidden }) { let obj = null let points = [] + // azimuth로부터 direction 결정 + let direction = 'south' + if (azimuth === 'left') direction = 'west' + else if (azimuth === 'right') direction = 'east' + else if (azimuth === 'up') direction = 'north' + + // after:render 콜백 - 매 렌더링 후 temp 수치 텍스트를 ctx에 직접 그림 + let tempLengthData = null // { transformedPoints, direction } + const afterRenderHandler = () => { + if (!tempLengthData) return + const ctx = canvas?.getContext('2d') + if (!ctx) return + + const { tPoints, dir } = tempLengthData + const vpt = canvas.viewportTransform + const zoom = vpt[0] + const oX = vpt[4] + const oY = vpt[5] + + ctx.save() + ctx.font = `bold ${(lengthTextFont.fontSize.value || 14) * zoom}px sans-serif` + ctx.fillStyle = 'black' + ctx.textAlign = 'center' + ctx.textBaseline = 'middle' + + tPoints.forEach((start, i) => { + const end = tPoints[(i + 1) % tPoints.length] + const dx = end.x - start.x + const dy = end.y - start.y + const planeSize = Math.round(Math.sqrt(dx * dx + dy * dy) * 10) + + let actualSize = planeSize + const lineDir = Math.abs(dx) > Math.abs(dy) ? (dx > 0 ? 'right' : 'left') : dy > 0 ? 'bottom' : 'top' + if (+roofSizeSet === 1 && globalPitch > 0) { + const pitchDeg = getDegreeByChon(globalPitch) + const isV = Math.abs(start.x - end.x) < 1 + const isH = Math.abs(start.y - end.y) < 1 + const isD = !isH && !isV + if (dir === 'south' || dir === 'north') { + if (isV) actualSize = Math.round(planeSize / Math.cos((pitchDeg * Math.PI) / 180)) + else if (isD) { + const h = Math.abs(dy) * 10 * Math.tan((pitchDeg * Math.PI) / 180) + actualSize = Math.round(Math.sqrt(h * h + planeSize * planeSize)) + } + } else if (dir === 'west' || dir === 'east') { + if (isH) actualSize = Math.round(planeSize / Math.cos((pitchDeg * Math.PI) / 180)) + else if (isD) { + const h = Math.abs(dx) * 10 * Math.tan((pitchDeg * Math.PI) / 180) + actualSize = Math.round(Math.sqrt(h * h + planeSize * planeSize)) + } + } + } + + const displayValue = (+roofSizeSet === 1 ? actualSize : planeSize).toString() + let tx = (start.x + end.x) / 2 + let ty = (start.y + end.y) / 2 + if (lineDir === 'right') ty += 15 + else if (lineDir === 'left') ty -= 15 + else if (lineDir === 'top') tx += 15 + else tx -= 25 + + ctx.fillText(displayValue, tx * zoom + oX, ty * zoom + oY) + }) + ctx.restore() + } + //일단 팝업을 가린다 if (checkSurfaceShape(surfaceId, { length1, length2, length3, length4, length5 })) { + canvas?.on('after:render', afterRenderHandler) addCanvasMouseEventListener('mouse:move', (e) => { if (!isDrawing) { return @@ -127,7 +190,7 @@ export function useSurfaceShapeBatch({ isHidden, setIsHidden }) { pitch: globalPitch, } - obj = new QPolygon(points, options) + obj = new fabric.Polygon(points, options) let imageRotate = 0 if (xInversion && !yInversion) { @@ -161,12 +224,25 @@ export function useSurfaceShapeBatch({ isHidden, setIsHidden }) { obj.setCoords() //좌표 변경 적용 canvas?.add(obj) + + // 회전/반전이 적용된 실제 좌표를 계산하여 after:render에서 그릴 데이터 설정 + const matrix = obj.calcTransformMatrix() + const tPoints = obj.get('points').map((p) => { + const pt = new fabric.Point(p.x - obj.pathOffset.x, p.y - obj.pathOffset.y) + return fabric.util.transformPoint(pt, matrix) + }) + tempLengthData = { tPoints, dir: direction } + canvas?.renderAll() }) addCanvasMouseEventListener('mouse:down', (e) => { isDrawing = false + // after:render 핸들러 해제 및 temp 수치 데이터 초기화 + tempLengthData = null + canvas?.off('after:render', afterRenderHandler) + const { xInversion, yInversion } = surfaceRefs canvas?.remove(obj) @@ -188,8 +264,12 @@ export function useSurfaceShapeBatch({ isHidden, setIsHidden }) { } //회전, flip등이 먹은 기준으로 새로생성 - // const batchSurface = addPolygon(reorderedPoints, { - const batchSurface = addPolygon(obj.getCurrentPoints(), { + const objMatrix = obj.calcTransformMatrix() + const finalPoints = obj.get('points').map((p) => { + const pt = new fabric.Point(p.x - obj.pathOffset.x, p.y - obj.pathOffset.y) + return fabric.util.transformPoint(pt, objMatrix) + }) + const batchSurface = addPolygon(finalPoints, { fill: 'transparent', stroke: 'red', strokeWidth: 3, @@ -231,6 +311,7 @@ export function useSurfaceShapeBatch({ isHidden, setIsHidden }) { // console.log('yInversion', yInversion) //좌우반전 changeSurfaceLineType(batchSurface) + setPolygonLinesActualSize(batchSurface, true) if (setIsHidden) setIsHidden(false) }) @@ -1589,6 +1670,11 @@ export function useSurfaceShapeBatch({ isHidden, setIsHidden }) { drawDirectionArrow(currentObject) setTimeout(() => { changeSurfaceLineType(currentObject) + // 회전된 배치면뿐 아니라 모든 배치면의 길이를 재계산 + const allRoofs = canvas.getObjects().filter((o) => o.name === POLYGON_TYPE.ROOF && o.type === 'QPolygon') + allRoofs.forEach((roof) => { + setPolygonLinesActualSize(roof, true) + }) currentObject.dirty = true currentObject.setCoords() canvas.renderAll() diff --git a/src/util/skeleton-utils.js b/src/util/skeleton-utils.js index 8ee92c3c..a7868622 100644 --- a/src/util/skeleton-utils.js +++ b/src/util/skeleton-utils.js @@ -324,7 +324,7 @@ const movingLineFromSkeleton = (roofId, canvas) => { const cleaned = removeNonOrthogonalPoints(newPoints); - console.log(cleaned); // 결과: 4개의 점만 남음 (P1, P2, P3, P4) + // console.log(cleaned) return cleaned; } @@ -380,127 +380,98 @@ export const skeletonBuilder = (roofId, canvas, textMode) => { const hipLines = canvas.getObjects().filter((object) => object.name === 'hip' && object.parentId === roofId) || [] const ridgeLines = canvas.getObjects().filter((object) => object.name === 'ridge' && object.parentId === roofId) || [] - //const skeletonLines = []; - // 1. 지붕 폴리곤 좌표 전처리 + // 1. 지붕 폴리곤 유효성 검사 const coordinates = preprocessPolygonCoordinates(roof.points) if (coordinates.length < 3) { console.warn('Polygon has less than 3 unique points. Cannot generate skeleton.') return } - const moveFlowLine = roof.moveFlowLine || 0 // Provide a default value - const moveUpDown = roof.moveUpDown || 0 // Provide a default value + const moveFlowLine = roof.moveFlowLine || 0 + const moveUpDown = roof.moveUpDown || 0 + // 닫힌 폴리곤(첫점 = 끝점)인 경우 마지막 중복 점 제거 const isClosedPolygon = (points) => points.length > 1 && isSamePoint(points[0], points[points.length - 1]) + // roofLinePoints : drawRoofPolygon에서 baseLine + 각 변의 offset으로 계산된 실제 처마 외곽선 + // orderedBaseLinePoints: 외벽선(baseLine) 꼭짓점을 연결 순서대로 정렬 + const roofLinePoints = isClosedPolygon(roof.points) ? roof.points.slice(0, -1) : roof.points const orderedBaseLinePoints = isClosedPolygon(baseLinePoints) ? baseLinePoints.slice(0, -1) : baseLinePoints - // baseLine 폴리곤의 중심 계산 (offset 방향 결정용) + // 2. 각 baseLine 꼭짓점 → 대응하는 roofLine 꼭짓점 방향벡터 계산 + // 각 변의 offset이 달라도 방향은 항상 baseLine → roofLine (바깥쪽) + const contactData = orderedBaseLinePoints.map((point, index) => { + const roofPoint = roofLinePoints[index] + if (!roofPoint) return { dx: 0, dy: 0, signDx: 0, signDy: 0 } + + const dx = roofPoint.x - point.x + const dy = roofPoint.y - point.y + return { dx, dy, signDx: Math.sign(dx), signDy: Math.sign(dy) } + }) + + // 3. maxStep: 모든 꼭짓점 중 가장 큰 45도 대각 step 값 + // - 각 꼭짓점의 min(|dx|, |dy|) = 해당 꼭짓점에서 45도 방향으로 확장 가능한 거리 + // - 전체에서 max를 취해 SkeletonBuilder 입력 다각형이 충분히 확장되도록 보장 + // - 모든 꼭짓점에 동일한 maxStep을 적용하므로 각 변의 offset이 달라도 + // SkeletonBuilder 입력은 항상 대칭 다각형이 되어 스켈레톤이 안정적으로 생성됨 + const maxStep = contactData.reduce((max, data) => { + return Math.max(max, Math.min(Math.abs(data.dx), Math.abs(data.dy))) + }, 0) + + // baseLine 폴리곤 중심점 (dx 또는 dy가 0인 꼭짓점의 확장 방향 보정에 사용) const centroid = orderedBaseLinePoints.reduce((acc, p) => { acc.x += p.x / orderedBaseLinePoints.length acc.y += p.y / orderedBaseLinePoints.length return acc }, { x: 0, y: 0 }) - // baseLine을 offset만큼 바깥으로 평행이동 - const offsetLine = (line, index) => { - const sp = line.startPoint, ep = line.endPoint - let offset = line.attributes?.offset ?? 0 + // 4. 모든 꼭짓점을 동일한 maxStep으로 45도 대각 방향 확장 + // - dx 또는 dy가 0인 경우(한 축 이동만 있는 꼭짓점): centroid 기준 바깥 방향으로 보정 + let roofLineContactPoints = orderedBaseLinePoints.map((point, index) => { + const data = contactData[index] - // 모든 offset이 동일할 때 수치적 안정성을 위해 약간의 변화 추가 - if (baseLines.every(l => (l.attributes?.offset ?? 0) === offset)) { - offset += index * 0.01 // 각 라인에 약간의 차이 추가 - } - - const edx = ep.x - sp.x, edy = ep.y - sp.y - const len = Math.hypot(edx, edy) - if (len === 0) return { sp: { ...sp }, ep: { ...ep } } - - // 수직 벡터 계산 (항상 바깥 방향) - let nx = -edy / len, ny = edx / len - - // 방향 결정 로직 제거 - 항상 바깥으로 확장 - // if (nx * (centroid.x - mid.x) + ny * (centroid.y - mid.y) > 0) { nx = -nx; ny = -ny } + let signDx = data.signDx + let signDy = data.signDy + if (signDx === 0) signDx = point.x >= centroid.x ? 1 : -1 + if (signDy === 0) signDy = point.y >= centroid.y ? 1 : -1 return { - sp: { x: sp.x + nx * offset, y: sp.y + ny * offset }, - ep: { x: ep.x + nx * offset, y: ep.y + ny * offset } + x: point.x + signDx * maxStep, + y: point.y + signDy * maxStep } - } - - // 두 직선의 교차점 - const lineIntersect = (a1, a2, b1, b2) => { - const denom = (a1.x - a2.x) * (b1.y - b2.y) - (a1.y - a2.y) * (b1.x - b2.x) - if (Math.abs(denom) < 0.001) return null - const t = ((a1.x - b1.x) * (b1.y - b2.y) - (a1.y - b1.y) * (b1.x - b2.x)) / denom - return { x: a1.x + t * (a2.x - a1.x), y: a1.y + t * (a2.y - a1.y) } - } - - // 각 꼭짓점에서 인접 두 baseLine의 offset 교차점으로 확장 좌표 계산 - let changRoofLinePoints = orderedBaseLinePoints.map((point) => { - const adjLines = baseLines.filter(line => - isSamePoint(point, line.startPoint) || isSamePoint(point, line.endPoint) - ) - if (adjLines.length < 2) return { ...point } - const idx1 = baseLines.indexOf(adjLines[0]) - const idx2 = baseLines.indexOf(adjLines[1]) - const oL1 = offsetLine(adjLines[0], idx1), oL2 = offsetLine(adjLines[1], idx2) - return lineIntersect(oL1.sp, oL1.ep, oL2.sp, oL2.ep) || { ...point } }) - // 중복 좌표 및 일직선 위의 불필요한 점 제거 (L자 확장 시 사각형으로 단순화) - // const simplifyPolygon = (pts) => { - // let result = [...pts] + // 5. changRoofLinePoints: SkeletonBuilder에 입력할 최종 다각형 + // - roofLineContactPoints 방향으로 maxContactDistance(= √2 × maxStep) 만큼 확장 + // - 45도 대각 방향(signDx, signDy 각 ±1)이므로 실제 x·y 이동량은 각 축으로 maxStep + const maxContactDistance = Math.hypot(maxStep, maxStep) - // // 복잡한 형태 보호를 위해 최소 6개 점 유지 - // if (result.length <= 6) return result + let changRoofLinePoints = orderedBaseLinePoints.map((point, index) => { + const contactPoint = roofLineContactPoints[index] + if (!contactPoint) return point - // let changed = true - // while (changed) { - // changed = false - // for (let i = result.length - 1; i >= 0; i--) { - // const next = result[(i + 1) % result.length] - // if (Math.abs(result[i].x - next.x) < 1.0 && Math.abs(result[i].y - next.y) < 1.0) { - // result.splice(i, 1) - // changed = true - // } - // } - // // 최소 6개 점 유지 확인 - // if (result.length <= 6) break + const dx = contactPoint.x - point.x + const dy = contactPoint.y - point.y + const len = Math.hypot(dx, dy) + if (len === 0 || maxContactDistance === 0) return point - // for (let i = result.length - 1; i >= 0 && result.length > 3; i--) { - // const prev = result[(i - 1 + result.length) % result.length] - // const curr = result[i] - // const next = result[(i + 1) % result.length] - // const cross = (curr.x - prev.x) * (next.y - prev.y) - (curr.y - prev.y) * (next.x - prev.x) - // // 복잡한 형태 보호를 위해 임계값 대폭 증가 - // if (Math.abs(cross) < 50) { - // result.splice(i, 1) - // changed = true - // } - // } - // } - // return result - // } - // changRoofLinePoints = simplifyPolygon(changRoofLinePoints) + return { + x: point.x + (dx / len) * maxContactDistance, + y: point.y + (dy / len) * maxContactDistance + } + }) - let roofLineContactPoints = [...changRoofLinePoints] - - console.log('baseLinePoints:', orderedBaseLinePoints) - console.log('changRoofLinePoints:', changRoofLinePoints) - - //마루이동 + // 6. 마루이동(용마루 위치 수동 조정)이 설정된 경우 movingLineFromSkeleton 결과로 대체 if (moveFlowLine !== 0 || moveUpDown !== 0) { const movedPoints = movingLineFromSkeleton(roofId, canvas) roofLineContactPoints = movedPoints changRoofLinePoints = movedPoints } - // changRoofLinePoints 좌표를 roof.skeletonPoints에 저장 (원본 roof.points는 유지) + // changRoofLinePoints를 roof.skeletonPoints에 저장 (roof.points 원본은 유지) roof.skeletonPoints = changRoofLinePoints.map(p => ({ x: p.x, y: p.y })) - console.log('points:', changRoofLinePoints) const geoJSONPolygon = toGeoJSON(changRoofLinePoints) try { @@ -511,7 +482,7 @@ export const skeletonBuilder = (roofId, canvas, textMode) => { // 스켈레톤 데이터를 기반으로 내부선 생성 roof.innerLines = roof.innerLines || [] roof.innerLines = createInnerLinesFromSkeleton(roofId, canvas, skeleton, textMode) - console.log("roofInnerLines:::", roof.innerLines); + //console.log("roofInnerLines:::", roof.innerLines); // 캔버스에 스켈레톤 상태 저장 if (!canvas.skeletonStates) { canvas.skeletonStates = {}