diff --git a/src/components/floor-plan/modal/roofAllocation/ContextRoofAllocationSetting.jsx b/src/components/floor-plan/modal/roofAllocation/ContextRoofAllocationSetting.jsx
index 2ce64be7..78597844 100644
--- a/src/components/floor-plan/modal/roofAllocation/ContextRoofAllocationSetting.jsx
+++ b/src/components/floor-plan/modal/roofAllocation/ContextRoofAllocationSetting.jsx
@@ -86,7 +86,7 @@ export default function ContextRoofAllocationSetting(props) {
return (
-
+
{pitchText}
diff --git a/src/components/floor-plan/modal/roofAllocation/RoofAllocationSetting.jsx b/src/components/floor-plan/modal/roofAllocation/RoofAllocationSetting.jsx
index 32364844..0e0e09ee 100644
--- a/src/components/floor-plan/modal/roofAllocation/RoofAllocationSetting.jsx
+++ b/src/components/floor-plan/modal/roofAllocation/RoofAllocationSetting.jsx
@@ -86,7 +86,7 @@ export default function RoofAllocationSetting(props) {
return (
{pitchText}
diff --git a/src/hooks/module/useModuleBasicSetting.js b/src/hooks/module/useModuleBasicSetting.js
index 933a423e..a0392407 100644
--- a/src/hooks/module/useModuleBasicSetting.js
+++ b/src/hooks/module/useModuleBasicSetting.js
@@ -15,6 +15,7 @@ import {
} from '@/store/canvasAtom'
import { calculateVisibleModuleHeight, getDegreeByChon, polygonToTurfPolygon, rectToPolygon, toFixedWithoutRounding } from '@/util/canvas-util'
+import '@/util/fabric-extensions' // fabric 객체들에 getCurrentPoints 메서드 추가
import { basicSettingState, roofDisplaySelector } from '@/store/settingAtom'
import offsetPolygon, { calculateAngle, createLinesFromPolygon } from '@/util/qpolygon-utils'
import { QPolygon } from '@/components/fabric/QPolygon'
@@ -265,14 +266,14 @@ export function useModuleBasicSetting(tabNum) {
batchObjects.forEach((obj) => {
//도머일때
if (obj.name === BATCH_TYPE.TRIANGLE_DORMER || obj.name === BATCH_TYPE.PENTAGON_DORMER) {
- const groupPoints = obj.groupPoints
+ const groupPoints = obj.getCurrentPoints()
const offsetObjects = offsetPolygon(groupPoints, 10)
const dormerOffset = new QPolygon(offsetObjects, batchObjectOptions)
dormerOffset.setViewLengthText(false)
canvas.add(dormerOffset) //모듈설치면 만들기
} else {
//개구, 그림자일때
- const points = obj.points
+ const points = obj.getCurrentPoints()
const offsetObjects = offsetPolygon(points, 10)
const offset = new QPolygon(offsetObjects, batchObjectOptions)
offset.setViewLengthText(false)
@@ -319,7 +320,7 @@ export function useModuleBasicSetting(tabNum) {
const margin = moduleSelectionData.common.margin ? moduleSelectionData.common.margin : 200
//육지붕일때는 그냥 하드코딩
- offsetPoints = offsetPolygon(roof.points, -Number(margin) / 10) //육지붕일때
+ offsetPoints = offsetPolygon(roof.getCurrentPoints(), -Number(margin) / 10) //육지붕일때
} else {
//육지붕이 아닐때
if (allPointsOutside) {
diff --git a/src/hooks/object/useObjectBatch.js b/src/hooks/object/useObjectBatch.js
index 959a0798..ef2cf587 100644
--- a/src/hooks/object/useObjectBatch.js
+++ b/src/hooks/object/useObjectBatch.js
@@ -675,6 +675,8 @@ export function useObjectBatch({ isHidden, setIsHidden }) {
}
})
+ objectGroup.recalculateGroupPoints()
+
isDown = false
initEvent()
// dbClickEvent()
@@ -1426,7 +1428,7 @@ export function useObjectBatch({ isHidden, setIsHidden }) {
//그림자는 아무데나 설치 할 수 있게 해달라고 함
if (obj.name === BATCH_TYPE.OPENING) {
- const turfObject = pointsToTurfPolygon(obj.points)
+ const turfObject = pointsToTurfPolygon(obj.getCurrentPoints())
if (turf.booleanWithin(turfObject, turfSurface)) {
obj.set({
@@ -1459,7 +1461,7 @@ export function useObjectBatch({ isHidden, setIsHidden }) {
const calcLeft = obj.left - originLeft
const calcTop = obj.top - originTop
- const currentDormerPoints = obj.groupPoints.map((item) => {
+ const currentDormerPoints = obj.getCurrentPoints().map((item) => {
return {
x: item.x + calcLeft,
y: item.y + calcTop,
diff --git a/src/hooks/roofcover/useRoofAllocationSetting.js b/src/hooks/roofcover/useRoofAllocationSetting.js
index 3fef0ee9..e6d7a825 100644
--- a/src/hooks/roofcover/useRoofAllocationSetting.js
+++ b/src/hooks/roofcover/useRoofAllocationSetting.js
@@ -174,22 +174,32 @@ export function useRoofAllocationSetting(id) {
})
}
+ const firstRes = Array.isArray(res) && res.length > 0 ? res[0] : null
+
setBasicSetting({
...basicSetting,
- planNo: res[0].planNo,
- roofSizeSet: res[0].roofSizeSet,
- roofAngleSet: res[0].roofAngleSet,
+ planNo: firstRes?.planNo ?? planNo,
+ roofSizeSet: firstRes?.roofSizeSet ?? 0,
+ roofAngleSet: firstRes?.roofAngleSet ?? 0,
roofsData: roofsArray,
selectedRoofMaterial: selectRoofs.find((roof) => roof.selected),
})
setBasicInfo({
- planNo: '' + res[0].planNo,
- roofSizeSet: '' + res[0].roofSizeSet,
- roofAngleSet: '' + res[0].roofAngleSet,
+ planNo: '' + (firstRes?.planNo ?? planNo),
+ roofSizeSet: '' + (firstRes?.roofSizeSet ?? 0),
+ roofAngleSet: '' + (firstRes?.roofAngleSet ?? 0),
})
- //데이터 동기화
- setCurrentRoofList(selectRoofs)
+ // 데이터 동기화: 렌더링용 필드 기본값 보정
+ const normalizedRoofs = selectRoofs.map((roof) => ({
+ ...roof,
+ width: roof.width ?? '',
+ length: roof.length ?? '',
+ hajebichi: roof.hajebichi ?? '',
+ pitch: roof.pitch ?? '',
+ angle: roof.angle ?? '',
+ }))
+ setCurrentRoofList(normalizedRoofs)
})
} catch (error) {
console.error('Data fetching error:', error)
diff --git a/src/hooks/surface/useSurfaceShapeBatch.js b/src/hooks/surface/useSurfaceShapeBatch.js
index 874526f8..e42a7025 100644
--- a/src/hooks/surface/useSurfaceShapeBatch.js
+++ b/src/hooks/surface/useSurfaceShapeBatch.js
@@ -1,9 +1,9 @@
'use client'
import { useRecoilValue, useResetRecoilState } from 'recoil'
-import { canvasSettingState, canvasState, currentCanvasPlanState, globalPitchState } from '@/store/canvasAtom'
+import { canvasSettingState, canvasState, currentCanvasPlanState, currentObjectState, globalPitchState } from '@/store/canvasAtom'
import { LINE_TYPE, MENU, POLYGON_TYPE } from '@/common/common'
-import { getIntersectionPoint, toFixedWithoutRounding } from '@/util/canvas-util'
+import { getIntersectionPoint } from '@/util/canvas-util'
import { degreesToRadians } from '@turf/turf'
import { QPolygon } from '@/components/fabric/QPolygon'
import { useSwal } from '@/hooks/useSwal'
@@ -21,10 +21,13 @@ import { placementShapeDrawingPointsState } from '@/store/placementShapeDrawingA
import { getBackGroundImage } from '@/lib/imageActions'
import { useCanvasSetting } from '@/hooks/option/useCanvasSetting'
import { useText } from '@/hooks/useText'
+import SizeSetting from '@/components/floor-plan/modal/object/SizeSetting'
+import { v4 as uuidv4 } from 'uuid'
+import { useState } from 'react'
export function useSurfaceShapeBatch({ isHidden, setIsHidden }) {
const { getMessage } = useMessage()
- const { drawDirectionArrow, addPolygon, addLengthText } = usePolygon()
+ const { drawDirectionArrow, addPolygon, addLengthText, setPolygonLinesActualSize } = usePolygon()
const lengthTextFont = useRecoilValue(fontSelector('lengthText'))
const resetOuterLinePoints = useResetRecoilState(outerLinePointsState)
const resetPlacementShapeDrawingPoints = useResetRecoilState(placementShapeDrawingPointsState)
@@ -36,11 +39,13 @@ export function useSurfaceShapeBatch({ isHidden, setIsHidden }) {
const { swalFire } = useSwal()
const { addCanvasMouseEventListener, initEvent } = useEvent()
// const { addCanvasMouseEventListener, initEvent } = useContext(EventContext)
- const { addPopup, closePopup } = usePopup()
+ const { addPopup, closePopup, closeAll } = usePopup()
const { setSurfaceShapePattern } = useRoofFn()
const { changeCorridorDimensionText } = useText()
const currentCanvasPlan = useRecoilValue(currentCanvasPlanState)
const { fetchSettings } = useCanvasSetting(false)
+ const currentObject = useRecoilValue(currentObjectState)
+ const [popupId, setPopupId] = useState(uuidv4())
const applySurfaceShape = (surfaceRefs, selectedType, id) => {
let length1, length2, length3, length4, length5
@@ -879,6 +884,7 @@ export function useSurfaceShapeBatch({ isHidden, setIsHidden }) {
drawDirectionArrow(roof)
changeCorridorDimensionText()
addLengthText(roof)
+ roof.setCoords()
initEvent()
canvas.renderAll()
})
@@ -916,71 +922,138 @@ export function useSurfaceShapeBatch({ isHidden, setIsHidden }) {
}
const resizeSurfaceShapeBatch = (side, target, width, height) => {
- const originTarget = { ...target }
+ if (!target || target.type !== 'QPolygon') return
- const objectWidth = target.width
- const objectHeight = target.height
- const changeWidth = width / 10 / objectWidth
- const changeHeight = height / 10 / objectHeight
- let sideX = 'left'
- let sideY = 'top'
+ width = width / 10
+ height = height / 10
- //그룹 중심점 변경
- if (side === 2) {
- sideX = 'right'
- sideY = 'top'
- } else if (side === 3) {
- sideX = 'left'
- sideY = 'bottom'
- } else if (side === 4) {
- sideX = 'right'
- sideY = 'bottom'
+ // 현재 QPolygon의 점들 가져오기 (변형 적용된 실제 좌표)
+ const currentPoints = target.getCurrentPoints() || []
+ const angle = target.angle % 360
+ if (currentPoints.length === 0) return
+
+ // 현재 바운딩 박스 계산
+ let minX = Math.min(...currentPoints.map((p) => p.x))
+ let maxX = Math.max(...currentPoints.map((p) => p.x))
+ let minY = Math.min(...currentPoints.map((p) => p.y))
+ let maxY = Math.max(...currentPoints.map((p) => p.y))
+
+ let currentWidth = maxX - minX
+ let currentHeight = maxY - minY
+
+ // 회전에 관계없이 단순한 앵커 포인트 계산
+ let anchorX, anchorY
+ switch (side) {
+ case 1: // left-top
+ anchorX = minX
+ anchorY = minY
+ break
+ case 2: // right-top
+ anchorX = maxX
+ anchorY = minY
+ break
+ case 3: // left-bottom
+ anchorX = minX
+ anchorY = maxY
+ break
+ case 4: // right-bottom
+ anchorX = maxX
+ anchorY = maxY
+ break
+ default:
+ return
}
- //변경 전 좌표
- const newCoords = target.getPointByOrigin(sideX, sideY)
+ // 목표 크기 (회전에 관계없이 동일하게 적용)
+ let targetWidth = width
+ let targetHeight = height
- target.set({
- originX: sideX,
- originY: sideY,
- left: newCoords.x,
- top: newCoords.y,
+ // 새로운 점들 계산 - 앵커 포인트는 고정, 나머지는 비례적으로 확장
+ // 각도와 side에 따라 확장 방향 결정
+ const newPoints = currentPoints.map((point) => {
+ // 앵커 포인트 기준으로 새로운 위치 계산
+ // side와 각도에 관계없이 일관된 방식으로 처리
+
+ // 앵커 포인트에서 각 점까지의 절대 거리
+ const deltaX = point.x - anchorX
+ const deltaY = point.y - anchorY
+
+ // 새로운 크기에 맞춰 비례적으로 확장
+ const newDeltaX = (deltaX / currentWidth) * targetWidth
+ const newDeltaY = (deltaY / currentHeight) * targetHeight
+
+ const newX = anchorX + newDeltaX
+ const newY = anchorY + newDeltaY
+
+ return {
+ x: newX, // 소수점 1자리로 반올림
+ y: newY,
+ }
})
- target.scaleX = changeWidth
- target.scaleY = changeHeight
-
- const currentPoints = target.getCurrentPoints()
-
- target.set({
+ // 기존 객체의 속성들을 복사 (scale은 1로 고정)
+ const originalOptions = {
+ stroke: target.stroke,
+ strokeWidth: target.strokeWidth,
+ fill: target.fill,
+ opacity: target.opacity,
+ visible: target.visible,
+ selectable: target.selectable,
+ evented: target.evented,
+ hoverCursor: target.hoverCursor,
+ moveCursor: target.moveCursor,
+ lockMovementX: target.lockMovementX,
+ lockMovementY: target.lockMovementY,
+ lockRotation: target.lockRotation,
+ lockScalingX: target.lockScalingX,
+ lockScalingY: target.lockScalingY,
+ lockUniScaling: target.lockUniScaling,
+ name: target.name,
+ uuid: target.uuid,
+ roofType: target.roofType,
+ roofMaterial: target.roofMaterial,
+ azimuth: target.azimuth,
+ // tilt: target.tilt,
+ // angle: target.angle,
+ // scale은 항상 1로 고정
scaleX: 1,
scaleY: 1,
- width: toFixedWithoutRounding(width / 10, 1),
- height: toFixedWithoutRounding(height / 10, 1),
- })
- //크기 변경후 좌표를 재 적용
- const changedCoords = target.getPointByOrigin(originTarget.originX, originTarget.originY)
-
- target.set({
- originX: originTarget.originX,
- originY: originTarget.originY,
- left: changedCoords.x,
- top: changedCoords.y,
- })
- canvas.renderAll()
-
- //면형상 리사이즈시에만
- target.fire('polygonMoved')
- target.points = currentPoints
- target.fire('modified')
-
- setSurfaceShapePattern(target, roofDisplay.column, false, target.roofMaterial, true)
-
- if (target.direction) {
- drawDirectionArrow(target)
+ lines: target.lines,
+ // 기타 모든 사용자 정의 속성들
+ ...Object.fromEntries(
+ Object.entries(target).filter(
+ ([key, value]) =>
+ !['type', 'left', 'top', 'width', 'height', 'scaleX', 'scaleY', 'points', 'lines', 'texts', 'canvas', 'angle', 'tilt'].includes(key) &&
+ typeof value !== 'function',
+ ),
+ ),
}
- target.setCoords()
- canvas.renderAll()
+
+ // 기존 QPolygon 제거
+ canvas.remove(target)
+
+ // 새로운 QPolygon 생성 (scale은 1로 고정됨)
+ const newPolygon = new QPolygon(newPoints, originalOptions, canvas)
+
+ newPolygon.set({
+ originWidth: width,
+ originHeight: height,
+ })
+
+ // 캔버스에 추가
+ canvas.add(newPolygon)
+
+ // 선택 상태 유지
+ canvas.setActiveObject(newPolygon)
+
+ newPolygon.fire('modified')
+ setSurfaceShapePattern(newPolygon, null, null, newPolygon.roofMaterial)
+ drawDirectionArrow(newPolygon)
+ newPolygon.setCoords()
+ changeSurfaceLineType(newPolygon)
+ canvas?.renderAll()
+ closeAll()
+ addPopup(popupId, 1, )
}
const changeSurfaceLinePropertyEvent = () => {
@@ -1364,6 +1437,95 @@ export function useSurfaceShapeBatch({ isHidden, setIsHidden }) {
return orderedPoints
}
+ const rotateSurfaceShapeBatch = () => {
+ if (currentObject) {
+ // 관련 객체들 찾기
+ // arrow는 제거
+ const arrow = canvas.getObjects().find((obj) => obj.parentId === currentObject.id && obj.name === 'arrow')
+ if (arrow) {
+ canvas.remove(arrow)
+ }
+
+ const relatedObjects = canvas.getObjects().filter((obj) => obj.parentId === currentObject.id)
+
+ // 그룹화할 객체들 배열 (currentObject + relatedObjects)
+ const objectsToGroup = [currentObject, ...relatedObjects]
+
+ // 기존 객체들을 캔버스에서 제거
+ objectsToGroup.forEach((obj) => canvas.remove(obj))
+
+ // fabric.Group 생성
+ const group = new fabric.Group(objectsToGroup, {
+ originX: 'center',
+ originY: 'center',
+ })
+
+ // 그룹을 캔버스에 추가
+ canvas.add(group)
+
+ // 현재 회전값에 90도 추가
+ const currentAngle = group.angle || 0
+ const newAngle = (currentAngle + 90) % 360
+
+ // 그룹 전체를 회전
+ group.rotate(newAngle)
+ group.setCoords()
+
+ // 그룹을 해제하고 개별 객체로 복원
+ group._restoreObjectsState()
+ canvas.remove(group)
+
+ // 개별 객체들을 다시 캔버스에 추가하고 처리
+ group.getObjects().forEach((obj) => {
+ canvas.add(obj)
+ obj.setCoords()
+
+ // currentObject인 경우 추가 처리
+ if (obj.id === currentObject.id) {
+ const originWidth = obj.originWidth
+ const originHeight = obj.originHeight
+
+ // QPolygon 내부 구조 재구성 (선이 깨지는 문제 해결)
+ if (obj.type === 'QPolygon' && obj.lines) {
+ obj.initLines()
+ }
+
+ obj.set({
+ originWidth: originHeight,
+ originHeight: originWidth,
+ })
+ } else {
+ // relatedObject인 경우에도 필요한 처리
+ if (obj.type === 'QPolygon' && obj.lines) {
+ obj.initLines()
+ }
+ if (obj.type === 'group') {
+ // 회전 후의 points를 groupPoints로 업데이트
+ // getCurrentPoints를 직접 호출하지 말고 recalculateGroupPoints만 실행
+
+ obj.recalculateGroupPoints()
+
+ obj._objects?.forEach((obj) => {
+ obj.initLines()
+ obj.fire('modified')
+ })
+ }
+ }
+ })
+ currentObject.fire('modified')
+ // 화살표와 선 다시 그리기
+ drawDirectionArrow(currentObject)
+ setTimeout(() => {
+ setPolygonLinesActualSize(currentObject)
+ changeSurfaceLineType(currentObject)
+ }, 500)
+
+ // currentObject를 다시 선택 상태로 설정
+ canvas.setActiveObject(currentObject)
+ canvas.renderAll()
+ }
+ }
+
return {
applySurfaceShape,
deleteAllSurfacesAndObjects,
@@ -1373,5 +1535,6 @@ export function useSurfaceShapeBatch({ isHidden, setIsHidden }) {
changeSurfaceLineProperty,
changeSurfaceLinePropertyReset,
changeSurfaceLineType,
+ rotateSurfaceShapeBatch,
}
}
diff --git a/src/hooks/useContextMenu.js b/src/hooks/useContextMenu.js
index fe6d800e..4580f3ee 100644
--- a/src/hooks/useContextMenu.js
+++ b/src/hooks/useContextMenu.js
@@ -1,7 +1,6 @@
import { useRecoilState, useRecoilValue, useSetRecoilState } from 'recoil'
import { canvasSettingState, canvasState, currentMenuState, currentObjectState } from '@/store/canvasAtom'
import { useEffect, useState } from 'react'
-import { MENU, POLYGON_TYPE } from '@/common/common'
import AuxiliarySize from '@/components/floor-plan/modal/auxiliary/AuxiliarySize'
import { usePopup } from '@/hooks/usePopup'
import { v4 as uuidv4 } from 'uuid'
@@ -12,11 +11,9 @@ import { gridColorState } from '@/store/gridAtom'
import { contextPopupPositionState, contextPopupState } from '@/store/popupAtom'
import AuxiliaryEdit from '@/components/floor-plan/modal/auxiliary/AuxiliaryEdit'
import SizeSetting from '@/components/floor-plan/modal/object/SizeSetting'
-import RoofMaterialSetting from '@/components/floor-plan/modal/object/RoofMaterialSetting'
import DormerOffset from '@/components/floor-plan/modal/object/DormerOffset'
import FontSetting from '@/components/common/font/FontSetting'
import RoofAllocationSetting from '@/components/floor-plan/modal/roofAllocation/RoofAllocationSetting'
-import LinePropertySetting from '@/components/floor-plan/modal/lineProperty/LinePropertySetting'
import FlowDirectionSetting from '@/components/floor-plan/modal/flowDirection/FlowDirectionSetting'
import { useCommonUtils } from './common/useCommonUtils'
@@ -29,7 +26,6 @@ import ColumnRemove from '@/components/floor-plan/modal/module/column/ColumnRemo
import ColumnInsert from '@/components/floor-plan/modal/module/column/ColumnInsert'
import RowRemove from '@/components/floor-plan/modal/module/row/RowRemove'
import RowInsert from '@/components/floor-plan/modal/module/row/RowInsert'
-import CircuitNumberEdit from '@/components/floor-plan/modal/module/CircuitNumberEdit'
import { useObjectBatch } from '@/hooks/object/useObjectBatch'
import { useSurfaceShapeBatch } from '@/hooks/surface/useSurfaceShapeBatch'
import { fontSelector, globalFontAtom } from '@/store/fontAtom'
@@ -45,6 +41,8 @@ import PlacementSurfaceLineProperty from '@/components/floor-plan/modal/placemen
import { selectedMenuState } from '@/store/menuAtom'
import { useTrestle } from './module/useTrestle'
import { useCircuitTrestle } from './useCirCuitTrestle'
+import { usePolygon } from '@/hooks/usePolygon'
+import { useText } from '@/hooks/useText'
export function useContextMenu() {
const canvas = useRecoilValue(canvasState)
@@ -64,7 +62,7 @@ export function useContextMenu() {
const [column, setColumn] = useState(null)
const { handleZoomClear } = useCanvasEvent()
const { moveObjectBatch, copyObjectBatch } = useObjectBatch({})
- const { moveSurfaceShapeBatch } = useSurfaceShapeBatch({})
+ const { moveSurfaceShapeBatch, rotateSurfaceShapeBatch } = useSurfaceShapeBatch({})
const [globalFont, setGlobalFont] = useRecoilState(globalFontAtom)
const { addLine, removeLine } = useLine()
const { removeGrid } = useGrid()
@@ -73,10 +71,12 @@ export function useContextMenu() {
const { settingsData, setSettingsDataSave } = useCanvasSetting(false)
const { swalFire } = useSwal()
const { alignModule, modulesRemove, moduleRoofRemove } = useModule()
- const { removeRoofMaterial, removeAllRoofMaterial, moveRoofMaterial, removeOuterLines } = useRoofFn()
+ const { removeRoofMaterial, removeAllRoofMaterial, moveRoofMaterial, removeOuterLines, setSurfaceShapePattern } = useRoofFn()
const selectedMenu = useRecoilValue(selectedMenuState)
const { isAllComplete, clear: resetModule } = useTrestle()
const { isExistCircuit } = useCircuitTrestle()
+ const { changeCorridorDimensionText } = useText()
+ const { setPolygonLinesActualSize, drawDirectionArrow } = usePolygon()
const currentMenuSetting = () => {
switch (selectedMenu) {
case 'outline':
@@ -170,6 +170,11 @@ export function useContextMenu() {
name: getMessage('contextmenu.size.edit'),
component: ,
},
+ {
+ id: 'rotate',
+ name: `${getMessage('contextmenu.rotate')}`,
+ fn: () => rotateSurfaceShapeBatch(),
+ },
{
id: 'roofMaterialRemove',
shortcut: ['d', 'D'],
diff --git a/src/hooks/useEvent.js b/src/hooks/useEvent.js
index dfbe492c..8caa90ea 100644
--- a/src/hooks/useEvent.js
+++ b/src/hooks/useEvent.js
@@ -213,7 +213,7 @@ export function useEvent() {
const modulePoints = []
const modules = canvas.getObjects().filter((obj) => obj.name === POLYGON_TYPE.MODULE)
modules.forEach((module) => {
- module.points.forEach((point) => {
+ module.getCurrentPoints().forEach((point) => {
modulePoints.push({ x: point.x, y: point.y })
})
})
diff --git a/src/hooks/usePlan.js b/src/hooks/usePlan.js
index ba8776d4..cdb02fef 100644
--- a/src/hooks/usePlan.js
+++ b/src/hooks/usePlan.js
@@ -173,6 +173,13 @@ export function usePlan(params = {}) {
* @param {boolean} saveAlert - 저장 완료 알림 표시 여부
*/
const saveCanvas = async (saveAlert = true) => {
+ // 저장 전 선택되어 있는 object 제거
+ const setupSurfaces = canvas.getObjects().filter((obj) => obj.name === POLYGON_TYPE.MODULE_SETUP_SURFACE)
+
+ setupSurfaces.forEach((surface) => {
+ surface.set({ fill: 'rgba(255,255,255,0.1)', strokeDashArray: [10, 4], strokeWidth: 1 })
+ })
+
const canvasStatus = currentCanvasData('save')
const result = await putCanvasStatus(canvasStatus, saveAlert)
//캔버스 저장 완료 후
diff --git a/src/locales/ja.json b/src/locales/ja.json
index 5aa83d3f..2999728f 100644
--- a/src/locales/ja.json
+++ b/src/locales/ja.json
@@ -99,6 +99,8 @@
"modal.module.basic.setting.module.construction.method": "工法",
"modal.module.basic.setting.module.under.roof": "屋根下地",
"modal.module.basic.setting.module.setting": "架台設定",
+ "modal.module.basic.setting.module.series.setting": "モジュールシリーズ",
+ "modal.module.basic.setting.module.setting2": "モジュール選択",
"modal.module.basic.setting.module.placement.area": "モジュール配置領域",
"modal.module.basic.setting.module.placement.margin": "モジュール間の間隙",
"modal.module.basic.setting.module.placement.area.eaves": "軒側",
@@ -444,6 +446,7 @@
"contextmenu.remove": "削除",
"contextmenu.remove.all": "完全削除",
"contextmenu.move": "移動",
+ "contextmenu.rotate": "回転",
"contextmenu.copy": "コピー",
"contextmenu.edit": "編集",
"contextmenu.module.vertical.align": "モジュールの垂直方向の中央揃え",
diff --git a/src/locales/ko.json b/src/locales/ko.json
index 35caabfd..4f6601cd 100644
--- a/src/locales/ko.json
+++ b/src/locales/ko.json
@@ -98,7 +98,9 @@
"modal.module.basic.setting.module.rafter.margin": "서까래 간격",
"modal.module.basic.setting.module.construction.method": "공법",
"modal.module.basic.setting.module.under.roof": "지붕밑바탕",
- "modal.module.basic.setting.module.setting": "모듈 선택",
+ "modal.module.basic.setting.module.setting": "가대 설정",
+ "modal.module.basic.setting.module.series.setting": "모듈 시리즈",
+ "modal.module.basic.setting.module.setting2": "모듈 선택",
"modal.module.basic.setting.module.placement.area": "모듈 배치 영역",
"modal.module.basic.setting.module.placement.margin": "모듈 배치 간격",
"modal.module.basic.setting.module.placement.area.eaves": "처마쪽",
@@ -444,6 +446,7 @@
"contextmenu.remove": "삭제",
"contextmenu.remove.all": "전체 삭제",
"contextmenu.move": "이동",
+ "contextmenu.rotate": "회전",
"contextmenu.copy": "복사",
"contextmenu.edit": "편집",
"contextmenu.module.vertical.align": "모듈 세로 가운데 정렬",
diff --git a/src/styles/calc.scss b/src/styles/calc.scss
index f3977c65..c80355d0 100644
--- a/src/styles/calc.scss
+++ b/src/styles/calc.scss
@@ -1,17 +1,17 @@
// Variables
$colors: (
- 'dark-700': #1f2937,
- 'dark-600': #334155,
- 'dark-500': #4b5563,
- 'dark-400': #6b7280,
- 'dark-300': #374151,
- 'primary': #10b981,
- 'primary-dark': #059669,
- 'danger': #ef4444,
- 'danger-dark': #dc2626,
- 'warning': #f59e0b,
- 'warning-dark': #d97706,
- 'border': #475569
+ 'dark-700': #1f2937,
+ 'dark-600': #334155,
+ 'dark-500': #4b5563,
+ 'dark-400': #6b7280,
+ 'dark-300': #374151,
+ 'primary': #10b981,
+ 'primary-dark': #059669,
+ 'danger': #ef4444,
+ 'danger-dark': #dc2626,
+ 'warning': #f59e0b,
+ 'warning-dark': #d97706,
+ 'border': #475569,
);
// Mixins
@@ -46,10 +46,15 @@ $colors: (
color: white;
font-weight: bold;
border-radius: 0.5rem;
- text-align: right;
+ text-align: left; // 왼쪽 정렬
cursor: pointer;
font-size: 0.625rem;
box-sizing: border-box;
+ direction: ltr; // 왼쪽에서 오른쪽으로 입력
+ white-space: nowrap; // 줄바꿈 방지
+ // input 요소에서는 overflow 대신 다른 방법 사용
+ text-overflow: clip; // 텍스트 잘림 처리
+ unicode-bidi: bidi-override; // 텍스트 방향 강제
&:focus {
outline: none;
@@ -67,7 +72,9 @@ $colors: (
width: 120px;
background-color: map-get($colors, 'dark-700');
border-radius: 0.5rem;
- box-shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.1), 0 2px 4px -1px rgba(0, 0, 0, 0.06);
+ box-shadow:
+ 0 4px 6px -1px rgba(0, 0, 0, 0.1),
+ 0 2px 4px -1px rgba(0, 0, 0, 0.06);
padding: 0.5rem;
z-index: 1000;
animation: fadeIn 0.15s ease-out;
@@ -153,4 +160,4 @@ $colors: (
padding: 0.5rem;
font-size: 0.875rem;
}
-}
\ No newline at end of file
+}
diff --git a/src/util/fabric-extensions.js b/src/util/fabric-extensions.js
new file mode 100644
index 00000000..9410f764
--- /dev/null
+++ b/src/util/fabric-extensions.js
@@ -0,0 +1,232 @@
+import { fabric } from 'fabric'
+
+/**
+ * fabric.Rect에 getCurrentPoints 메서드를 추가
+ * QPolygon의 getCurrentPoints와 동일한 방식으로 변형된 현재 점들을 반환
+ */
+fabric.Rect.prototype.getCurrentPoints = function () {
+ // 사각형의 네 모서리 점들을 계산
+ const width = this.width
+ const height = this.height
+
+ // 사각형의 로컬 좌표계에서의 네 모서리 점
+ const points = [
+ { x: -width / 2, y: -height / 2 }, // 좌상단
+ { x: width / 2, y: -height / 2 }, // 우상단
+ { x: width / 2, y: height / 2 }, // 우하단
+ { x: -width / 2, y: height / 2 }, // 좌하단
+ ]
+
+ // 변형 매트릭스 계산
+ const matrix = this.calcTransformMatrix()
+
+ // 각 점을 변형 매트릭스로 변환
+ return points.map(function (p) {
+ const point = new fabric.Point(p.x, p.y)
+ return fabric.util.transformPoint(point, matrix)
+ })
+}
+
+/**
+ * fabric.Group에 getCurrentPoints 메서드를 추가 (도머 그룹용)
+ * 그룹의 groupPoints를 다시 계산하여 반환
+ */
+fabric.Group.prototype.getCurrentPoints = function () {
+ // groupPoints를 다시 계산
+
+ // 그룹에 groupPoints가 있으면 해당 점들을 사용 (도머의 경우)
+ if (this.groupPoints && Array.isArray(this.groupPoints)) {
+ const matrix = this.calcTransformMatrix()
+ console.log('this.groupPoints', this.groupPoints)
+ return this.groupPoints.map(function (p) {
+ const point = new fabric.Point(p.x, p.y)
+ return fabric.util.transformPoint(point, matrix)
+ })
+ }
+
+ // groupPoints가 없으면 바운딩 박스를 사용
+ const bounds = this.getBoundingRect()
+ const points = [
+ { x: bounds.left, y: bounds.top },
+ { x: bounds.left + bounds.width, y: bounds.top },
+ { x: bounds.left + bounds.width, y: bounds.top + bounds.height },
+ { x: bounds.left, y: bounds.top + bounds.height },
+ ]
+
+ return points.map(function (p) {
+ return new fabric.Point(p.x, p.y)
+ })
+}
+
+/**
+ * fabric.Group에 groupPoints 재계산 메서드 추가
+ * 그룹 내 모든 객체의 점들을 기반으로 groupPoints를 새로 계산
+ * Convex Hull 알고리즘을 사용하여 가장 외곽의 점들만 반환
+ */
+fabric.Group.prototype.recalculateGroupPoints = function () {
+ if (!this._objects || this._objects.length === 0) {
+ return
+ }
+
+ let allPoints = []
+
+ // 그룹 내 모든 객체의 점들을 수집
+ this._objects.forEach(function (obj) {
+ if (obj.getCurrentPoints && typeof obj.getCurrentPoints === 'function') {
+ // getCurrentPoints가 있는 객체는 해당 메서드 사용
+ const objPoints = obj.getCurrentPoints()
+ allPoints = allPoints.concat(objPoints)
+ } else if (obj.points && Array.isArray(obj.points)) {
+ // QPolygon과 같이 points 배열이 있는 경우
+ const pathOffset = obj.pathOffset || { x: 0, y: 0 }
+ const matrix = obj.calcTransformMatrix()
+ const transformedPoints = obj.points
+ .map(function (p) {
+ return new fabric.Point(p.x - pathOffset.x, p.y - pathOffset.y)
+ })
+ .map(function (p) {
+ return fabric.util.transformPoint(p, matrix)
+ })
+ allPoints = allPoints.concat(transformedPoints)
+ } else {
+ // 일반 객체는 바운딩 박스의 네 모서리 점 사용
+ const bounds = obj.getBoundingRect()
+ const cornerPoints = [
+ { x: bounds.left, y: bounds.top },
+ { x: bounds.left + bounds.width, y: bounds.top },
+ { x: bounds.left + bounds.width, y: bounds.top + bounds.height },
+ { x: bounds.left, y: bounds.top + bounds.height },
+ ]
+ allPoints = allPoints.concat(
+ cornerPoints.map(function (p) {
+ return new fabric.Point(p.x, p.y)
+ }),
+ )
+ }
+ })
+
+ if (allPoints.length > 0) {
+ // Convex Hull 알고리즘을 사용하여 외곽 점들만 추출
+ const convexHullPoints = this.getConvexHull(allPoints)
+
+ // 그룹의 로컬 좌표계로 변환하기 위해 그룹의 역변환 적용
+ const groupMatrix = this.calcTransformMatrix()
+ const invertedMatrix = fabric.util.invertTransform(groupMatrix)
+
+ this.groupPoints = convexHullPoints.map(function (p) {
+ const localPoint = fabric.util.transformPoint(p, invertedMatrix)
+ return { x: localPoint.x, y: localPoint.y }
+ })
+ }
+}
+
+/**
+ * Graham Scan 알고리즘을 사용한 Convex Hull 계산
+ * 점들의 집합에서 가장 외곽의 점들만 반환
+ */
+fabric.Group.prototype.getConvexHull = function (points) {
+ if (points.length < 3) return points
+
+ // 중복 점 제거
+ const uniquePoints = []
+ const seen = new Set()
+
+ points.forEach(function (p) {
+ const key = `${Math.round(p.x * 10) / 10},${Math.round(p.y * 10) / 10}`
+ if (!seen.has(key)) {
+ seen.add(key)
+ uniquePoints.push({ x: p.x, y: p.y })
+ }
+ })
+
+ if (uniquePoints.length < 3) return uniquePoints
+
+ // 가장 아래쪽 점을 찾기 (y가 가장 작고, 같으면 x가 가장 작은 점)
+ let pivot = uniquePoints[0]
+ for (let i = 1; i < uniquePoints.length; i++) {
+ if (uniquePoints[i].y < pivot.y || (uniquePoints[i].y === pivot.y && uniquePoints[i].x < pivot.x)) {
+ pivot = uniquePoints[i]
+ }
+ }
+
+ // 극각에 따라 정렬
+ const sortedPoints = uniquePoints
+ .filter(function (p) { return p !== pivot })
+ .sort(function (a, b) {
+ const angleA = Math.atan2(a.y - pivot.y, a.x - pivot.x)
+ const angleB = Math.atan2(b.y - pivot.y, b.x - pivot.x)
+ if (angleA !== angleB) return angleA - angleB
+
+ // 각도가 같으면 거리로 정렬
+ const distA = Math.pow(a.x - pivot.x, 2) + Math.pow(a.y - pivot.y, 2)
+ const distB = Math.pow(b.x - pivot.x, 2) + Math.pow(b.y - pivot.y, 2)
+ return distA - distB
+ })
+
+ // Graham Scan 실행
+ const hull = [pivot]
+
+ for (let i = 0; i < sortedPoints.length; i++) {
+ const current = sortedPoints[i]
+
+ // 반시계방향이 아닌 점들 제거
+ while (hull.length > 1) {
+ const p1 = hull[hull.length - 2]
+ const p2 = hull[hull.length - 1]
+ const cross = (p2.x - p1.x) * (current.y - p1.y) - (p2.y - p1.y) * (current.x - p1.x)
+
+ if (cross > 0) break // 반시계방향이면 유지
+ hull.pop() // 시계방향이면 제거
+ }
+
+ hull.push(current)
+ }
+
+ return hull
+}
+
+/**
+ * fabric.Triangle에 getCurrentPoints 메서드를 추가
+ * 삼각형의 세 꼭짓점을 반환
+ */
+fabric.Triangle.prototype.getCurrentPoints = function () {
+ const width = this.width
+ const height = this.height
+
+ // 삼각형의 로컬 좌표계에서의 세 꼭짓점
+ const points = [
+ { x: 0, y: -height / 2 }, // 상단 중앙
+ { x: -width / 2, y: height / 2 }, // 좌하단
+ { x: width / 2, y: height / 2 }, // 우하단
+ ]
+
+ // 변형 매트릭스 계산
+ const matrix = this.calcTransformMatrix()
+
+ // 각 점을 변형 매트릭스로 변환
+ return points.map(function (p) {
+ const point = new fabric.Point(p.x, p.y)
+ return fabric.util.transformPoint(point, matrix)
+ })
+}
+
+/**
+ * fabric.Polygon에 getCurrentPoints 메서드를 추가 (QPolygon이 아닌 일반 Polygon용)
+ * QPolygon과 동일한 방식으로 구현
+ */
+if (!fabric.Polygon.prototype.getCurrentPoints) {
+ fabric.Polygon.prototype.getCurrentPoints = function () {
+ const pathOffset = this.get('pathOffset') || { x: 0, y: 0 }
+ const matrix = this.calcTransformMatrix()
+
+ return this.get('points')
+ .map(function (p) {
+ return new fabric.Point(p.x - pathOffset.x, p.y - pathOffset.y)
+ })
+ .map(function (p) {
+ return fabric.util.transformPoint(p, matrix)
+ })
+ }
+}
+
+export default {}
diff --git a/src/util/skeleton-utils.js b/src/util/skeleton-utils.js
new file mode 100644
index 00000000..ba5b4e87
--- /dev/null
+++ b/src/util/skeleton-utils.js
@@ -0,0 +1,2050 @@
+import { LINE_TYPE, POLYGON_TYPE } from '@/common/common'
+import Big from 'big.js'
+import { SkeletonBuilder } from '@/lib/skeletons'
+import { arePointsEqual, calcLineActualSize, calcLinePlaneSize, calculateAngle, toGeoJSON } from '@/util/qpolygon-utils'
+import { QLine } from '@/components/fabric/QLine'
+import { getDegreeByChon } from '@/util/canvas-util'
+
+
+/**
+ * 지붕 폴리곤의 스켈레톤(중심선)을 생성하고 캔버스에 그립니다.
+ * @param {string} roofId - 대상 지붕 객체의 ID
+ * @param {fabric.Canvas} canvas - Fabric.js 캔버스 객체
+ * @param {string} textMode - 텍스트 표시 모드
+ * @param existingSkeletonLines
+ */
+export const drawSkeletonRidgeRoof = (roofId, canvas, textMode, existingSkeletonLines = []) => {
+ const roof = canvas?.getObjects().find((object) => object.id === roofId)
+ if (!roof) {
+ console.error(`Roof with id "${roofId}" not found.`);
+ return;
+ }
+ const skeletonLines = [...existingSkeletonLines];
+ // 1. 기존 스켈레톤 라인 제거
+ // const existingSkeletonLines = canvas.getObjects().filter(obj =>
+ // obj.parentId === roofId && obj.attributes?.type === 'skeleton'
+ // );
+ // existingSkeletonLines.forEach(line => canvas.remove(line));
+
+ // 2. 지붕 폴리곤 좌표 전처리
+ const coordinates = preprocessPolygonCoordinates(roof.points);
+ if (coordinates.length < 3) {
+ console.warn("Polygon has less than 3 unique points. Cannot generate skeleton.");
+ return;
+ }
+
+
+ const wall = canvas.getObjects().find((object) => object.name === POLYGON_TYPE.WALL && object.attributes.roofId === roofId)
+
+ //평행선 여부
+ const hasNonParallelLines = roof.lines.filter((line) => Big(line.x1).minus(Big(line.x2)).gt(1) && Big(line.y1).minus(Big(line.y2)).gt(1))
+ if (hasNonParallelLines.length > 0) {
+ return
+ }
+
+
+ /** 외벽선 */
+ const baseLines = wall.baseLines.filter((line) => line.attributes.planeSize > 0)
+ const baseLinePoints = baseLines.map((line) => ({ x: line.x1, y: line.y1 }))
+
+ skeletonBuilder(roofId, canvas, textMode, roof, skeletonLines)
+
+}
+
+/**
+ * 스켈레톤의 edge를 각도가 있는 구간으로 변형합니다.
+ * @param {Object} skeleton - 스켈레톤 객체
+ * @param {number} edgeIndex - 변형할 edge의 인덱스
+ * @param {number} angleOffset - 추가할 각도 (도 단위)
+ * @param {number} splitRatio - 분할 비율 (0-1 사이, 0.5면 중간점)
+ * @returns {Object} 변형된 스켈레톤 객체
+ */
+export const transformEdgeWithAngle = (skeleton, edgeIndex, angleOffset = 45, splitRatio = 0.5) => {
+ if (!skeleton || !skeleton.Edges || edgeIndex >= skeleton.Edges.length || edgeIndex < 0) {
+ console.warn('유효하지 않은 스켈레톤 또는 edge 인덱스입니다.')
+ return skeleton
+ }
+
+ const edgeResult = skeleton.Edges[edgeIndex]
+ if (!edgeResult || !edgeResult.Polygon || !Array.isArray(edgeResult.Polygon)) {
+ console.warn('유효하지 않은 edge 또는 Polygon 데이터입니다.')
+ return skeleton
+ }
+
+ const polygonPoints = edgeResult.Polygon.map((p) => ({ x: p.X, y: p.Y }))
+
+ // 변형할 edge 찾기 (가장 긴 내부 선분을 대상으로 함)
+ let longestEdge = null
+ let longestLength = 0
+ let longestEdgeIndex = -1
+
+ for (let i = 0; i < polygonPoints.length; i++) {
+ const p1 = polygonPoints[i]
+ const p2 = polygonPoints[(i + 1) % polygonPoints.length]
+
+ const length = Math.sqrt(Math.pow(p2.x - p1.x, 2) + Math.pow(p2.y - p1.y, 2))
+
+ if (length > longestLength) {
+ longestLength = length
+ longestEdge = { p1, p2, index: i }
+ longestEdgeIndex = i
+ }
+ }
+
+ if (!longestEdge) return skeleton
+
+ // 중간점 계산
+ const midPoint = {
+ x: longestEdge.p1.x + (longestEdge.p2.x - longestEdge.p1.x) * splitRatio,
+ y: longestEdge.p1.y + (longestEdge.p2.y - longestEdge.p1.y) * splitRatio,
+ }
+
+ // 원래 선분의 방향 벡터
+ const originalVector = {
+ x: longestEdge.p2.x - longestEdge.p1.x,
+ y: longestEdge.p2.y - longestEdge.p1.y,
+ }
+
+ // 각도 변형을 위한 새로운 점 계산
+ const angleRad = (angleOffset * Math.PI) / 180
+ const perpVector = {
+ x: -originalVector.y,
+ y: originalVector.x,
+ }
+
+ // 정규화
+ const perpLength = Math.sqrt(perpVector.x * perpVector.x + perpVector.y * perpVector.y)
+ const normalizedPerp = {
+ x: perpVector.x / perpLength,
+ y: perpVector.y / perpLength,
+ }
+
+ // 각도 변형을 위한 오프셋 거리 (선분 길이의 10%)
+ const offsetDistance = longestLength * 0.1
+
+ // 새로운 각도 점
+ const anglePoint = {
+ x: midPoint.x + normalizedPerp.x * offsetDistance * Math.sin(angleRad),
+ y: midPoint.y + normalizedPerp.y * offsetDistance * Math.sin(angleRad),
+ }
+
+ // 새로운 폴리곤 점들 생성
+ const newPolygonPoints = [...polygonPoints]
+
+ // 기존 점을 제거하고 새로운 세 점으로 교체
+ newPolygonPoints.splice(longestEdgeIndex + 1, 0, anglePoint)
+
+ // 스켈레톤 객체 업데이트 - 순환 참조 문제를 방지하기 위해 안전한 복사 방식 사용
+ const newSkeleton = {
+ ...skeleton,
+ Edges: skeleton.Edges.map((edge, idx) => {
+ if (idx === edgeIndex) {
+ return {
+ ...edge,
+ Polygon: newPolygonPoints.map((p) => ({ X: p.x, Y: p.y })),
+ }
+ }
+ return edge
+ }),
+ }
+
+ return newSkeleton
+}
+
+/**
+ * 여러 edge를 한 번에 변형합니다.
+ * @param {Object} skeleton - 스켈레톤 객체
+ * @param {Array} edgeConfigs - 변형 설정 배열 [{edgeIndex, angleOffset, splitRatio}]
+ * @returns {Object} 변형된 스켈레톤 객체
+ */
+export const transformMultipleEdges = (skeleton, edgeConfigs) => {
+ let transformedSkeleton = skeleton
+
+ // 인덱스 역순으로 정렬하여 변형 시 인덱스 변화를 방지
+ edgeConfigs.sort((a, b) => b.edgeIndex - a.edgeIndex)
+
+ edgeConfigs.forEach((config) => {
+ transformedSkeleton = transformEdgeWithAngle(transformedSkeleton, config.edgeIndex, config.angleOffset || 45, config.splitRatio || 0.5)
+ })
+
+ return transformedSkeleton
+}
+
+/**
+ * 마루가 있는 지붕을 그린다.
+ * @param roofId
+ * @param canvas
+ * @param textMode
+ * @param roof
+ * @param edgeProperties
+ */
+export const skeletonBuilder = (roofId, canvas, textMode, roof, skeletonLines) => {
+ // 1. 다각형 좌표를 GeoJSON 형식으로 변환합니다.
+ const geoJSONPolygon = toGeoJSON(roof.points)
+
+ try {
+ // 2. SkeletonBuilder를 사용하여 스켈레톤을 생성합니다.
+ geoJSONPolygon.pop()
+ let skeleton = SkeletonBuilder.BuildFromGeoJSON([[geoJSONPolygon]])
+
+ console.log(`지붕 형태: ${skeleton.roof_type}`) // "complex"
+ console.log('Edge 분석:', skeleton.edge_analysis)
+
+ // 3. 라인을 그림
+ const innerLines = createInnerLinesFromSkeleton(skeleton, roof.lines, roof, canvas, textMode, skeletonLines)
+
+ console.log("innerLines::", innerLines)
+ // 4. 생성된 선들을 캔버스에 추가하고 지붕 객체에 저장
+ // innerLines.forEach((line) => {
+ // canvas.add(line)
+ // line.bringToFront()
+ // canvas.renderAll()
+ // })
+
+ roof.innerLines = innerLines
+
+ // canvas에 skeleton 상태 저장
+ if (!canvas.skeletonStates) {
+ canvas.skeletonStates = {}
+ }
+ canvas.skeletonStates[roofId] = true
+
+ canvas.renderAll()
+ } catch (e) {
+ console.error('지붕 생성 중 오류 발생:', e)
+ // 오류 발생 시 기존 로직으로 대체하거나 사용자에게 알림
+ if (canvas.skeletonStates) {
+ canvas.skeletonStates[roofId] = false
+ }
+ }
+}
+
+/**
+ * 스켈레톤 결과와 원본 외벽선 정보를 바탕으로 내부선(마루, 추녀)들을 생성합니다.
+ * @param {object} skeleton - SkeletonBuilder로부터 반환된 스켈레톤 객체
+ * @param {Array
} baseLines - 원본 외벽선 QLine 객체 배열
+ * @param {QPolygon} roof - 대상 지붕 QPolygon 객체
+ * @param {fabric.Canvas} canvas - Fabric.js 캔버스 객체
+ * @param {string} textMode - 텍스트 표시 모드 ('plane', 'actual', 'none')
+ * @returns {Array} 생성된 내부선(QLine) 배열
+ */
+// 두 선분이 같은 직선상에 있고 겹치는지 확인하는 함수
+const areLinesCollinearAndOverlapping = (line1, line2) => {
+ // 두 선분이 같은 직선상에 있는지 확인
+ const areCollinear = (p1, p2, p3, p4) => {
+ const area1 = (p2.x - p1.x) * (p3.y - p1.y) - (p2.y - p1.y) * (p3.x - p1.x)
+ const area2 = (p2.x - p1.x) * (p4.y - p1.y) - (p2.y - p1.y) * (p4.x - p1.x)
+ return Math.abs(area1) < 1 && Math.abs(area2) < 1
+ }
+
+ // 두 선분이 겹치는지 확인
+ const isOverlapping = (a1, a2, b1, b2) => {
+ // x축에 평행한 경우
+ if (Math.abs(a1.y - a2.y) < 1 && Math.abs(b1.y - b2.y) < 1) {
+ if (Math.abs(a1.y - b1.y) > 1) return false
+ return !(Math.max(a1.x, a2.x) < Math.min(b1.x, b2.x) || Math.min(a1.x, a2.x) > Math.max(b1.x, b2.x))
+ }
+ // y축에 평행한 경우
+ if (Math.abs(a1.x - a2.x) < 1 && Math.abs(b1.x - b2.x) < 1) {
+ if (Math.abs(a1.x - b1.x) > 1) return false
+ return !(Math.max(a1.y, a2.y) < Math.min(b1.y, b2.y) || Math.min(a1.y, a2.y) > Math.max(b1.y, b2.y))
+ }
+ return false
+ }
+
+ return areCollinear(line1.p1, line1.p2, line2.p1, line2.p2) && isOverlapping(line1.p1, line1.p2, line2.p1, line2.p2)
+}
+
+// 겹치는 선분을 하나로 합치는 함수
+const mergeCollinearLines = (lines) => {
+ if (lines.length <= 1) return lines
+
+ const merged = []
+ const processed = new Set()
+
+ for (let i = 0; i < lines.length; i++) {
+ if (processed.has(i)) continue
+
+ let currentLine = lines[i]
+ let mergedLine = { ...currentLine }
+ let wasMerged = false
+
+ for (let j = i + 1; j < lines.length; j++) {
+ if (processed.has(j)) continue
+
+ const otherLine = lines[j]
+
+ if (areLinesCollinearAndOverlapping(mergedLine, otherLine)) {
+ // 겹치는 선분을 하나로 합침
+ const allPoints = [
+ { x: mergedLine.p1.x, y: mergedLine.p1.y },
+ { x: mergedLine.p2.x, y: mergedLine.p2.y },
+ { x: otherLine.p1.x, y: otherLine.p1.y },
+ { x: otherLine.p2.x, y: otherLine.p2.y },
+ ]
+
+ // x축에 평행한 경우 x 좌표로 정렬
+ if (Math.abs(mergedLine.p1.y - mergedLine.p2.y) < 1) {
+ allPoints.sort((a, b) => a.x - b.x)
+ mergedLine = {
+ p1: allPoints[0],
+ p2: allPoints[allPoints.length - 1],
+ attributes: mergedLine.attributes,
+ }
+ }
+ // y축에 평행한 경우 y 좌표로 정렬
+ else {
+ allPoints.sort((a, b) => a.y - b.y)
+ mergedLine = {
+ p1: allPoints[0],
+ p2: allPoints[allPoints.length - 1],
+ attributes: mergedLine.attributes,
+ }
+ }
+
+ wasMerged = true
+ processed.add(j)
+ }
+ }
+
+ merged.push(mergedLine)
+ processed.add(i)
+ }
+
+ return merged
+}
+
+//조건에 따른 스켈레톤을 그린다.
+const createInnerLinesFromSkeleton = (skeleton, baseLines, roof, canvas, textMode, skeletonLines) => {
+ console.log('=== Edge Properties 기반 후처리 시작 ===')
+
+ if (!skeleton || !skeleton.Edges) return []
+
+ const innerLines = []
+ const processedInnerEdges = new Set()
+ //const skeletonLines = []
+
+ // 1. 기본 skeleton에서 모든 내부 선분 수집
+ //edge 순서와 baseLines 순서가 같을수가 없다.
+ for (let edgeIndex = 0; edgeIndex < skeleton.Edges.length; edgeIndex++) {
+ console.log('edgeIndex:::', edgeIndex)
+ let changeEdgeIndex = edgeIndex
+ //입력 폴리곤이 왼쪽 상단에서 시작하면 시계 방향으로 진행합니다.
+ //오른쪽 하단에서 시작하면 그 지점에서부터 시계 방향으로 진행합니다.
+ //edgeIndex 대신에 실제 baseLines 선택라인을 찾아야 한다.
+ const edgeResult = skeleton.Edges[edgeIndex]
+ console.log(edgeResult)
+ // 방향을 고려하지 않고 같은 라인인지 확인하는 함수
+
+ let edgeType = 'eaves'
+ let baseLineIndex = 0
+
+ processEavesEdge(edgeResult, baseLines, skeletonLines, processedInnerEdges)
+
+ }
+
+
+ for (let edgeIndex = 0; edgeIndex < skeleton.Edges.length; edgeIndex++) {
+
+ const edgeResult = skeleton.Edges[edgeIndex]
+ const startX = edgeResult.Edge.Begin.X
+ const startY = edgeResult.Edge.Begin.Y
+ const endX = edgeResult.Edge.End.X
+ const endY = edgeResult.Edge.End.Y
+
+
+ //외벽선 라인과 같은 edgeResult를 찾는다
+ for (let baseLineIndex = 0; baseLineIndex < baseLines.length; baseLineIndex++) {
+
+ if (baseLines[baseLineIndex].attributes.type === 'gable') {
+ // 일다 그려서 skeletonLines를 만들어
+//외벽선 동일 라인이면
+ if (isSameLine(startX, startY, endX, endY, baseLines[baseLineIndex])) {
+ processGableEdge(edgeResult, baseLines, skeletonLines, processedInnerEdges, edgeIndex, baseLineIndex) //
+ break // 매칭되는 라인을 찾았으므로 루프 종료
+ }
+ }
+
+ }
+
+
+
+
+ }
+
+ console.log(`처리된 skeletonLines: ${skeletonLines.length}개`)
+
+ // 2. 겹치는 선분 병합
+ // const mergedLines = mergeCollinearLines(skeletonLines)
+ // console.log('mergedLines', mergedLines)
+ // 3. QLine 객체로 변환
+ for (const line of skeletonLines) {
+ const { p1, p2, attributes, lineStyle } = line
+ const innerLine = new QLine([p1.x, p1.y, p2.x, p2.y], {
+ parentId: roof.id,
+ fontSize: roof.fontSize,
+ stroke: lineStyle.color,
+ strokeWidth: lineStyle?.width,
+ name: attributes.type,
+ textMode: textMode,
+ attributes: attributes,
+ })
+
+ canvas.add(innerLine)
+ innerLine.bringToFront()
+ canvas.renderAll()
+
+ innerLines.push(innerLine)
+ }
+
+ return innerLines
+}
+
+// ✅ EAVES (처마) 처리 - 기본 skeleton 모두 사용
+function processEavesEdge(edgeResult, baseLines, skeletonLines, processedInnerEdges) {
+ console.log(`processEavesEdge::`, skeletonLines)
+ const begin = edgeResult.Edge.Begin
+ const end = edgeResult.Edge.End
+
+
+ //내부 선분 수집 (스케레톤은 다각형)
+ const polygonPoints = edgeResult.Polygon.map((p) => ({ x: p.X, y: p.Y }))
+
+ for (let i = 0; i < polygonPoints.length; i++) {
+ //시계방향
+ const p1 = polygonPoints[i]
+ const p2 = polygonPoints[(i + 1) % polygonPoints.length]
+
+ // 외벽선 제외 후 추가
+ if(begin !== edgeResult.Polygon[i] && end !== edgeResult.Polygon[i] ) {
+ addRawLine(skeletonLines, processedInnerEdges, p1, p2, 'RIDGE', '#FF0000', 3)
+ }
+ }
+}
+
+// ✅ WALL (벽) 처리 - 선분 개수 최소화
+function processWallEdge(edgeResult, baseLines, skeletonLines, processedInnerEdges, edgeIndex) {
+ console.log(`WALL Edge ${edgeIndex}: 내부 선분 최소화`)
+
+ const polygonPoints = edgeResult.Polygon.map((p) => ({ x: p.X, y: p.Y }))
+
+ // 벽면은 내부 구조를 단순화 - 주요 선분만 선택
+ for (let i = 0; i < polygonPoints.length; i++) {
+ const p1 = polygonPoints[i]
+ const p2 = polygonPoints[(i + 1) % polygonPoints.length]
+
+ if (!isOuterEdge(p1, p2, baseLines)) {
+ // 선분 길이 확인 - 긴 선분만 사용 (짧은 선분 제거)
+ const lineLength = Math.sqrt((p2.x - p1.x) ** 2 + (p2.y - p1.y) ** 2)
+
+ if (lineLength > 10) {
+ // 최소 길이 조건
+ addRawLine(skeletonLines, processedInnerEdges, p1, p2, 'HIP', '#000000', 2)
+ } else {
+ console.log(`WALL: 짧은 선분 제거 (길이: ${lineLength.toFixed(1)})`)
+ }
+ }
+ }
+}
+
+// ✅ GABLE (케라바) 처리 - 직선 생성, 다른 선분 제거
+function processGableEdge(edgeResult, baseLines, skeletonLines, processedInnerEdges, edgeIndex, baseLineIndex) {
+ console.log(`GABLE Edge ${edgeResult}: 직선 skeleton 생성`)
+ const diagonalLine = []; //대각선 라인
+
+ const polygonPoints = edgeResult.Polygon.map((p) => ({ x: p.X, y: p.Y }))
+ console.log('polygonPoints::', polygonPoints)
+ // ✅ 케라바는 직선 패턴으로 변경
+
+ // 1. 기존 복잡한 skeleton 선분들 무시
+ // 2. GABLE edge에 수직인 직선 생성
+ const sourceEdge = edgeResult.Edge
+ const gableStart = { x: sourceEdge.Begin.X, y: sourceEdge.Begin.Y }
+ const gableEnd = { x: sourceEdge.End.X, y: sourceEdge.End.Y }
+
+ // GABLE edge 중점
+ const gableMidpoint = {
+ x: (gableStart.x + gableEnd.x) / 2,
+ y: (gableStart.y + gableEnd.y) / 2,
+ }
+
+ // 폴리곤 중심점 (대략적)
+ const centerX = polygonPoints.reduce((sum, p) => sum + p.x, 0) / polygonPoints.length
+ const centerY = polygonPoints.reduce((sum, p) => sum + p.y, 0) / polygonPoints.length
+ const polygonCenter = { x: centerX, y: centerY }
+
+
+
+ const selectBaseLine = baseLines[baseLineIndex];
+ console.log('selectBaseLine:', selectBaseLine);
+ console.log('skeletonLines:', skeletonLines)
+
+ // selectBaseLine의 중간 좌표 계산
+ const midPoint = {
+ x: (selectBaseLine.x1 + selectBaseLine.x2) / 2,
+ y: (selectBaseLine.y1 + selectBaseLine.y2) / 2
+ };
+ console.log('midPoint of selectBaseLine:', midPoint);
+
+ // 대각선 보정(fallback) 제거: 항상 수평/수직 내부 용마루만 생성
+ const edgePoints = edgeResult.Polygon.map(p => ({ x: p.X, y: p.Y }));
+
+ //제거
+ for (let i = skeletonLines.length - 1; i >= 0; i--) {
+ const line = skeletonLines[i];
+ console.log('line:', line)
+ console.log('line.attributes.type:', line.attributes.type)
+
+ const linePoints = [line.p1, line.p2];
+
+ // Check if both points of the line are in the edgePoints
+ const isEdgeLine = linePoints.every(point =>
+ edgePoints.some(ep =>
+ Math.abs(ep.x - point.x) < 0.001 &&
+ Math.abs(ep.y - point.y) < 0.001
+ )
+ );
+
+ if (isEdgeLine) {
+ skeletonLines.splice(i, 1);
+ }
+ }
+
+ //확장
+ const breakLinePont = findDisconnectedSkeletonLines(skeletonLines, baseLines)
+ console.log('breakLinePont:', breakLinePont)
+
+if(breakLinePont.disconnectedLines.length > 0) {
+
+
+ for (const dLine of breakLinePont.disconnectedLines) {
+ const inx = dLine.index;
+ const exLine = dLine.extendedLine;
+
+ //확장
+ if (dLine.p1Connected) {
+ skeletonLines[inx].p2 = { ...skeletonLines[inx].p2, x: exLine.p2.x, y: exLine.p2.y };
+
+ } else if (dLine.p2Connected) {
+ skeletonLines[inx].p1 = { ...skeletonLines[inx].p1, x: exLine.p1.x, y: exLine.p1.y };
+ }
+
+ }
+}
+ //확장(연장)
+// for (let i = 0; i < skeletonLines.length; i++) {
+// const line = skeletonLines[i];
+// const p1 = line.p1;
+// const p2 = line.p2;
+// const lineP1 = { x: line.p1.x, y: line.p1.y };
+// const lineP2 = { x: line.p2.x, y: line.p2.y };
+//
+// let hasP1 = false;
+// let hasP2 = false;
+// console.log('edgeResult.Edge::',edgeResult.Edge)
+// //선택한 라인과 다각형을 생성하는 라인 여부
+// const matchingLinePoint = findMatchingLinePoints(line, edgeResult.Polygon);
+// console.log(matchingLinePoint);
+//
+//
+// if(matchingLinePoint.hasMatch) {
+//
+// if (matchingLinePoint.matches[0].type === 'diagonal') {
+// console.log("lineP1:", lineP1)
+// console.log("lineP2:", lineP2)
+// const intersectionPoint = getLineIntersectionParametric(lineP1, lineP2, gableStart, gableEnd);
+// console.log('intersectionPoint:', intersectionPoint);
+// console.log('gableStart:', gableStart);
+// console.log('gableEnd:', gableEnd);
+// // 교차점이 생겼다면 절삭(교차점 이하(이상) 삭제)
+// if (!intersectionPoint) {
+// console.warn('No valid intersection point found between line and gable edge');
+// return; // or handle the null case appropriately
+// }
+//
+// if (matchingLinePoint.matches[0].linePoint === 'p1') {
+// skeletonLines[i].p1 = { ...skeletonLines[i].p1, x: intersectionPoint.x, y: intersectionPoint.y };
+// } else {
+// skeletonLines[i].p2 = { ...skeletonLines[i].p2, x: intersectionPoint.x, y: intersectionPoint.y };
+// }
+//
+// } else if (matchingLinePoint.matches[0].type === 'horizontal') {
+// if (matchingLinePoint.matches[0].linePoint === 'p1') {
+// skeletonLines[i].p1 = { ...skeletonLines[i].p1, x: edgeResult.Edge.Begin.X };
+// } else {
+// skeletonLines[i].p2 = { ...skeletonLines[i].p2, x: edgeResult.Edge.Begin.X };
+// }
+//
+// } else if (matchingLinePoint.matches[0].type === 'vertical') {
+// if (matchingLinePoint.matches[0].linePoint === 'p1') {
+// skeletonLines[i].p1 = { ...skeletonLines[i].p1, y: edgeResult.Edge.Begin.Y };
+// } else {
+// skeletonLines[i].p2 = { ...skeletonLines[i].p2, y: edgeResult.Edge.Begin.Y };
+// }
+// }
+//
+// }
+//
+// }
+
+
+}
+
+// ✅ 헬퍼 함수들
+function isOuterEdge(p1, p2, baseLines) {
+ const tolerance = 0.1
+ return baseLines.some((line) => {
+ const lineStart = line.startPoint || { x: line.x1, y: line.y1 }
+ const lineEnd = line.endPoint || { x: line.x2, y: line.y2 }
+
+ return (
+ (Math.abs(lineStart.x - p1.x) < tolerance &&
+ Math.abs(lineStart.y - p1.y) < tolerance &&
+ Math.abs(lineEnd.x - p2.x) < tolerance &&
+ Math.abs(lineEnd.y - p2.y) < tolerance) ||
+ (Math.abs(lineStart.x - p2.x) < tolerance &&
+ Math.abs(lineStart.y - p2.y) < tolerance &&
+ Math.abs(lineEnd.x - p1.x) < tolerance &&
+ Math.abs(lineEnd.y - p1.y) < tolerance)
+ )
+ })
+}
+
+function addRawLine(skeletonLines, processedInnerEdges, p1, p2, lineType, color, width) {
+ const edgeKey = [`${p1.x.toFixed(1)},${p1.y.toFixed(1)}`, `${p2.x.toFixed(1)},${p2.y.toFixed(1)}`].sort().join('|')
+
+ if (processedInnerEdges.has(edgeKey)) return
+ processedInnerEdges.add(edgeKey)
+
+ // 라인 타입을 상수로 정규화
+ const inputNormalizedType =
+ lineType === LINE_TYPE.SUBLINE.RIDGE || lineType === 'RIDGE'
+ ? LINE_TYPE.SUBLINE.RIDGE
+ : lineType === LINE_TYPE.SUBLINE.HIP || lineType === 'HIP'
+ ? LINE_TYPE.SUBLINE.HIP
+ : lineType
+
+ // 대각선 여부 판단 (수평/수직이 아닌 경우)
+ const dx = Math.abs(p2.x - p1.x)
+ const dy = Math.abs(p2.y - p1.y)
+ const tolerance = 0.1
+ const isHorizontal = dy < tolerance
+ const isVertical = dx < tolerance
+ const isDiagonal = !isHorizontal && !isVertical
+
+ // 대각선일 때 lineType을 HIP로 지정
+ const normalizedType = isDiagonal ? LINE_TYPE.SUBLINE.HIP : inputNormalizedType
+
+ skeletonLines.push({
+ p1: p1,
+ p2: p2,
+ attributes: {
+ type: normalizedType,
+ planeSize: calcLinePlaneSize({ x1: p1.x, y1: p1.y, x2: p2.x, y2: p2.y }),
+ isRidge: normalizedType === LINE_TYPE.SUBLINE.RIDGE,
+ },
+ lineStyle: {
+ color: color,
+ width: width,
+ },
+ })
+
+}
+
+/**
+ * 특정 roof의 edge를 캐라바로 설정하여 다시 그립니다.
+ * @param {string} roofId - 지붕 ID
+ * @param {fabric.Canvas} canvas - 캔버스 객체
+ * @param {string} textMode - 텍스트 모드
+ * @param {number} selectedEdgeIndex - 선택된 외곽선의 인덱스
+ */
+
+export const drawSkeletonWithTransformedEdges = (roofId, canvas, textMode, selectedEdgeIndex) => {
+ let roof = canvas?.getObjects().find((object) => object.id === roofId)
+ if (!roof) {
+ console.warn('Roof object not found')
+ return
+ }
+
+ // Clear existing inner lines if any
+ if (roof.innerLines) {
+ roof.innerLines.forEach((line) => canvas.remove(line))
+ roof.innerLines = []
+ }
+
+ // Transform the selected wall into a roof
+ transformWallToRoof(roof, canvas, selectedEdgeIndex)
+
+ canvas.renderAll()
+}
+
+/**
+ * 삼각형에 대한 캐라바 처리
+ * @param {QPolygon} roof - 지붕 객체
+ * @param {fabric.Canvas} canvas - 캔버스 객체
+ * @param {string} textMode - 텍스트 모드
+ * @param {number} selectedEdgeIndex - 선택된 외곽선의 인덱스
+ */
+const drawCarabaForTriangle = (roof, canvas, textMode, edgeIndex) => {
+ const points = roof.getCurrentPoints()
+
+ if (!points || points.length !== 3) {
+ console.warn('삼각형이 아니거나 유효하지 않은 점 데이터입니다.')
+ return
+ }
+
+ if (edgeIndex < 0 || edgeIndex >= 3) {
+ console.warn('유효하지 않은 edge 인덱스입니다.')
+ return
+ }
+
+ // 선택된 edge의 두 꼭짓점을 제외한 나머지 점 찾기
+ const oppositeVertexIndex = (edgeIndex + 2) % 3
+ const oppositeVertex = points[oppositeVertexIndex]
+
+ // 선택된 edge의 시작점과 끝점
+ const edgeStartIndex = edgeIndex
+ const edgeEndIndex = (edgeIndex + 1) % 3
+ const edgeStart = points[edgeStartIndex]
+ const edgeEnd = points[edgeEndIndex]
+
+ // 선택된 edge의 중점 계산
+ const edgeMidPoint = {
+ x: (edgeStart.x + edgeEnd.x) / 2,
+ y: (edgeStart.y + edgeEnd.y) / 2,
+ }
+
+ // 맞은편 꼭짓점에서 선택된 edge의 중점으로 가는 직선 생성
+ const carabaLine = new QLine([oppositeVertex.x, oppositeVertex.y, edgeMidPoint.x, edgeMidPoint.y], {
+ parentId: roof.id,
+ fontSize: roof.fontSize,
+ stroke: '#FF0000',
+ strokeWidth: 2,
+ name: LINE_TYPE.SUBLINE.RIDGE,
+ textMode: textMode,
+ attributes: {
+ type: LINE_TYPE.SUBLINE.RIDGE,
+ planeSize: calcLinePlaneSize({
+ x1: oppositeVertex.x,
+ y1: oppositeVertex.y,
+ x2: edgeMidPoint.x,
+ y2: edgeMidPoint.y,
+ }),
+ actualSize: calcLineActualSize(
+ {
+ x1: oppositeVertex.x,
+ y1: oppositeVertex.y,
+ x2: edgeMidPoint.x,
+ y2: edgeMidPoint.y,
+ },
+ getDegreeByChon(roof.lines[edgeIndex]?.attributes?.pitch || 30),
+ ),
+ roofId: roof.id,
+ isRidge: true,
+ },
+ })
+
+ // 캔버스에 추가
+ canvas.add(carabaLine)
+ carabaLine.bringToFront()
+
+ // 지붕 객체에 저장
+ roof.innerLines = [carabaLine]
+
+ // canvas에 skeleton 상태 저장
+ if (!canvas.skeletonStates) {
+ canvas.skeletonStates = {}
+ }
+ canvas.skeletonStates[roof.id] = true
+
+ canvas.renderAll()
+}
+
+/**
+ * 다각형에 대한 캐라바 처리
+ * @param {QPolygon} roof - 지붕 객체
+ * @param {fabric.Canvas} canvas - 캔버스 객체
+ * @param {string} textMode - 텍스트 모드
+ * @param {number} selectedEdgeIndex - 선택된 외곽선의 인덱스
+ */
+const drawCarabaForPolygon = (roof, canvas, textMode, selectedEdgeIndex) => {
+ const points = roof.getCurrentPoints()
+
+ if (!points || points.length < 3) {
+ console.warn('유효하지 않은 다각형 점 데이터입니다.')
+ return
+ }
+
+ if (selectedEdgeIndex < 0 || selectedEdgeIndex >= points.length) {
+ console.warn('유효하지 않은 edge 인덱스입니다.')
+ return
+ }
+
+ // 삼각형인 경우 기존 로직 사용
+ if (points.length === 3) {
+ drawCarabaForTriangle(roof, canvas, textMode, selectedEdgeIndex)
+ return
+ }
+
+ // 먼저 스켈레톤을 생성하여 내부 구조를 파악
+ const geoJSONPolygon = toGeoJSON(points)
+ geoJSONPolygon.pop() // 마지막 좌표 제거
+
+ let skeleton
+ try {
+ skeleton = SkeletonBuilder.BuildFromGeoJSON([[geoJSONPolygon]])
+ } catch (e) {
+ console.error('스켈레톤 생성 중 오류:', e)
+ return
+ }
+
+ if (!skeleton || !skeleton.Edges) {
+ console.warn('스켈레톤 생성에 실패했습니다.')
+ return
+ }
+
+ // 선택된 외곽선의 시작점과 끝점
+ const selectedStart = points[selectedEdgeIndex]
+ const selectedEnd = points[(selectedEdgeIndex + 1) % points.length]
+
+ const innerLines = []
+ const processedInnerEdges = new Set()
+
+ // 스켈레톤의 모든 내부선을 수집
+ for (const edgeResult of skeleton.Edges) {
+ const polygonPoints = edgeResult.Polygon.map((p) => ({ x: p.X, y: p.Y }))
+
+ for (let i = 0; i < polygonPoints.length; i++) {
+ const p1 = polygonPoints[i]
+ const p2 = polygonPoints[(i + 1) % polygonPoints.length]
+
+ // 선택된 외곽선은 제외
+ const isSelectedEdge =
+ (arePointsEqual(selectedStart, p1) && arePointsEqual(selectedEnd, p2)) ||
+ (arePointsEqual(selectedStart, p2) && arePointsEqual(selectedEnd, p1))
+
+ if (isSelectedEdge) continue
+
+ // 다른 외곽선들은 제외
+ const isOtherOuterEdge = roof.lines.some((line, idx) => {
+ if (idx === selectedEdgeIndex) return false
+ return (
+ (arePointsEqual(line.startPoint, p1) && arePointsEqual(line.endPoint, p2)) ||
+ (arePointsEqual(line.startPoint, p2) && arePointsEqual(line.endPoint, p1))
+ )
+ })
+
+ if (isOtherOuterEdge) continue
+
+ const edgeKey = [`${p1.x.toFixed(1)},${p1.y.toFixed(1)}`, `${p2.x.toFixed(1)},${p2.y.toFixed(1)}`].sort().join('|')
+
+ if (processedInnerEdges.has(edgeKey)) continue
+ processedInnerEdges.add(edgeKey)
+
+ // 선택된 외곽선에 수직으로 연장되는 선들만 처리
+ const selectedLineAngle = calculateAngle(selectedStart, selectedEnd)
+ const innerLineAngle = calculateAngle(p1, p2)
+ const angleDiff = Math.abs(selectedLineAngle - innerLineAngle)
+ const isPerpendicular = Math.abs(angleDiff - 90) < 5 || Math.abs(angleDiff - 270) < 5
+
+ if (isPerpendicular) {
+ // 선택된 외곽선 방향으로 연장
+ const extendedLine = extendLineToOppositeEdge(p1, p2, points, selectedEdgeIndex)
+
+ if (extendedLine) {
+ const carabaLine = new QLine([extendedLine.start.x, extendedLine.start.y, extendedLine.end.x, extendedLine.end.y], {
+ parentId: roof.id,
+ fontSize: roof.fontSize,
+ stroke: '#FF0000',
+ strokeWidth: 2,
+ name: LINE_TYPE.SUBLINE.RIDGE,
+ textMode: textMode,
+ attributes: {
+ type: LINE_TYPE.SUBLINE.RIDGE,
+ planeSize: calcLinePlaneSize({
+ x1: extendedLine.start.x,
+ y1: extendedLine.start.y,
+ x2: extendedLine.end.x,
+ y2: extendedLine.end.y,
+ }),
+ actualSize: calcLineActualSize(
+ {
+ x1: extendedLine.start.x,
+ y1: extendedLine.start.y,
+ x2: extendedLine.end.x,
+ y2: extendedLine.end.y,
+ },
+ getDegreeByChon(roof.lines[selectedEdgeIndex]?.attributes?.pitch || 30),
+ ),
+ roofId: roof.id,
+ isRidge: true,
+ },
+ })
+
+ innerLines.push(carabaLine)
+ }
+ }
+ }
+ }
+
+ // 캔버스에 추가
+ innerLines.forEach((line) => {
+ canvas.add(line)
+ line.bringToFront()
+ })
+
+ // 지붕 객체에 저장
+ roof.innerLines = innerLines
+
+ // canvas에 skeleton 상태 저장
+ if (!canvas.skeletonStates) {
+ canvas.skeletonStates = {}
+ }
+ canvas.skeletonStates[roof.id] = true
+
+ canvas.renderAll()
+}
+
+/**
+ * 선분을 맞은편 외곽선까지 연장하는 함수
+ */
+const extendLineToOppositeEdge = (p1, p2, polygonPoints, selectedEdgeIndex) => {
+ // 선분의 방향 벡터 계산
+ const direction = {
+ x: p2.x - p1.x,
+ y: p2.y - p1.y,
+ }
+
+ // 방향 벡터 정규화
+ const length = Math.sqrt(direction.x * direction.x + direction.y * direction.y)
+
+ if (length === 0) return null
+
+ const normalizedDir = {
+ x: direction.x / length,
+ y: direction.y / length,
+ }
+
+ // 선택된 외곽선의 반대편 찾기
+ const oppositeEdgeIndex = (selectedEdgeIndex + Math.floor(polygonPoints.length / 2)) % polygonPoints.length
+ const oppositeStart = polygonPoints[oppositeEdgeIndex]
+ const oppositeEnd = polygonPoints[(oppositeEdgeIndex + 1) % polygonPoints.length]
+
+ // p1에서 시작해서 반대편까지 연장
+ const extendedStart = { x: p1.x, y: p1.y }
+ const extendedEnd = findIntersectionWithEdge(p1, normalizedDir, oppositeStart, oppositeEnd) || { x: p2.x, y: p2.y }
+
+ return {
+ start: extendedStart,
+ end: extendedEnd,
+ }
+}
+
+/**
+ * 선분과 외곽선의 교점을 찾는 함수
+ */
+const findIntersectionWithEdge = (lineStart, lineDir, edgeStart, edgeEnd) => {
+ const edgeDir = {
+ x: edgeEnd.x - edgeStart.x,
+ y: edgeEnd.y - edgeStart.y,
+ }
+
+ const denominator = lineDir.x * edgeDir.y - lineDir.y * edgeDir.x
+ if (Math.abs(denominator) < 1e-10) return null // 평행선
+
+ const t = ((edgeStart.x - lineStart.x) * edgeDir.y - (edgeStart.y - lineStart.y) * edgeDir.x) / denominator
+ const u = ((edgeStart.x - lineStart.x) * lineDir.y - (edgeStart.y - lineStart.y) * lineDir.x) / denominator
+
+ if (t >= 0 && u >= 0 && u <= 1) {
+ return {
+ x: lineStart.x + t * lineDir.x,
+ y: lineStart.y + t * lineDir.y,
+ }
+ }
+
+ return null
+}
+
+/**
+ * Transforms the selected wall line into a roof structure
+ * @param {QPolygon} roof - The roof object
+ * @param {fabric.Canvas} canvas - The canvas object
+ * @param {number} edgeIndex - Index of the selected edge
+ */
+const transformWallToRoof = (roof, canvas, edgeIndex) => {
+ // Get the current points
+ const points = roof.getCurrentPoints()
+
+ if (!points || points.length < 3) {
+ console.warn('Invalid polygon points')
+ return
+ }
+
+ // Get the selected edge points
+ const p1 = points[edgeIndex]
+ const p2 = points[(edgeIndex + 1) % points.length]
+
+ // Calculate mid point of the selected edge
+ const midX = (p1.x + p2.x) / 2
+ const midY = (p1.y + p2.y) / 2
+
+ // Calculate the perpendicular vector (for the roof ridge)
+ const dx = p2.x - p1.x
+ const dy = p2.y - p1.y
+ const length = Math.sqrt(dx * dx + dy * dy)
+
+ // Normal vector (perpendicular to the edge)
+ const nx = -dy / length
+ const ny = dx / length
+
+ // Calculate the ridge point (extending inward from the middle of the edge)
+ const ridgeLength = length * 0.4 // Adjust this factor as needed
+ const ridgeX = midX + nx * ridgeLength
+ const ridgeY = midY + ny * ridgeLength
+
+ // Create the new points for the roof
+ const newPoints = [...points]
+ newPoints.splice(edgeIndex + 1, 0, { x: ridgeX, y: ridgeY })
+
+ // Update the roof with new points
+ roof.set({
+ points: newPoints,
+ // Ensure the polygon is re-rendered
+ dirty: true,
+ })
+
+ // Update the polygon's path
+ roof.setCoords()
+
+ // Force a re-render of the canvas
+ canvas.renderAll()
+
+ return roof
+}
+
+/**
+ * 사용 예제: 첫 번째 edge를 45도 각도로 변형
+ * drawSkeletonWithTransformedEdges(roofId, canvas, textMode, [
+ * { edgeIndex: 0, angleOffset: 45, splitRatio: 0.5 }
+ * ]);
+ */
+
+class Advanced2DRoofBuilder extends SkeletonBuilder {
+ static Build2DRoofFromAdvancedProperties(geoJsonPolygon, edgeProperties) {
+ // 입력 데이터 검증
+ if (!geoJsonPolygon || !Array.isArray(geoJsonPolygon) || geoJsonPolygon.length === 0) {
+ throw new Error('geoJsonPolygon이 유효하지 않습니다')
+ }
+
+ if (!edgeProperties || !Array.isArray(edgeProperties)) {
+ throw new Error('edgeProperties가 유효하지 않습니다')
+ }
+
+ console.log('입력 검증 통과')
+ console.log('geoJsonPolygon:', geoJsonPolygon)
+ console.log('edgeProperties:', edgeProperties)
+
+ // 1. 입력 폴리곤을 edgeProperties에 따라 수정
+ const modifiedPolygon = this.preprocessPolygonByEdgeTypes(geoJsonPolygon, edgeProperties)
+
+ // 2. 수정된 폴리곤으로 skeleton 생성
+ const skeleton = SkeletonBuilder.BuildFromGeoJSON([[modifiedPolygon]])
+
+ if (!skeleton || !skeleton.Edges) {
+ throw new Error('Skeleton 생성 실패')
+ }
+
+ // 3. Edge 분석
+ const edgeAnalysis = this.analyzeAdvancedEdgeTypes(edgeProperties)
+
+ return {
+ skeleton: skeleton,
+ original_polygon: geoJsonPolygon,
+ modified_polygon: modifiedPolygon,
+ roof_type: edgeAnalysis.roof_type,
+ edge_analysis: edgeAnalysis,
+ }
+ }
+
+ /**
+ * ✅ 안전한 폴리곤 전처리
+ */
+ static preprocessPolygonByEdgeTypes(geoJsonPolygon, edgeProperties) {
+ try {
+ const originalRing = geoJsonPolygon
+
+ if (!Array.isArray(originalRing) || originalRing.length < 4) {
+ throw new Error('외곽선이 유효하지 않습니다')
+ }
+
+ const modifiedRing = originalRing.map((point) => {
+ if (!Array.isArray(point) || point.length < 2) {
+ throw new Error('좌표점 형식이 잘못되었습니다')
+ }
+ return [point[0], point[1]]
+ })
+
+ const isClosedPolygon = this.isPolygonClosed(modifiedRing)
+ if (isClosedPolygon) {
+ modifiedRing.pop()
+ }
+
+ const actualEdgeCount = modifiedRing.length
+ const edgeCountToProcess = Math.min(edgeProperties.length, actualEdgeCount)
+
+ for (let i = 0; i < edgeCountToProcess; i++) {
+ const edgeProp = edgeProperties[i]
+ const edgeType = edgeProp?.edge_type
+
+ console.log(`Processing edge ${i}: ${edgeType}`)
+
+ try {
+ switch (edgeType) {
+ case 'EAVES':
+ // ✅ 수정: 처마는 기본 상태이므로 수정하지 않음
+ console.log(`Edge ${i}: EAVES - 기본 처마 상태 유지`)
+ break
+
+ case 'WALL':
+ // ✅ 수정: 처마를 벽으로 변경
+ this.transformEavesToWall(modifiedRing, i, edgeProp)
+ break
+
+ case 'GABLE':
+ // ✅ 수정: 처마를 케라바로 변경
+ this.transformEavesToGable(modifiedRing, i, edgeProp)
+ break
+
+ default:
+ console.warn(`알 수 없는 edge 타입: ${edgeType}, 기본 EAVES로 처리`)
+ }
+ } catch (edgeError) {
+ console.error(`Edge ${i} 처리 중 오류:`, edgeError)
+ }
+ }
+
+ const finalPolygon = this.prepareFinalPolygon(modifiedRing)
+ return finalPolygon
+ } catch (error) {
+ console.error('폴리곤 전처리 오류:', error)
+ throw error
+ }
+ }
+
+ /**
+ * ✅ 처마를 벽으로 변경 (내부로 수축)
+ */
+ static transformEavesToWall(ring, edgeIndex, edgeProp) {
+ console.log(`transformEavesToWall: edgeIndex=${edgeIndex}`)
+
+ if (!ring || !Array.isArray(ring)) {
+ console.error('ring이 배열이 아니거나 undefined입니다')
+ return
+ }
+
+ const totalPoints = ring.length
+
+ if (edgeIndex < 0 || edgeIndex >= totalPoints) {
+ console.error(`edgeIndex ${edgeIndex}가 범위를 벗어났습니다`)
+ return
+ }
+
+ const p1 = ring[edgeIndex]
+ const nextIndex = (edgeIndex + 1) % totalPoints
+ const p2 = ring[nextIndex]
+
+ if (!Array.isArray(p1) || p1.length < 2 || !Array.isArray(p2) || p2.length < 2) {
+ console.error('점 형식이 잘못되었습니다')
+ return
+ }
+
+ try {
+ // 폴리곤 중심 계산
+ const centerX = ring.reduce((sum, p) => sum + p[0], 0) / totalPoints
+ const centerY = ring.reduce((sum, p) => sum + p[1], 0) / totalPoints
+
+ // edge 중점
+ const midX = (p1[0] + p2[0]) / 2
+ const midY = (p1[1] + p2[1]) / 2
+
+ // 내향 방향 (처마 → 벽: 안쪽으로 수축)
+ const dirX = centerX - midX
+ const dirY = centerY - midY
+ const length = Math.sqrt(dirX * dirX + dirY * dirY)
+
+ if (length < 0.001) {
+ console.warn('내향 방향 벡터 길이가 거의 0입니다')
+ return
+ }
+
+ const unitX = dirX / length
+ const unitY = dirY / length
+ const shrinkDistance = edgeProp.shrink_distance || 0.8 // 벽 수축 거리
+
+ // 점들을 내부로 이동
+ ring[edgeIndex] = [p1[0] + unitX * shrinkDistance, p1[1] + unitY * shrinkDistance]
+
+ ring[nextIndex] = [p2[0] + unitX * shrinkDistance, p2[1] + unitY * shrinkDistance]
+
+ console.log(`✅ WALL: Edge ${edgeIndex} 내부로 수축 완료 (${shrinkDistance})`)
+ } catch (calcError) {
+ console.error('벽 변환 계산 중 오류:', calcError)
+ }
+ }
+
+ /**
+ * ✅ 처마를 케라바로 변경 (특별한 형태로 변형)
+ */
+ // static transformEavesToGable(ring, edgeIndex, edgeProp) {
+ // console.log(`transformEavesToGable: edgeIndex=${edgeIndex}`);
+ //
+ // // 안전성 검증
+ // if (!ring || !Array.isArray(ring)) {
+ // console.error('ring이 배열이 아니거나 undefined입니다');
+ // return;
+ // }
+ //
+ // const totalPoints = ring.length;
+ //
+ // if (edgeIndex < 0 || edgeIndex >= totalPoints) {
+ // console.error(`edgeIndex ${edgeIndex}가 범위를 벗어났습니다`);
+ // return;
+ // }
+ //
+ // const p1 = ring[edgeIndex];
+ // const nextIndex = (edgeIndex + 1) % totalPoints;
+ // const p2 = ring[nextIndex];
+ //
+ // if (!Array.isArray(p1) || p1.length < 2 ||
+ // !Array.isArray(p2) || p2.length < 2) {
+ // console.error('점 형식이 잘못되었습니다');
+ // return;
+ // }
+ //
+ // try {
+ // // 케라바 변형: edge를 직선화하고 약간 내부로 이동
+ // const centerX = ring.reduce((sum, p) => sum + p[0], 0) / totalPoints;
+ // const centerY = ring.reduce((sum, p) => sum + p[1], 0) / totalPoints;
+ //
+ // const midX = (p1[0] + p2[0]) / 2;
+ // const midY = (p1[1] + p2[1]) / 2;
+ //
+ // // 내향 방향으로 약간 이동
+ // const dirX = centerX - midX;
+ // const dirY = centerY - midY;
+ // const length = Math.sqrt(dirX * dirX + dirY * dirY);
+ //
+ // if (length < 0.001) {
+ // console.warn('내향 방향 벡터 길이가 거의 0입니다');
+ // return;
+ // }
+ //
+ // const unitX = dirX / length;
+ // const unitY = dirY / length;
+ // const gableInset = edgeProp.gable_inset || 0.3; // 케라바 안쪽 이동 거리
+ //
+ // // edge의 방향 벡터
+ // const edgeVecX = p2[0] - p1[0];
+ // const edgeVecY = p2[1] - p1[1];
+ //
+ // // 새로운 중점 (안쪽으로 이동)
+ // const newMidX = midX + unitX * gableInset;
+ // const newMidY = midY + unitY * gableInset;
+ //
+ // // 케라바를 위한 직선화된 점들
+ // ring[edgeIndex] = [
+ // newMidX - edgeVecX * 0.5,
+ // newMidY - edgeVecY * 0.5
+ // ];
+ //
+ // ring[nextIndex] = [
+ // newMidX + edgeVecX * 0.5,
+ // newMidY + edgeVecY * 0.5
+ // ];
+ //
+ // console.log(`✅ GABLE: Edge ${edgeIndex} 케라바 변형 완료`);
+ //
+ // } catch (calcError) {
+ // console.error('케라바 변환 계산 중 오류:', calcError);
+ // }
+ // }
+
+ static transformEavesToGable(ring, edgeIndex, edgeProp) {
+ // ✅ 캐라바면을 위한 특별 처리
+ // 해당 edge를 "직선 제약 조건"으로 만들어야 함
+
+ const p1 = ring[edgeIndex]
+ const nextIndex = (edgeIndex + 1) % ring.length
+ const p2 = ring[nextIndex]
+
+ // 캐라바면: edge를 완전히 직선으로 고정
+ // 이렇게 하면 skeleton이 이 edge에 수직으로만 생성됨
+
+ // 중간점들을 제거하여 직선화
+ const midX = (p1[0] + p2[0]) / 2
+ const midY = (p1[1] + p2[1]) / 2
+
+ // 캐라바 edge를 단순 직선으로 만들어
+ // SkeletonBuilder가 여기서 직선 skeleton을 생성하도록 유도
+ console.log(`✅ GABLE: Edge ${edgeIndex}를 직선 캐라바로 설정`)
+ }
+
+ // analyzeAdvancedEdgeTypes도 수정
+ static analyzeAdvancedEdgeTypes(edgeProperties) {
+ const eavesEdges = [] // 기본 처마 (수정 안함)
+ const wallEdges = [] // 처마→벽 변경
+ const gableEdges = [] // 처마→케라바 변경
+
+ edgeProperties.forEach((prop, i) => {
+ switch (prop?.edge_type) {
+ case 'EAVES':
+ eavesEdges.push(i)
+ break
+ case 'WALL':
+ wallEdges.push(i)
+ break
+ case 'GABLE':
+ gableEdges.push(i)
+ break
+ default:
+ console.warn(`Edge ${i}: 알 수 없는 타입 ${prop?.edge_type}, 기본 EAVES로 처리`)
+ eavesEdges.push(i)
+ }
+ })
+
+ let roofType
+ if (wallEdges.length === 0 && gableEdges.length === 0) {
+ roofType = 'pavilion' // 모든 면이 처마
+ } else if (wallEdges.length === 4 && gableEdges.length === 0) {
+ roofType = 'hipped' // 모든 면이 벽
+ } else if (gableEdges.length === 2 && wallEdges.length === 2) {
+ roofType = 'gabled' // 박공지붕
+ } else {
+ roofType = 'complex' // 복합지붕
+ }
+
+ return {
+ roof_type: roofType,
+ eaves_edges: eavesEdges, // 기본 처마
+ wall_edges: wallEdges, // 처마→벽
+ gable_edges: gableEdges, // 처마→케라바
+ }
+ }
+
+ /**
+ * ✅ 폴리곤이 닫혀있는지 확인
+ */
+ static isPolygonClosed(ring) {
+ if (!ring || ring.length < 2) return false
+
+ const firstPoint = ring[0]
+ const lastPoint = ring[ring.length - 1]
+
+ const tolerance = 0.0001 // 부동소수점 허용 오차
+
+ return Math.abs(firstPoint[0] - lastPoint[0]) < tolerance && Math.abs(firstPoint[1] - lastPoint[1]) < tolerance
+ }
+
+ /**
+ * ✅ BuildFromGeoJSON용 최종 polygon 준비
+ */
+ static prepareFinalPolygon(ring) {
+ // 1. 최소 점 개수 확인
+ if (ring.length < 3) {
+ throw new Error(`폴리곤 점이 부족합니다: ${ring.length}개 (최소 3개 필요)`)
+ }
+
+ // 2. 닫힌 폴리곤인지 다시 확인
+ const isClosed = this.isPolygonClosed(ring)
+
+ if (isClosed) {
+ console.log('여전히 닫힌 폴리곤입니다. 마지막 점 제거')
+ return ring.slice(0, -1) // 마지막 점 제거
+ }
+
+ // 3. 열린 폴리곤이면 그대로 반환
+ console.log('열린 폴리곤 상태로 BuildFromGeoJSON에 전달')
+ return [...ring] // 복사본 반환
+ }
+
+ static expandEdgeForEaves(ring, edgeIndex, overhang) {
+ console.log(`expandEdgeForEaves 시작: edgeIndex=${edgeIndex}, overhang=${overhang}`)
+
+ // 안전성 검증
+ if (!ring || !Array.isArray(ring)) {
+ console.error('ring이 배열이 아니거나 undefined입니다')
+ return
+ }
+
+ const totalPoints = ring.length - 1 // 마지막 중복점 제외
+ console.log(`ring 길이: ${ring.length}, totalPoints: ${totalPoints}`)
+
+ if (totalPoints <= 2) {
+ console.error('ring 점 개수가 부족합니다')
+ return
+ }
+
+ if (edgeIndex < 0 || edgeIndex >= totalPoints) {
+ console.error(`edgeIndex ${edgeIndex}가 범위를 벗어났습니다`)
+ return
+ }
+
+ // 안전한 점 접근
+ const p1 = ring[edgeIndex]
+ const nextIndex = (edgeIndex + 1) % totalPoints
+ const p2 = ring[nextIndex]
+
+ console.log(`p1 (index ${edgeIndex}):`, p1)
+ console.log(`p2 (index ${nextIndex}):`, p2)
+
+ if (!Array.isArray(p1) || p1.length < 2 || !Array.isArray(p2) || p2.length < 2) {
+ console.error('점 형식이 잘못되었습니다')
+ console.error('p1:', p1, 'p2:', p2)
+ return
+ }
+
+ if (typeof p1[0] !== 'number' || typeof p1[1] !== 'number' || typeof p2[0] !== 'number' || typeof p2[1] !== 'number') {
+ console.error('좌표값이 숫자가 아닙니다')
+ return
+ }
+
+ try {
+ // 폴리곤 중심 계산 (마지막 중복점 제외)
+ const validPoints = ring.slice(0, totalPoints)
+ const centerX = validPoints.reduce((sum, p) => sum + p[0], 0) / totalPoints
+ const centerY = validPoints.reduce((sum, p) => sum + p[1], 0) / totalPoints
+
+ console.log(`중심점: (${centerX}, ${centerY})`)
+
+ // edge 중점
+ const midX = (p1[0] + p2[0]) / 2
+ const midY = (p1[1] + p2[1]) / 2
+
+ // 외향 방향
+ const dirX = midX - centerX
+ const dirY = midY - centerY
+ const length = Math.sqrt(dirX * dirX + dirY * dirY)
+
+ if (length < 0.001) {
+ // 거의 0인 경우
+ console.warn('외향 방향 벡터 길이가 거의 0입니다, 확장하지 않습니다')
+ return
+ }
+
+ const unitX = dirX / length
+ const unitY = dirY / length
+
+ // 안전하게 점 수정
+ ring[edgeIndex] = [p1[0] + unitX * overhang, p1[1] + unitY * overhang]
+
+ ring[nextIndex] = [p2[0] + unitX * overhang, p2[1] + unitY * overhang]
+
+ console.log(`✅ EAVES: Edge ${edgeIndex} 확장 완료 (${overhang})`)
+ console.log('수정된 p1:', ring[edgeIndex])
+ console.log('수정된 p2:', ring[nextIndex])
+ } catch (calcError) {
+ console.error('계산 중 오류:', calcError)
+ }
+ }
+
+ /**
+ * ✅ 안전한 박공 조정
+ */
+ static adjustEdgeForGable(ring, edgeIndex, gableHeight) {
+ console.log(`adjustEdgeForGable 시작: edgeIndex=${edgeIndex}`)
+
+ // 안전성 검증 (동일한 패턴)
+ if (!ring || !Array.isArray(ring)) {
+ console.error('ring이 배열이 아니거나 undefined입니다')
+ return
+ }
+
+ const totalPoints = ring.length - 1
+
+ if (totalPoints <= 2) {
+ console.error('ring 점 개수가 부족합니다')
+ return
+ }
+
+ if (edgeIndex < 0 || edgeIndex >= totalPoints) {
+ console.error(`edgeIndex ${edgeIndex}가 범위를 벗어났습니다`)
+ return
+ }
+
+ const p1 = ring[edgeIndex]
+ const nextIndex = (edgeIndex + 1) % totalPoints
+ const p2 = ring[nextIndex]
+
+ if (!Array.isArray(p1) || p1.length < 2 || !Array.isArray(p2) || p2.length < 2) {
+ console.error('점 형식이 잘못되었습니다')
+ return
+ }
+
+ try {
+ const validPoints = ring.slice(0, totalPoints)
+ const centerX = validPoints.reduce((sum, p) => sum + p[0], 0) / totalPoints
+ const centerY = validPoints.reduce((sum, p) => sum + p[1], 0) / totalPoints
+
+ console.log(`중심점: (${centerX}, ${centerY})`)
+
+ // edge 중점
+ const midX = (p1[0] + p2[0]) / 2
+ const midY = (p1[1] + p2[1]) / 2
+
+ // 외향 방향
+ const dirX = centerX - midX
+ const dirY = centerY - midY
+ const length = Math.sqrt(dirX * dirX + dirY * dirY)
+
+ if (length < 0.001) {
+ console.warn('중심 방향 벡터 길이가 거의 0입니다')
+ return
+ }
+
+ const unitX = dirX / length
+ const unitY = dirY / length
+ const insetDistance = 0.5
+
+ const newMidX = midX + unitX * insetDistance
+ const newMidY = midY + unitY * insetDistance
+
+ const edgeVecX = p2[0] - p1[0]
+ const edgeVecY = p2[1] - p1[1]
+
+ ring[edgeIndex] = [newMidX - edgeVecX * 0.5, newMidY - edgeVecY * 0.5]
+
+ ring[nextIndex] = [newMidX + edgeVecX * 0.5, newMidY + edgeVecY * 0.5]
+
+ console.log(`✅ GABLE: Edge ${edgeIndex} 조정 완료`)
+ } catch (calcError) {
+ console.error('박공 조정 계산 중 오류:', calcError)
+ }
+ }
+
+ static processGableSkeleton(skeleton, gableEdgeIndex, originalPolygon) {
+ // ✅ Gable edge에 해당하는 skeleton 정점들을 찾아서
+ // 해당 edge의 중점으로 강제 이동
+
+ const gableEdge = originalPolygon[gableEdgeIndex]
+ const edgeMidpoint = calculateMidpoint(gableEdge)
+
+ // skeleton 정점들을 edge 중점으로 "압축"
+ skeleton.Edges.forEach((edge) => {
+ if (isRelatedToGableEdge(edge, gableEdgeIndex)) {
+ // 해당 edge 관련 skeleton 정점들을 직선으로 정렬
+ straightenSkeletonToEdge(edge, edgeMidpoint)
+ }
+ })
+ }
+
+ // ✅ Gable edge에 제약 조건을 추가하여 skeleton 생성
+ static buildConstrainedSkeleton(polygon, edgeConstraints) {
+ const constraints = edgeConstraints
+ .map((constraint) => {
+ if (constraint.type === 'GABLE') {
+ return {
+ edgeIndex: constraint.edgeIndex,
+ forceLinear: true, // 직선 강제
+ fixToMidpoint: true, // 중점 고정
+ }
+ }
+ return null
+ })
+ .filter((c) => c !== null)
+
+ // 제약 조건이 적용된 skeleton 생성
+ return SkeletonBuilder.build(polygon, constraints)
+ }
+}
+
+/**
+ * 지붕 폴리곤의 좌표를 스켈레톤 생성에 적합한 형태로 전처리합니다.
+ * - 연속된 중복 좌표를 제거합니다.
+ * - 폴리곤이 닫힌 형태가 되도록 마지막 좌표를 확인하고 필요시 제거합니다.
+ * - 좌표를 시계 방향으로 정렬합니다.
+ * @param {Array