2486 lines
93 KiB
JavaScript
2486 lines
93 KiB
JavaScript
import {
|
||
ANGLE_TYPE,
|
||
canvasState,
|
||
currentAngleTypeSelector,
|
||
globalPitchState,
|
||
pitchTextSelector,
|
||
} from '@/store/canvasAtom'
|
||
import { useRecoilValue } from 'recoil'
|
||
import { fabric } from 'fabric'
|
||
import { calculateIntersection, findAndRemoveClosestPoint, getDegreeByChon, isPointOnLine } from '@/util/canvas-util'
|
||
import { QPolygon } from '@/components/fabric/QPolygon'
|
||
import { calcLinePlaneSize, equalizeSymmetricHips, isSamePoint, removeDuplicatePolygons } from '@/util/qpolygon-utils'
|
||
import { basicSettingState, corridorDimensionSelector, flowDisplaySelector } from '@/store/settingAtom'
|
||
import { fontSelector } from '@/store/fontAtom'
|
||
import { QLine } from '@/components/fabric/QLine'
|
||
import { LINE_TYPE, POLYGON_TYPE } from '@/common/common'
|
||
import { useLine } from '@/hooks/useLine'
|
||
import { logger } from '@/util/logger'
|
||
import { debugCapture } from '@/util/debugCapture'
|
||
|
||
export const usePolygon = () => {
|
||
const canvas = useRecoilValue(canvasState)
|
||
const isFlowDisplay = useRecoilValue(flowDisplaySelector)
|
||
const flowFontOptions = useRecoilValue(fontSelector('flowText'))
|
||
const lengthTextFontOptions = useRecoilValue(fontSelector('lengthText'))
|
||
const currentAngleType = useRecoilValue(currentAngleTypeSelector)
|
||
const pitchText = useRecoilValue(pitchTextSelector)
|
||
const globalPitch = useRecoilValue(globalPitchState)
|
||
const roofSizeSet = useRecoilValue(basicSettingState).roofSizeSet
|
||
const corridorDimension = useRecoilValue(corridorDimensionSelector)
|
||
|
||
const { setActualSize } = useLine()
|
||
|
||
const { getLengthByLine } = useLine()
|
||
|
||
const addPolygon = (points, options, isAddCanvas = true) => {
|
||
const polygon = new QPolygon(points, {
|
||
...options,
|
||
fontSize: lengthTextFontOptions.fontSize.value,
|
||
fill: options.fill || 'transparent',
|
||
stroke: options.stroke || '#000000',
|
||
// selectable: true,
|
||
})
|
||
|
||
if (isAddCanvas) canvas?.add(polygon)
|
||
addLengthText(polygon)
|
||
|
||
return polygon
|
||
}
|
||
|
||
const addPolygonByLines = (lines, options) => {
|
||
//lines의 idx를 정렬한다.
|
||
lines.sort((a, b) => a.idx - b.idx)
|
||
const points = createPolygonPointsFromOuterLines(lines)
|
||
|
||
return addPolygon(points, {
|
||
...options,
|
||
})
|
||
}
|
||
|
||
const addLengthText = (polygon) => {
|
||
const lengthTexts = canvas.getObjects().filter((obj) => obj.name === 'lengthText' && obj.parentId === polygon.id)
|
||
lengthTexts.forEach((text) => {
|
||
canvas.remove(text)
|
||
})
|
||
const lines = polygon.lines
|
||
polygon.texts = []
|
||
|
||
lines.forEach((line, i) => {
|
||
const length = line.getLength()
|
||
const { planeSize, actualSize } = line.attributes
|
||
const scaleX = line.scaleX
|
||
const scaleY = line.scaleY
|
||
const x1 = line.left
|
||
const y1 = line.top
|
||
const x2 = line.left + line.width * scaleX
|
||
const y2 = line.top + line.height * scaleY
|
||
|
||
let left, top
|
||
|
||
if (line.direction === 'right') {
|
||
left = (x1 + x2) / 2
|
||
top = (y1 + y2) / 2 + 10
|
||
} else if (line.direction === 'top') {
|
||
left = (x1 + x2) / 2 + 10
|
||
top = (y1 + y2) / 2
|
||
} else if (line.direction === 'left') {
|
||
left = (x1 + x2) / 2
|
||
top = (y1 + y2) / 2 - 30
|
||
} else if (line.direction === 'bottom') {
|
||
left = (x1 + x2) / 2 - 50
|
||
top = (y1 + y2) / 2
|
||
}
|
||
|
||
const minX = line.left
|
||
const maxX = line.left + line.width
|
||
const minY = line.top
|
||
const maxY = line.top + line.length
|
||
const degree = (Math.atan2(y2 - y1, x2 - x1) * 180) / Math.PI
|
||
|
||
// [라벨 실제길이 모드 fix] 치수표시가 '복도치수 표시'(corridorDimension) 면 roofSizeSet 무관하게
|
||
// planeSize(평면길이)를 표시한다. 그 외에는 roofSizeSet===1(실제길이) 이면 actualSize, 아니면 planeSize.
|
||
// 기존엔 planeSize 만 표시해, 실제치수 모드에서도 이동 후 평면길이(예: 8000)가 나오던 문제.
|
||
const __isCorridorDimension = corridorDimension?.column === 'corridorDimension'
|
||
const __display = (__isCorridorDimension ? planeSize : +roofSizeSet === 1 ? actualSize : planeSize) ?? planeSize ?? length
|
||
const text = new fabric.Textbox(Number(__display).toFixed(1).replace(/\.0$/, ''), {
|
||
left: left,
|
||
top: top,
|
||
fontSize: lengthTextFontOptions.fontSize.value,
|
||
minX,
|
||
maxX,
|
||
minY,
|
||
maxY,
|
||
parentDirection: line.direction,
|
||
parentDegree: degree,
|
||
parentId: polygon.id,
|
||
planeSize: planeSize ?? length,
|
||
actualSize: actualSize ?? length,
|
||
editable: false,
|
||
selectable: true,
|
||
lockRotation: true,
|
||
lockScalingX: true,
|
||
lockScalingY: true,
|
||
parent: polygon,
|
||
name: 'lengthText',
|
||
})
|
||
polygon.texts.push(text)
|
||
canvas.add(text)
|
||
})
|
||
|
||
/*const points = polygon.get('points')
|
||
points.forEach((start, i) => {
|
||
const end = points[(i + 1) % points.length]
|
||
const dx = end.x - start.x
|
||
const dy = end.y - start.y
|
||
const length = Number(Math.sqrt(dx * dx + dy * dy).toFixed(1)) * 10
|
||
|
||
const midPoint = new fabric.Point((start.x + end.x) / 2, (start.y + end.y) / 2)
|
||
|
||
const degree = (Math.atan2(dy, dx) * 180) / Math.PI
|
||
|
||
// Create new text object if it doesn't exist
|
||
const text = new fabric.Text(length.toString(), {
|
||
left: midPoint.x,
|
||
top: midPoint.y,
|
||
parentId: polygon.id,
|
||
fontSize: lengthTextFontOptions.fontSize.value,
|
||
minX: Math.min(start.x, end.x),
|
||
maxX: Math.max(start.x, end.x),
|
||
minY: Math.min(start.y, end.y),
|
||
maxY: Math.max(start.y, end.y),
|
||
parentDirection: getDirectionByPoint(start, end),
|
||
parentDegree: degree,
|
||
dirty: true,
|
||
editable: true,
|
||
selectable: true,
|
||
lockRotation: true,
|
||
lockScalingX: true,
|
||
lockScalingY: true,
|
||
idx: i,
|
||
name: 'lengthText',
|
||
parent: polygon,
|
||
})
|
||
|
||
// this.texts.push(text)
|
||
canvas.add(text)
|
||
})*/
|
||
canvas.renderAll()
|
||
}
|
||
|
||
const createPolygonPointsFromOuterLines = (outerLines) => {
|
||
if (!outerLines || outerLines.length === 0) {
|
||
return []
|
||
}
|
||
|
||
// Extract points from outerLines
|
||
|
||
return outerLines.map((line) => ({
|
||
x: line.x1,
|
||
y: line.y1,
|
||
}))
|
||
}
|
||
|
||
const removePolygon = (polygon) => {
|
||
const texts = canvas.getObjects().filter((obj) => obj.parentId === polygon.id)
|
||
texts.forEach((text) => {
|
||
canvas.remove(text)
|
||
})
|
||
|
||
canvas.remove(polygon)
|
||
canvas.renderAll()
|
||
}
|
||
|
||
/**
|
||
* poolygon의 방향에 따라 화살표를 추가한다.
|
||
* @param polygon
|
||
* @param showDirectionText
|
||
*/
|
||
const drawDirectionArrow = (polygon, showDirectionText = true) => {
|
||
if (!polygon) {
|
||
return
|
||
}
|
||
|
||
if (polygon.points.length < 3) {
|
||
return
|
||
}
|
||
// 모듈있으면 화살표 이미 그려져 있으므로 수행 안함
|
||
const hasModules = canvas.getObjects().filter((obj) => obj.name === POLYGON_TYPE.MODULE).length > 0
|
||
|
||
if (hasModules) {
|
||
return
|
||
}
|
||
const direction = polygon.direction
|
||
if (!direction) {
|
||
return
|
||
}
|
||
|
||
//동일 아이디가 있으면 일단 지우고 다시 그린다
|
||
const existArrow = polygon.canvas.getObjects().filter((obj) => obj.name === 'arrow' && obj.parentId === polygon.id)
|
||
if (existArrow.length > 0) {
|
||
polygon.canvas.remove(...existArrow)
|
||
}
|
||
|
||
polygon.canvas
|
||
.getObjects()
|
||
.filter((obj) => obj.name === 'flowText' && obj.parentId === polygon.arrow?.id)
|
||
.forEach((obj) => polygon.canvas.remove(obj))
|
||
|
||
let arrow = null
|
||
let points = []
|
||
|
||
if (polygon.arrow) {
|
||
polygon.canvas.remove(polygon.arrow)
|
||
}
|
||
|
||
let centerPoint = { x: polygon.left, y: polygon.top }
|
||
const { width, height } = polygon
|
||
let stickeyPoint
|
||
|
||
const polygonMaxX = Math.max(...polygon.getCurrentPoints().map((point) => point.x))
|
||
const polygonMinX = Math.min(...polygon.getCurrentPoints().map((point) => point.x))
|
||
const polygonMaxY = Math.max(...polygon.getCurrentPoints().map((point) => point.y))
|
||
const polygonMinY = Math.min(...polygon.getCurrentPoints().map((point) => point.y))
|
||
const lines = polygon.lines
|
||
let centerPoints
|
||
switch (direction) {
|
||
case 'south':
|
||
// lines중 가장 아래에 있는 라인을 찾는다.
|
||
const line = lines.reduce((acc, cur) => {
|
||
return acc.y2 + acc.y1 > cur.y2 + cur.y1 ? acc : cur
|
||
}, lines[0])
|
||
centerPoint = { x: (line.x2 + line.x1) / 2, y: Math.max(line.y1, line.y2) }
|
||
break
|
||
case 'north':
|
||
// lines중 가장 위에 있는 라인을 찾는다.
|
||
const line2 = lines.reduce((acc, cur) => {
|
||
return acc.y2 + acc.y1 < cur.y2 + cur.y1 ? acc : cur
|
||
}, lines[0])
|
||
centerPoint = { x: (line2.x2 + line2.x1) / 2, y: Math.min(line2.y1, line2.y2) }
|
||
break
|
||
case 'west':
|
||
// lines중 가장 왼쪽에 있는 라인을 찾는다.
|
||
const line3 = lines.reduce((acc, cur) => {
|
||
return acc.x2 + acc.x1 < cur.x2 + cur.x1 ? acc : cur
|
||
}, lines[0])
|
||
centerPoint = { x: Math.min(line3.x1, line3.x2), y: (line3.y1 + line3.y2) / 2 }
|
||
break
|
||
case 'east':
|
||
// lines중 가장 오른쪽에 있는 라인을 찾는다.
|
||
const line4 = lines.reduce((acc, cur) => {
|
||
return acc.x2 + acc.x1 > cur.x2 + cur.x1 ? acc : cur
|
||
}, lines[0])
|
||
centerPoint = { x: Math.max(line4.x1, line4.x2), y: (line4.y1 + line4.y2) / 2 }
|
||
break
|
||
}
|
||
|
||
switch (direction) {
|
||
case 'north':
|
||
points = [
|
||
{ x: centerPoint.x, y: polygonMinY - 50 },
|
||
{ x: centerPoint.x + 20, y: polygonMinY - 50 },
|
||
{ x: centerPoint.x + 20, y: polygonMinY - 80 },
|
||
{ x: centerPoint.x + 50, y: polygonMinY - 80 },
|
||
{ x: centerPoint.x, y: polygonMinY - 110 },
|
||
{ x: centerPoint.x - 50, y: polygonMinY - 80 },
|
||
{ x: centerPoint.x - 20, y: polygonMinY - 80 },
|
||
{ x: centerPoint.x - 20, y: polygonMinY - 50 },
|
||
]
|
||
|
||
stickeyPoint = { x: centerPoint.x, y: polygonMinY - 110 }
|
||
break
|
||
case 'south':
|
||
points = [
|
||
{ x: centerPoint.x, y: polygonMaxY + 50 },
|
||
{ x: centerPoint.x + 20, y: polygonMaxY + 50 },
|
||
{ x: centerPoint.x + 20, y: polygonMaxY + 80 },
|
||
{ x: centerPoint.x + 50, y: polygonMaxY + 80 },
|
||
{ x: centerPoint.x, y: polygonMaxY + 110 },
|
||
{ x: centerPoint.x - 50, y: polygonMaxY + 80 },
|
||
{ x: centerPoint.x - 20, y: polygonMaxY + 80 },
|
||
{ x: centerPoint.x - 20, y: polygonMaxY + 50 },
|
||
]
|
||
stickeyPoint = { x: centerPoint.x, y: polygonMaxY + 110 }
|
||
break
|
||
case 'west':
|
||
points = [
|
||
{ x: polygonMinX - 50, y: centerPoint.y },
|
||
{ x: polygonMinX - 50, y: centerPoint.y + 20 },
|
||
{ x: polygonMinX - 80, y: centerPoint.y + 20 },
|
||
{ x: polygonMinX - 80, y: centerPoint.y + 50 },
|
||
{ x: polygonMinX - 110, y: centerPoint.y },
|
||
{ x: polygonMinX - 80, y: centerPoint.y - 50 },
|
||
{ x: polygonMinX - 80, y: centerPoint.y - 20 },
|
||
{ x: polygonMinX - 50, y: centerPoint.y - 20 },
|
||
]
|
||
|
||
stickeyPoint = { x: polygonMinX - 110, y: centerPoint.y }
|
||
break
|
||
case 'east':
|
||
points = [
|
||
{ x: polygonMaxX + 50, y: centerPoint.y },
|
||
{ x: polygonMaxX + 50, y: centerPoint.y + 20 },
|
||
{ x: polygonMaxX + 80, y: centerPoint.y + 20 },
|
||
{ x: polygonMaxX + 80, y: centerPoint.y + 50 },
|
||
{ x: polygonMaxX + 110, y: centerPoint.y },
|
||
{ x: polygonMaxX + 80, y: centerPoint.y - 50 },
|
||
{ x: polygonMaxX + 80, y: centerPoint.y - 20 },
|
||
{ x: polygonMaxX + 50, y: centerPoint.y - 20 },
|
||
]
|
||
|
||
stickeyPoint = { x: polygonMaxX + 110, y: centerPoint.y }
|
||
break
|
||
}
|
||
|
||
arrow = new QPolygon(points, {
|
||
selectable: false,
|
||
name: 'arrow',
|
||
fill: 'transparent',
|
||
stroke: 'black',
|
||
direction: direction,
|
||
parent: polygon,
|
||
stickeyPoint: stickeyPoint,
|
||
surfaceCompass: polygon.surfaceCompass,
|
||
moduleCompass: polygon.moduleCompass,
|
||
visible: isFlowDisplay,
|
||
pitch: polygon.roofMaterial?.pitch ?? 4,
|
||
roofAngle: polygon.roofMaterial?.angle,
|
||
parentId: polygon.id,
|
||
})
|
||
arrow.setViewLengthText(false)
|
||
polygon.arrow = arrow
|
||
polygon.canvas.add(arrow)
|
||
polygon.canvas.renderAll()
|
||
drawDirectionStringToArrow2(polygon, showDirectionText)
|
||
// drawDirectionStringToArrow()
|
||
}
|
||
|
||
//arrow의 compass 값으로 방향 글자 설정 필요
|
||
// moduleCompass 각도와 direction(지붕면 방향)에 따라 한자 방위 텍스트 매핑
|
||
const drawDirectionStringToArrow2 = (polygon, showDirectionText) => {
|
||
let { direction, surfaceCompass, moduleCompass, arrow } = polygon
|
||
if (moduleCompass === null || moduleCompass === undefined) {
|
||
const textObj = new fabric.Text(
|
||
`${currentAngleType === ANGLE_TYPE.SLOPE ? arrow.pitch : (arrow.roofAngle ?? getDegreeByChon(arrow.pitch))}${pitchText}`,
|
||
{
|
||
fontFamily: flowFontOptions.fontFamily.value,
|
||
fontWeight: flowFontOptions.fontWeight.value.toLowerCase().includes('bold') ? 'bold' : 'normal',
|
||
fontStyle: flowFontOptions.fontWeight.value.toLowerCase().includes('italic') ? 'italic' : 'normal',
|
||
fontSize: flowFontOptions.fontSize.value,
|
||
fill: flowFontOptions.fontColor.value,
|
||
originX: 'center',
|
||
originY: 'center',
|
||
pitch: arrow.pitch,
|
||
roofAngle: arrow.roofAngle,
|
||
name: 'flowText',
|
||
selectable: false,
|
||
left: arrow.stickeyPoint.x,
|
||
top: arrow.stickeyPoint.y,
|
||
parent: arrow,
|
||
parentId: arrow.id,
|
||
visible: isFlowDisplay,
|
||
},
|
||
)
|
||
|
||
polygon.canvas.add(textObj)
|
||
return
|
||
}
|
||
|
||
let text = ''
|
||
|
||
// moduleCompass 각도와 direction에 따른 한자 방위 매핑
|
||
// direction: south(↓), west(←), north(↑), east(→)
|
||
// 각도 범위별 매핑 테이블 (사진 기준)
|
||
const getDirectionText = (angle, dir) => {
|
||
// 각도를 정규화 (-180 ~ 180 범위로)
|
||
let normalizedAngle = Number(angle)
|
||
while (normalizedAngle > 180) normalizedAngle -= 360
|
||
while (normalizedAngle < -180) normalizedAngle += 360
|
||
|
||
// 매핑 테이블: { south(↓), west(←), north(↑), east(→) }
|
||
// 각도 0: 남, 서, 북, 동
|
||
// 각도 45: 남서, 북서, 북동, 남동
|
||
// 각도 90: 서, 북, 동, 남
|
||
// 각도 135: 북서, 북동, 남동, 남서
|
||
// 각도 180: 북, 동, 남, 서
|
||
// 각도 -45: 남동, 남서, 북서, 북동
|
||
// 각도 -90: 동, 남, 서, 북
|
||
// 각도 -135: 북동, 남동, 남서, 북서
|
||
|
||
let mapping
|
||
// 정확한 각도 먼저 체크
|
||
if (normalizedAngle === 0) {
|
||
mapping = { south: '南', west: '西', north: '北', east: '東' }
|
||
} else if (normalizedAngle === 45) {
|
||
mapping = { south: '南西', west: '北西', north: '北東', east: '南東' }
|
||
} else if (normalizedAngle === 90) {
|
||
mapping = { south: '西', west: '北', north: '東', east: '南' }
|
||
} else if (normalizedAngle === 135) {
|
||
mapping = { south: '北西', west: '北東', north: '南東', east: '南西' }
|
||
} else if (normalizedAngle === 180 || normalizedAngle === -180) {
|
||
mapping = { south: '北', west: '東', north: '南', east: '西' }
|
||
} else if (normalizedAngle === -45) {
|
||
mapping = { south: '南東', west: '南西', north: '北西', east: '北東' }
|
||
} else if (normalizedAngle === -90) {
|
||
mapping = { south: '東', west: '南', north: '西', east: '北' }
|
||
} else if (normalizedAngle === -135) {
|
||
mapping = { south: '北東', west: '南東', north: '南西', east: '北西' }
|
||
}
|
||
// 범위 각도 체크
|
||
else if (normalizedAngle >= 1 && normalizedAngle <= 44) {
|
||
// 1~44: 남남서, 서북서, 북북동, 동남동
|
||
mapping = { south: '南南西', west: '西北西', north: '北北東', east: '東南東' }
|
||
} else if (normalizedAngle >= 46 && normalizedAngle <= 89) {
|
||
// 46~89: 서남서, 북북서, 동북동, 남남동
|
||
mapping = { south: '西南西', west: '北北西', north: '東北東', east: '南南東' }
|
||
} else if (normalizedAngle >= 91 && normalizedAngle <= 134) {
|
||
// 91~134: 서북서, 북북동, 동남동, 남남서
|
||
mapping = { south: '西北西', west: '北北東', north: '東南東', east: '南南西' }
|
||
} else if (normalizedAngle >= 136 && normalizedAngle <= 179) {
|
||
// 136~179: 북북서, 동북동, 남남동, 서남서
|
||
mapping = { south: '北北西', west: '東北東', north: '南南東', east: '西南西' }
|
||
} else if (normalizedAngle >= -44 && normalizedAngle <= -1) {
|
||
// -1~-44: 남남동, 서남서, 북북서, 동북동
|
||
mapping = { south: '南南東', west: '西南西', north: '北北西', east: '東北東' }
|
||
} else if (normalizedAngle >= -89 && normalizedAngle <= -46) {
|
||
// -46~-89: 동남동, 남남서, 서북서, 북북동
|
||
mapping = { south: '東南東', west: '南南西', north: '西北西', east: '北北東' }
|
||
} else if (normalizedAngle >= -134 && normalizedAngle <= -91) {
|
||
// -91~-134: 동북동, 남남동, 서남서, 북북서
|
||
mapping = { south: '東北東', west: '南南東', north: '西南西', east: '北北西' }
|
||
} else if (normalizedAngle >= -179 && normalizedAngle <= -136) {
|
||
// -136~-179: 북북동, 동남동, 남남서, 서북서
|
||
mapping = { south: '北北東', west: '東南東', north: '南南西', east: '西北西' }
|
||
} else {
|
||
// 기본값: 0도
|
||
mapping = { south: '南', west: '西', north: '北', east: '東' }
|
||
}
|
||
|
||
return mapping[dir] || '南'
|
||
}
|
||
|
||
text = getDirectionText(moduleCompass, direction)
|
||
|
||
// surfaceCompass가 있으면 text를 덮어쓰기 (기존 로직 유지)
|
||
if (surfaceCompass !== null && surfaceCompass !== undefined) {
|
||
if ([0].includes(surfaceCompass)) {
|
||
text = '南'
|
||
} else if ([15, 30].includes(surfaceCompass)) {
|
||
text = '南南東'
|
||
} else if ([45].includes(surfaceCompass)) {
|
||
text = '南東'
|
||
} else if ([60, 75].includes(surfaceCompass)) {
|
||
text = '東南東'
|
||
} else if ([90].includes(surfaceCompass)) {
|
||
text = '東'
|
||
} else if ([105, 120].includes(surfaceCompass)) {
|
||
text = '東北東'
|
||
} else if ([135].includes(surfaceCompass)) {
|
||
text = '北東'
|
||
} else if ([150, 165].includes(surfaceCompass)) {
|
||
text = '北北東'
|
||
} else if ([180].includes(surfaceCompass)) {
|
||
text = '北'
|
||
} else if ([-165, -150].includes(surfaceCompass)) {
|
||
text = '北北西'
|
||
} else if ([-135].includes(surfaceCompass)) {
|
||
text = '北西'
|
||
} else if ([-120, -105].includes(surfaceCompass)) {
|
||
text = '西北西'
|
||
} else if ([-90].includes(surfaceCompass)) {
|
||
text = '西'
|
||
} else if ([-75, -60].includes(surfaceCompass)) {
|
||
text = '西南西'
|
||
} else if ([-45].includes(surfaceCompass)) {
|
||
text = '南西'
|
||
} else if ([-30, -15].includes(surfaceCompass)) {
|
||
text = '南南西'
|
||
}
|
||
}
|
||
|
||
const sameDirectionCnt = canvas.getObjects().filter((obj) => {
|
||
const onlyStrDirection = obj.directionText?.replace(/[0-9]/g, '')
|
||
return obj.name === POLYGON_TYPE.ROOF && obj !== polygon && onlyStrDirection === text
|
||
})
|
||
|
||
text = text + (sameDirectionCnt.length + 1)
|
||
polygon.set('directionText', text)
|
||
const textObj = new fabric.Text(
|
||
`${showDirectionText && text} (${currentAngleType === ANGLE_TYPE.SLOPE ? arrow.pitch : (arrow.roofAngle ?? getDegreeByChon(arrow.pitch))}${pitchText})`,
|
||
{
|
||
fontFamily: flowFontOptions.fontFamily.value,
|
||
fontWeight: flowFontOptions.fontWeight.value.toLowerCase().includes('bold') ? 'bold' : 'normal',
|
||
fontStyle: flowFontOptions.fontWeight.value.toLowerCase().includes('italic') ? 'italic' : 'normal',
|
||
fontSize: flowFontOptions.fontSize.value,
|
||
fill: flowFontOptions.fontColor.value,
|
||
pitch: arrow.pitch,
|
||
roofAngle: arrow.roofAngle,
|
||
originX: 'center',
|
||
originY: 'center',
|
||
name: 'flowText',
|
||
originText: text,
|
||
selectable: false,
|
||
left: arrow.stickeyPoint.x,
|
||
top: arrow.stickeyPoint.y,
|
||
parent: arrow,
|
||
parentId: arrow.id,
|
||
visible: isFlowDisplay,
|
||
},
|
||
)
|
||
|
||
polygon.canvas.add(textObj)
|
||
}
|
||
|
||
/**
|
||
* 방향을 나타낸 화살표에 각도에 따라 글씨 추가
|
||
* @param canvas
|
||
* @param compass
|
||
*/
|
||
const drawDirectionStringToArrow = (canvas, compass = 0) => {
|
||
const arrows = canvas?.getObjects().filter((obj) => obj.name === 'arrow')
|
||
|
||
if (arrows.length === 0) {
|
||
return
|
||
}
|
||
|
||
const eastArrows = arrows.filter((arrow) => arrow.direction === 'east')
|
||
const westArrows = arrows.filter((arrow) => arrow.direction === 'west')
|
||
const northArrows = arrows.filter((arrow) => arrow.direction === 'north')
|
||
const southArrows = arrows.filter((arrow) => arrow.direction === 'south')
|
||
|
||
let southText = '南'
|
||
let eastText = '東'
|
||
let westText = '西'
|
||
let northText = '北'
|
||
|
||
if (compass === 0 || compass === 360) {
|
||
// 남,동,서 가능
|
||
// 그대로
|
||
} else if (compass < 45) {
|
||
//남(남남동),동(동북동),서(서남서) 가능
|
||
//북(북북서)
|
||
southText = '南南東'
|
||
eastText = '東北東'
|
||
westText = '西南西'
|
||
northText = '北北西'
|
||
} else if (compass === 45) {
|
||
// 남, 서 가능
|
||
// 남(남동)
|
||
// 서(남서)
|
||
// 북(북서)
|
||
// 동(북동)
|
||
southText = '南東'
|
||
westText = '南西'
|
||
northText = '北西'
|
||
eastText = '北東'
|
||
} else if (compass < 90) {
|
||
// 북(서북서)
|
||
// 동 (북북동)
|
||
// 남(동남동)
|
||
// 서(남남서)
|
||
northText = '北西北'
|
||
eastText = '北北東'
|
||
southText = '東南東'
|
||
westText = '南南西'
|
||
} else if (compass === 90) {
|
||
// 동(북)
|
||
// 서(남)
|
||
// 남(동)
|
||
// 북(서)
|
||
eastText = '北'
|
||
westText = '南'
|
||
southText = '東'
|
||
northText = '西'
|
||
} else if (compass < 135) {
|
||
// 남,서,북 가능
|
||
// 동(북북서)
|
||
// 서(남남동)
|
||
// 남(동북동)
|
||
// 북(서남서)
|
||
eastText = '北北西'
|
||
westText = '南南東'
|
||
southText = '東北東'
|
||
northText = '西南西'
|
||
} else if (compass === 135) {
|
||
// 서,북 가능
|
||
|
||
// 서(남동)
|
||
// 북(남서)
|
||
// 남(북동)
|
||
// 동(북서)
|
||
|
||
westText = '南東'
|
||
northText = '南西'
|
||
southText = '北東'
|
||
eastText = '北西'
|
||
} else if (compass < 180) {
|
||
// 북,동,서 가능
|
||
// 북(남남서)
|
||
// 동(서북서)
|
||
// 남(북북동)
|
||
// 서(동남동)
|
||
|
||
northText = '南南西'
|
||
eastText = '西北西'
|
||
southText = '北北東'
|
||
westText = '東南東'
|
||
} else if (compass === 180) {
|
||
// 북,동,서 가능
|
||
// 북(남)
|
||
// 동(서)
|
||
// 남(북)
|
||
// 서(동)
|
||
northText = '南'
|
||
eastText = '西'
|
||
southText = '北'
|
||
westText = '東'
|
||
} else if (compass < 225) {
|
||
// 서,북,동 가능
|
||
// 북(남남동)
|
||
// 동(서남서)
|
||
// 남(북북서)
|
||
// 서(동남동)
|
||
northText = '南南東'
|
||
eastText = '西南西'
|
||
southText = '北北西'
|
||
westText = '東南東'
|
||
} else if (compass === 225) {
|
||
// 북,동 가능
|
||
// 북(남동)
|
||
// 동(남서)
|
||
// 남(북서)
|
||
// 서(북동)
|
||
northText = '南東'
|
||
eastText = '南西'
|
||
southText = '北西'
|
||
westText = '北東'
|
||
} else if (compass < 270) {
|
||
// 북동남 가능
|
||
// 북(동남동)
|
||
// 동(남남서)
|
||
// 남(서북서)
|
||
// 서(북북동)
|
||
northText = '東南東'
|
||
eastText = '南南西'
|
||
southText = '西北西'
|
||
westText = '北北東'
|
||
} else if (compass === 270) {
|
||
// 북동남 가능
|
||
// 북(동)
|
||
// 동(남)
|
||
// 남(서)
|
||
// 서(북)
|
||
northText = '東'
|
||
eastText = '南'
|
||
southText = '西'
|
||
westText = '北'
|
||
} else if (compass < 315) {
|
||
// 북,동,남 가능
|
||
// 북(동북동)
|
||
// 동(남남동)
|
||
// 남(서남서)
|
||
// 서(북북서)
|
||
northText = '東北東'
|
||
eastText = '南南東'
|
||
southText = '西南西'
|
||
westText = '北北西'
|
||
} else if (compass === 315) {
|
||
// 동,남 가능
|
||
// 북(북동)
|
||
// 동(남동)
|
||
// 남(남서)
|
||
// 서(북서)
|
||
northText = '北東'
|
||
eastText = '南東'
|
||
southText = '南西'
|
||
westText = '北西'
|
||
} else if (compass < 360) {
|
||
// 남,동,서 가능
|
||
// 북(북북동)
|
||
// 동(동남동)
|
||
// 남(남남서)
|
||
// 서(서북서)
|
||
northText = '北北東'
|
||
eastText = '東南東'
|
||
southText = '南南西'
|
||
westText = '西北西'
|
||
}
|
||
|
||
clearFlowText(canvas)
|
||
|
||
addTextByArrows(eastArrows, eastText, canvas)
|
||
addTextByArrows(westArrows, westText, canvas)
|
||
addTextByArrows(northArrows, northText, canvas)
|
||
addTextByArrows(southArrows, southText, canvas)
|
||
}
|
||
|
||
const clearFlowText = (canvas) => {
|
||
const texts = canvas.getObjects().filter((obj) => obj.name === 'flowText')
|
||
texts.forEach((text) => {
|
||
canvas.remove(text)
|
||
})
|
||
}
|
||
|
||
const addTextByArrows = (arrows, txt, canvas) => {
|
||
arrows.forEach((arrow, index) => {
|
||
// const textStr = `${txt}${index + 1} (${currentAngleType === ANGLE_TYPE.SLOPE ? arrow.pitch : getDegreeByChon(arrow.pitch)}${pitchText})`
|
||
const textStr = `${txt} (${currentAngleType === ANGLE_TYPE.SLOPE ? arrow.pitch : (arrow.roofAngle ?? getDegreeByChon(arrow.pitch))}${pitchText})`
|
||
|
||
const text = new fabric.Text(`${textStr}`, {
|
||
fontFamily: flowFontOptions.fontFamily.value,
|
||
fontWeight: flowFontOptions.fontWeight.value.toLowerCase().includes('bold') ? 'bold' : 'normal',
|
||
fontStyle: flowFontOptions.fontWeight.value.toLowerCase().includes('italic') ? 'italic' : 'normal',
|
||
fontSize: flowFontOptions.fontSize.value,
|
||
fill: flowFontOptions.fontColor.value,
|
||
pitch: arrow.pitch,
|
||
roofAngle: arrow.roofAngle,
|
||
originX: 'center',
|
||
originY: 'center',
|
||
name: 'flowText',
|
||
originText: `${txt}${index + 1}`,
|
||
selectable: false,
|
||
left: arrow.stickeyPoint.x,
|
||
top: arrow.stickeyPoint.y,
|
||
parent: arrow,
|
||
parentId: arrow.id,
|
||
visible: isFlowDisplay,
|
||
})
|
||
canvas.add(text)
|
||
})
|
||
}
|
||
|
||
const splitPolygonWithLines = (polygon) => {
|
||
polygon.set({ visible: false })
|
||
|
||
const auxiliaryLines = canvas.getObjects().filter((obj) => obj.name === 'auxiliaryLine')
|
||
|
||
let innerLines = [...polygon.innerLines].filter((line) => line.visible)
|
||
|
||
// [SPLIT-ALLOC-DIAG 2026-06-01] 분할 graph 진입 전 innerLines 전수 dump.
|
||
// 케라바 패턴 라인(kerabPatternRidge/ExtRidge/Hip/ExtHip)과 원래 ridge(RG-2 등) 가
|
||
// type='default' / isStart 미설정으로 graph 시드/유효점 후보에서 제외되는지 추적용.
|
||
logger.log(
|
||
`[SPLIT-ALLOC-DIAG] polygon.id=${polygon.id?.slice(0, 8)} innerLines.visible=${innerLines.length} polygonLines=${polygon.lines?.length ?? 0}`,
|
||
)
|
||
innerLines.forEach((l, i) => {
|
||
logger.log(
|
||
` [SPLIT-ALLOC-DIAG ${i}] lineName=${l.lineName ?? 'none'} name=${l.name} ` +
|
||
`type=${l.attributes?.type ?? 'none'} isStart=${l.attributes?.isStart ?? false} ` +
|
||
`(${l.x1?.toFixed(1)},${l.y1?.toFixed(1)})→(${l.x2?.toFixed(1)},${l.y2?.toFixed(1)})`,
|
||
)
|
||
})
|
||
;(polygon.lines || []).forEach((l, i) => {
|
||
logger.log(
|
||
` [SPLIT-ALLOC-DIAG outer ${i}] lineName=${l.lineName ?? 'none'} name=${l.name} ` +
|
||
`type=${l.attributes?.type ?? 'none'} isStart=${l.attributes?.isStart ?? false} ` +
|
||
`(${l.x1?.toFixed(1)},${l.y1?.toFixed(1)})→(${l.x2?.toFixed(1)},${l.y2?.toFixed(1)})`,
|
||
)
|
||
})
|
||
const __splitLineRec = (l) => ({
|
||
lineName: l.lineName ?? 'none',
|
||
name: l.name,
|
||
type: l.attributes?.type ?? 'none',
|
||
isStart: l.attributes?.isStart ?? false,
|
||
x1: Math.round(l.x1 * 100) / 100,
|
||
y1: Math.round(l.y1 * 100) / 100,
|
||
x2: Math.round(l.x2 * 100) / 100,
|
||
y2: Math.round(l.y2 * 100) / 100,
|
||
})
|
||
debugCapture.log('SPLIT-ALLOC-DIAG', {
|
||
polygonId: polygon.id?.slice(0, 8),
|
||
visibleInner: innerLines.length,
|
||
polygonLines: polygon.lines?.length ?? 0,
|
||
inner: innerLines.map(__splitLineRec),
|
||
outer: (polygon.lines || []).map(__splitLineRec),
|
||
})
|
||
|
||
/*// innerLine이 세팅이 안되어있는경우 찾아서 세팅한다.
|
||
if (!innerLines || innerLines.length === 0) {
|
||
let innerLineTypes = Object.keys(LINE_TYPE.SUBLINE).map((key, value) => LINE_TYPE.SUBLINE[key])
|
||
polygon.innerLines = canvas
|
||
.getObjects()
|
||
.filter(
|
||
(obj) =>
|
||
obj.type === 'QLine' &&
|
||
obj.attributes?.type !== 'pitchSizeLine' &&
|
||
obj.attributes?.roofId === polygon.id &&
|
||
innerLineTypes.includes(obj.name),
|
||
)
|
||
|
||
innerLines = [...polygon.innerLines]
|
||
}*/
|
||
canvas.renderAll()
|
||
let polygonLines = [...polygon.lines]
|
||
|
||
// polygonLines와 innerLines의 겹침을 확인하고 type을 변경하는 함수
|
||
const checkLineOverlap = (line1, line2) => {
|
||
// 두 선분이 같은 직선 위에 있는지 확인
|
||
const isOnSameLine = (l1, l2) => {
|
||
// 수직선인 경우 (x1 == x2)
|
||
if (Math.abs(l1.x1 - l1.x2) < 1 && Math.abs(l2.x1 - l2.x2) < 1) {
|
||
return Math.abs(l1.x1 - l2.x1) < 1
|
||
}
|
||
// 수평선인 경우 (y1 == y2)
|
||
if (Math.abs(l1.y1 - l1.y2) < 1 && Math.abs(l2.y1 - l2.y2) < 1) {
|
||
return Math.abs(l1.y1 - l2.y1) < 1
|
||
}
|
||
// 대각선인 경우는 기울기가 같은지 확인
|
||
const slope1 = (l1.y2 - l1.y1) / (l1.x2 - l1.x1)
|
||
const slope2 = (l2.y2 - l2.y1) / (l2.x2 - l2.x1)
|
||
const intercept1 = l1.y1 - slope1 * l1.x1
|
||
const intercept2 = l2.y1 - slope2 * l2.x1
|
||
return Math.abs(slope1 - slope2) < 0.01 && Math.abs(intercept1 - intercept2) < 1
|
||
}
|
||
|
||
if (!isOnSameLine(line1, line2)) {
|
||
return false
|
||
}
|
||
|
||
// 선분들이 같은 직선 위에 있다면 겹치는 부분이 있는지 확인
|
||
const getLineRange = (line) => {
|
||
if (Math.abs(line.x1 - line.x2) < 1) {
|
||
// 수직선: y 범위 확인
|
||
return {
|
||
min: Math.min(line.y1, line.y2),
|
||
max: Math.max(line.y1, line.y2),
|
||
}
|
||
} else {
|
||
// 수평선 또는 대각선: x 범위 확인
|
||
return {
|
||
min: Math.min(line.x1, line.x2),
|
||
max: Math.max(line.x1, line.x2),
|
||
}
|
||
}
|
||
}
|
||
|
||
const range1 = getLineRange(line1)
|
||
const range2 = getLineRange(line2)
|
||
|
||
// 겹치는 부분이 있는지 확인
|
||
return !(range1.max < range2.min || range2.max < range1.min)
|
||
}
|
||
|
||
polygonLines.forEach((line) => {
|
||
line.need = true
|
||
})
|
||
// 순서에 의존하지 않도록 모든 조합을 먼저 확인한 후 처리
|
||
const innerLineMapping = new Map() // innerLine -> polygonLine 매핑 저장
|
||
|
||
// innerLines와 polygonLines의 겹침을 확인하고 type 변경
|
||
innerLines.forEach((innerLine) => {
|
||
polygonLines.forEach((polygonLine) => {
|
||
if (polygonLine.attributes.type === LINE_TYPE.WALLLINE.EAVES) {
|
||
return
|
||
}
|
||
if (checkLineOverlap(innerLine, polygonLine)) {
|
||
// innerLine의 type을 polygonLine의 type으로 변경
|
||
if (innerLine.attributes && polygonLine.attributes.type) {
|
||
// innerLine이 polygonLine보다 긴 경우 polygonLine.need를 false로 변경
|
||
if (polygonLine.length < innerLine.length) {
|
||
if (polygonLine.lineName !== 'eaveHelpLine' && polygonLine.lineName !== 'extensionLine') {
|
||
polygonLine.need = false
|
||
}
|
||
}
|
||
// innerLine.attributes.planeSize = innerLine.attributes.planeSize ?? polygonLine.attributes.planeSize
|
||
// innerLine.attributes.actualSize = innerLine.attributes.actualSize ?? polygonLine.attributes.actualSize
|
||
// innerLine.attributes.type = polygonLine.attributes.type
|
||
// innerLine.direction = polygonLine.direction
|
||
// innerLine.attributes.isStart = true
|
||
// innerLine.parentLine = polygonLine
|
||
|
||
// 매핑된 innerLine의 attributes를 변경 (교차점 계산 전에 적용)
|
||
innerLineMapping.forEach((polygonLine, innerLine) => {
|
||
innerLine.attributes.planeSize = innerLine.attributes.planeSize ?? polygonLine.attributes.planeSize
|
||
innerLine.attributes.actualSize = innerLine.attributes.actualSize ?? polygonLine.attributes.actualSize
|
||
innerLine.attributes.type = polygonLine.attributes.type
|
||
innerLine.direction = polygonLine.direction
|
||
innerLine.attributes.isStart = true
|
||
innerLine.parentLine = polygonLine
|
||
})
|
||
}
|
||
}
|
||
})
|
||
})
|
||
|
||
const roofs = []
|
||
polygonLines = polygonLines.filter((line) => line.need)
|
||
|
||
//polygonLines를 순회하며 innerLines와 교차하는 점을 line의 속성에 배열로 저장한다.
|
||
polygonLines.forEach((line) => {
|
||
let startPoint // 시작점
|
||
let endPoint // 끝점
|
||
if (line.x1 < line.x2) {
|
||
startPoint = { x: line.x1, y: line.y1 }
|
||
endPoint = { x: line.x2, y: line.y2 }
|
||
} else if (line.x1 > line.x2) {
|
||
startPoint = { x: line.x2, y: line.y2 }
|
||
endPoint = { x: line.x1, y: line.y1 }
|
||
} else {
|
||
if (line.y1 < line.y2) {
|
||
startPoint = { x: line.x1, y: line.y1 }
|
||
endPoint = { x: line.x2, y: line.y2 }
|
||
} else {
|
||
startPoint = { x: line.x2, y: line.y2 }
|
||
endPoint = { x: line.x1, y: line.y1 }
|
||
}
|
||
}
|
||
|
||
line.startPoint = startPoint
|
||
line.endPoint = endPoint
|
||
})
|
||
innerLines.forEach((line) => {
|
||
let startPoint // 시작점
|
||
let endPoint // 끝점
|
||
if (line.x1 < line.x2) {
|
||
startPoint = { x: line.x1, y: line.y1 }
|
||
endPoint = { x: line.x2, y: line.y2 }
|
||
} else if (line.x1 > line.x2) {
|
||
startPoint = { x: line.x2, y: line.y2 }
|
||
endPoint = { x: line.x1, y: line.y1 }
|
||
} else {
|
||
if (line.y1 < line.y2) {
|
||
startPoint = { x: line.x1, y: line.y1 }
|
||
endPoint = { x: line.x2, y: line.y2 }
|
||
} else {
|
||
startPoint = { x: line.x2, y: line.y2 }
|
||
endPoint = { x: line.x1, y: line.y1 }
|
||
}
|
||
}
|
||
|
||
line.startPoint = startPoint
|
||
line.endPoint = endPoint
|
||
})
|
||
|
||
// polygonLines과 innerLines에서 startPoint, endPoint가 같은 라인을 innerLines에서 제거하고 canvas에서도 제거
|
||
const linesToRemove = []
|
||
innerLines = innerLines.filter((innerLine) => {
|
||
const shouldRemove = polygonLines.some((polygonLine) => {
|
||
return (
|
||
(isSamePoint(innerLine.startPoint, polygonLine.startPoint) && isSamePoint(innerLine.endPoint, polygonLine.endPoint)) ||
|
||
(isSamePoint(innerLine.startPoint, polygonLine.endPoint) && isSamePoint(innerLine.endPoint, polygonLine.startPoint))
|
||
)
|
||
})
|
||
if (shouldRemove) {
|
||
linesToRemove.push(innerLine)
|
||
}
|
||
return !shouldRemove
|
||
})
|
||
|
||
// 중복된 라인들을 canvas에서 제거
|
||
linesToRemove.forEach((line) => {
|
||
canvas.remove(line)
|
||
})
|
||
|
||
// innerLines가 합쳐졌을 때 polygonLine과 같은 경우 그 polygonLine의 need를 false로 변경
|
||
const mergeOverlappingInnerLines = (lines) => {
|
||
const mergedLines = []
|
||
const processed = new Set()
|
||
|
||
lines.forEach((line, index) => {
|
||
if (processed.has(index)) return
|
||
|
||
let currentLine = { ...line }
|
||
processed.add(index)
|
||
|
||
// 현재 라인과 겹치는 다른 라인들을 찾아서 합치기
|
||
for (let i = index + 1; i < lines.length; i++) {
|
||
if (processed.has(i)) continue
|
||
|
||
const otherLine = lines[i]
|
||
if (checkLineOverlap(currentLine, otherLine)) {
|
||
// 두 라인을 합치기 - 가장 긴 범위로 확장
|
||
const isVertical = Math.abs(currentLine.x1 - currentLine.x2) < 1
|
||
|
||
if (isVertical) {
|
||
const allYPoints = [currentLine.y1, currentLine.y2, otherLine.y1, otherLine.y2]
|
||
currentLine.y1 = Math.min(...allYPoints)
|
||
currentLine.y2 = Math.max(...allYPoints)
|
||
currentLine.x1 = currentLine.x2 = (currentLine.x1 + otherLine.x1) / 2
|
||
} else {
|
||
const allXPoints = [currentLine.x1, currentLine.x2, otherLine.x1, otherLine.x2]
|
||
currentLine.x1 = Math.min(...allXPoints)
|
||
currentLine.x2 = Math.max(...allXPoints)
|
||
currentLine.y1 = currentLine.y2 = (currentLine.y1 + otherLine.y1) / 2
|
||
}
|
||
|
||
processed.add(i)
|
||
}
|
||
}
|
||
|
||
mergedLines.push(currentLine)
|
||
})
|
||
|
||
return mergedLines
|
||
}
|
||
|
||
const mergedInnerLines = mergeOverlappingInnerLines(innerLines)
|
||
|
||
// 합쳐진 innerLine과 동일한 polygonLine의 need를 false로 설정
|
||
polygonLines.forEach((polygonLine) => {
|
||
mergedInnerLines.forEach((mergedInnerLine) => {
|
||
const isSameLine =
|
||
(isSamePoint(polygonLine.startPoint, mergedInnerLine.startPoint) && isSamePoint(polygonLine.endPoint, mergedInnerLine.endPoint)) ||
|
||
(isSamePoint(polygonLine.startPoint, mergedInnerLine.endPoint) && isSamePoint(polygonLine.endPoint, mergedInnerLine.startPoint))
|
||
|
||
if (isSameLine) {
|
||
polygonLine.need = false
|
||
}
|
||
})
|
||
})
|
||
|
||
canvas.renderAll()
|
||
/*polygonLines.forEach((line) => {
|
||
line.set({ strokeWidth: 10 })
|
||
canvas.add(line)
|
||
})
|
||
canvas.renderAll()*/
|
||
|
||
polygonLines = polygonLines.filter((line) => line.need)
|
||
|
||
polygonLines.forEach((line) => {
|
||
/*const originStroke = line.stroke
|
||
line.set({ stroke: 'red' })
|
||
canvas.renderAll()*/
|
||
const intersections = []
|
||
innerLines.forEach((innerLine) => {
|
||
/*const originInnerStroke = innerLine.stroke
|
||
innerLine.set({ stroke: 'red' })
|
||
canvas.renderAll()*/
|
||
if (checkLineOverlap(line, innerLine)) {
|
||
return
|
||
}
|
||
if (isPointOnLine(line, innerLine.startPoint)) {
|
||
canvas.renderAll()
|
||
if (isSamePoint(line.startPoint, innerLine.startPoint) || isSamePoint(line.endPoint, innerLine.startPoint)) {
|
||
return
|
||
}
|
||
intersections.push(innerLine.startPoint)
|
||
}
|
||
if (isPointOnLine(line, innerLine.endPoint)) {
|
||
canvas.renderAll()
|
||
if (isSamePoint(line.startPoint, innerLine.endPoint) || isSamePoint(line.endPoint, innerLine.endPoint)) {
|
||
return
|
||
}
|
||
intersections.push(innerLine.endPoint)
|
||
}
|
||
/*innerLine.set({ stroke: originInnerStroke })
|
||
canvas.renderAll()*/
|
||
})
|
||
line.set({ intersections })
|
||
|
||
/*line.set({ stroke: originStroke })
|
||
canvas.renderAll()*/
|
||
})
|
||
|
||
const divideLines = polygonLines.filter((line) => line.intersections?.length > 0)
|
||
let newLines = []
|
||
polygonLines = polygonLines.filter((line) => !line.intersections || line.intersections.length === 0)
|
||
for (let i = divideLines.length - 1; i >= 0; i--) {
|
||
const line = divideLines[i]
|
||
const { intersections, startPoint, endPoint } = line
|
||
|
||
// 원본 라인의 기하학적 길이 (비율 계산용)
|
||
// [ROUND-PRECISION 2026-05-14] Math.round(hypot)*10 → calcLinePlaneSize (Big.js, ×10 후 round).
|
||
// 기존: 45.6 → Math.round(45.6)*10 = 460. 伏せ図入力 의 Big.js 경로는 456.
|
||
// 배치면 split 결과와 伏せ図入力 SK 빌더의 0.5mm boundary 가 갈리는 근본 원인.
|
||
const originalGeomLength = calcLinePlaneSize({ x1: line.x1, y1: line.y1, x2: line.x2, y2: line.y2 })
|
||
|
||
if (intersections.length === 1) {
|
||
const newLinePoint1 = [line.x1, line.y1, intersections[0].x, intersections[0].y]
|
||
const newLinePoint2 = [intersections[0].x, intersections[0].y, line.x2, line.y2]
|
||
|
||
const newLine1 = new QLine(newLinePoint1, {
|
||
stroke: 'blue',
|
||
strokeWidth: 3,
|
||
fontSize: polygon.fontSize,
|
||
attributes: line.attributes,
|
||
name: 'newLine',
|
||
})
|
||
const newLine2 = new QLine(newLinePoint2, {
|
||
stroke: 'blue',
|
||
strokeWidth: 3,
|
||
fontSize: polygon.fontSize,
|
||
attributes: line.attributes,
|
||
name: 'newLine',
|
||
})
|
||
|
||
// 분할된 각 세그먼트의 기하학적 길이
|
||
// [ROUND-PRECISION 2026-05-14] calcLinePlaneSize 사용 — 위 originalGeomLength 와 동일 이유.
|
||
const length1 = calcLinePlaneSize({ x1: newLine1.x1, y1: newLine1.y1, x2: newLine1.x2, y2: newLine1.y2 })
|
||
const length2 = calcLinePlaneSize({ x1: newLine2.x1, y1: newLine2.y1, x2: newLine2.x2, y2: newLine2.y2 })
|
||
|
||
// 분할 시 새 sub-segment 의 planeSize/actualSize 는 새 기하학으로 직접 계산.
|
||
// 부모 비율을 쓰면 부모 좌표 drift 가 그대로 전파되어 사용자 기대 round 값과 어긋남.
|
||
// sum 이 부모 입력 planeSize 와 ±20mm 차이일 때 잔차 보정:
|
||
// - sum < parent (drift +): geomLen 이 underestimated → 잔차를 SHORTER 에 더해
|
||
// LONGER 의 round 값(예: 910)을 보존
|
||
// - sum > parent (drift -): geomLen 이 overestimated → 잔차를 LONGER 에서 빼서
|
||
// SHORTER 의 round 값(예: 460)을 보존
|
||
let planeSize1 = length1
|
||
let planeSize2 = length2
|
||
let actualSize1 = length1
|
||
let actualSize2 = length2
|
||
|
||
const _redistribute = (parentVal, p1, p2) => {
|
||
if (!parentVal) return [p1, p2]
|
||
const sum = p1 + p2
|
||
const drift = parentVal - sum
|
||
if (Math.abs(drift) > 20) return [p1, p2] // 큰 차이는 보정하지 않음
|
||
if (drift === 0) return [p1, p2]
|
||
// drift > 0: SHORTER 에 잔차 추가 → LONGER 보존
|
||
// drift < 0: LONGER 에 잔차 제거 → SHORTER 보존
|
||
if (drift > 0) {
|
||
if (length1 <= length2) return [parentVal - p2, p2]
|
||
return [p1, parentVal - p1]
|
||
} else {
|
||
if (length1 >= length2) return [parentVal - p2, p2]
|
||
return [p1, parentVal - p1]
|
||
}
|
||
}
|
||
|
||
const parentPlane = line.attributes?.planeSize
|
||
const parentActual = line.attributes?.actualSize
|
||
;[planeSize1, planeSize2] = _redistribute(parentPlane, planeSize1, planeSize2)
|
||
;[actualSize1, actualSize2] = _redistribute(parentActual, actualSize1, actualSize2)
|
||
|
||
// 2분할에서 양 끝이 거의 같을 때(차이 ≤ 5mm) 대칭성 강제: 두 값을 평균으로 통일
|
||
// 예: ridge 가 wall 을 거의 정확히 양분 → 1637 / 1638 → 둘 다 1637.5
|
||
// 합이 홀수이면 0.5 소수점을 유지하여 양쪽이 정확히 같은 값으로 표시되도록 함
|
||
const _equalizeIfNearSymmetric = (p1, p2) => {
|
||
if (!p1 || !p2) return [p1, p2]
|
||
if (Math.abs(p1 - p2) > 5) return [p1, p2]
|
||
const avg = (p1 + p2) / 2 // 0.5 소수점 허용 (예: 3275 / 2 = 1637.5)
|
||
// 0.5 단위로 정밀도 유지 (Big number/이상한 부동소수점 방지)
|
||
const halfPrecision = Math.round(avg * 2) / 2
|
||
return [halfPrecision, halfPrecision]
|
||
}
|
||
;[planeSize1, planeSize2] = _equalizeIfNearSymmetric(planeSize1, planeSize2)
|
||
;[actualSize1, actualSize2] = _equalizeIfNearSymmetric(actualSize1, actualSize2)
|
||
|
||
newLine1.attributes = {
|
||
...line.attributes,
|
||
planeSize: planeSize1,
|
||
actualSize: actualSize1,
|
||
}
|
||
newLine1.length = length1
|
||
newLine2.attributes = {
|
||
...line.attributes,
|
||
planeSize: planeSize2,
|
||
actualSize: actualSize2,
|
||
}
|
||
newLine2.length = length2
|
||
|
||
newLines.push(newLine1, newLine2)
|
||
divideLines.splice(i, 1) // 기존 line 제거
|
||
} else {
|
||
let currentPoint = startPoint
|
||
|
||
while (intersections.length !== 0) {
|
||
const minDistancePoint = findAndRemoveClosestPoint(currentPoint, intersections)
|
||
const newLinePoint = [currentPoint.x, currentPoint.y, minDistancePoint.x, minDistancePoint.y]
|
||
|
||
const newLine = new QLine(newLinePoint, {
|
||
stroke: 'blue',
|
||
strokeWidth: 3,
|
||
fontSize: polygon.fontSize,
|
||
attributes: line.attributes,
|
||
name: 'newLine',
|
||
})
|
||
|
||
// [ROUND-PRECISION 2026-05-14] calcLinePlaneSize 사용 — Big.js ×10 후 round.
|
||
const calcLength = calcLinePlaneSize({ x1: newLine.x1, y1: newLine.y1, x2: newLine.x2, y2: newLine.y2 })
|
||
|
||
let segPlaneSize, segActualSize
|
||
if (line.attributes.planeSize && originalGeomLength > 0) {
|
||
segPlaneSize = Math.round(line.attributes.planeSize * (calcLength / originalGeomLength))
|
||
} else {
|
||
segPlaneSize = calcLength
|
||
}
|
||
if (line.attributes.actualSize && originalGeomLength > 0) {
|
||
segActualSize = Math.round(line.attributes.actualSize * (calcLength / originalGeomLength))
|
||
} else {
|
||
segActualSize = calcLength
|
||
}
|
||
|
||
newLine.attributes = {
|
||
...line.attributes,
|
||
planeSize: segPlaneSize,
|
||
actualSize: segActualSize,
|
||
}
|
||
newLine.length = calcLength
|
||
|
||
newLines.push(newLine)
|
||
currentPoint = minDistancePoint
|
||
}
|
||
|
||
const newLinePoint = [currentPoint.x, currentPoint.y, endPoint.x, endPoint.y]
|
||
const newLine = new QLine(newLinePoint, {
|
||
stroke: 'blue',
|
||
strokeWidth: 3,
|
||
fontSize: polygon.fontSize,
|
||
attributes: line.attributes,
|
||
name: 'newLine',
|
||
})
|
||
// [ROUND-PRECISION 2026-05-14] calcLinePlaneSize 사용 — Big.js ×10 후 round.
|
||
const lastCalcLength = calcLinePlaneSize({ x1: newLine.x1, y1: newLine.y1, x2: newLine.x2, y2: newLine.y2 })
|
||
|
||
let lastPlaneSize, lastActualSize
|
||
if (line.attributes.planeSize && originalGeomLength > 0) {
|
||
lastPlaneSize = Math.round(line.attributes.planeSize * (lastCalcLength / originalGeomLength))
|
||
} else {
|
||
lastPlaneSize = lastCalcLength
|
||
}
|
||
if (line.attributes.actualSize && originalGeomLength > 0) {
|
||
lastActualSize = Math.round(line.attributes.actualSize * (lastCalcLength / originalGeomLength))
|
||
} else {
|
||
lastActualSize = lastCalcLength
|
||
}
|
||
|
||
newLine.attributes = {
|
||
...line.attributes,
|
||
planeSize: lastPlaneSize,
|
||
actualSize: lastActualSize,
|
||
}
|
||
newLine.length = lastCalcLength
|
||
|
||
newLines.push(newLine)
|
||
|
||
// 홀수 분할(3, 5, ...) 시 양 끝(첫·마지막) sub-segment 의 길이를 평균값으로 동일하게 보정
|
||
// 패턴 A/B 등 대칭 입력 wall 이 hip lines 로 분할될 때 좌우 대칭성을 강제로 유지
|
||
const newSubsForThisLine = newLines.slice(-(intersections.length + 1)) // 이 부모 line 이 만든 sub-segment 들
|
||
const segCount = newSubsForThisLine.length
|
||
if (segCount >= 3 && segCount % 2 === 1) {
|
||
const first = newSubsForThisLine[0]
|
||
const last = newSubsForThisLine[segCount - 1]
|
||
const avgPlane = Math.round((first.attributes.planeSize + last.attributes.planeSize) / 2)
|
||
const avgActual = Math.round((first.attributes.actualSize + last.attributes.actualSize) / 2)
|
||
// 두 끝값의 차이가 작을 때만 평균화 (대칭성 가정 케이스)
|
||
if (Math.abs(first.attributes.planeSize - last.attributes.planeSize) <= 20) {
|
||
first.attributes = { ...first.attributes, planeSize: avgPlane, actualSize: avgActual }
|
||
last.attributes = { ...last.attributes, planeSize: avgPlane, actualSize: avgActual }
|
||
}
|
||
}
|
||
|
||
divideLines.splice(i, 1) // 기존 line 제거
|
||
}
|
||
}
|
||
|
||
//polygonLines에서 divideLines를 제거하고 newLines를 추가한다.
|
||
newLines = newLines.filter((line) => !(Math.abs(line.startPoint.x - line.endPoint.x) < 1 && Math.abs(line.startPoint.y - line.endPoint.y) < 1))
|
||
|
||
polygonLines = [...polygonLines, ...newLines]
|
||
|
||
polygonLines.forEach((polygonLine) => {
|
||
polygonLine.attributes = { ...polygonLine.attributes, isStart: true }
|
||
})
|
||
|
||
// [지붕면 공유변 fix] 보조선(innerLines)도 isStart=true 로 표시해 외곽선과 동일한 distance 페널티(+1000)를
|
||
// 받게 한다. 미설정 시 보조선(빗변)이 페널티 없이 항상 최단으로 선택돼, 면이 외곽 변(예: 직사각형
|
||
// 왼쪽변)을 두고 빗변으로 우회 → 오각형이 생긴다. (isValidPoint 가 type=default 끝점을 막아 면 자체는 안 늘어남)
|
||
innerLines.forEach((line) => {
|
||
line.attributes = { ...line.attributes, isStart: true }
|
||
})
|
||
|
||
let allLines = [...polygonLines, ...innerLines]
|
||
|
||
// allLines를 전부 돌면서 교차점이 있는 경우 그 line을 잘라서 allLines에 추가
|
||
const processIntersections = () => {
|
||
const linesToProcess = canvas.getObjects().filter((obj) => obj.type === 'QLine' && obj.name === 'auxiliaryLine' && obj.visible)
|
||
const newDividedLines = []
|
||
const processedLines = new Set()
|
||
|
||
for (let i = 0; i < linesToProcess.length; i++) {
|
||
for (let j = i + 1; j < linesToProcess.length; j++) {
|
||
const line1 = linesToProcess[i]
|
||
const line2 = linesToProcess[j]
|
||
|
||
// 이미 처리된 line들은 건너뛰기
|
||
if (processedLines.has(line1) || processedLines.has(line2)) continue
|
||
|
||
// 같은 line이거나 이미 연결된 line은 건너뛰기
|
||
if (line1 === line2) continue
|
||
if (
|
||
isSamePoint({ x: line1.x1, y: line1.y1 }, { x: line2.x1, y: line2.y1 }) ||
|
||
isSamePoint({ x: line1.x1, y: line1.y1 }, { x: line2.x2, y: line2.y2 }) ||
|
||
isSamePoint({ x: line1.x2, y: line1.y2 }, { x: line2.x1, y: line2.y1 }) ||
|
||
isSamePoint({ x: line1.x2, y: line1.y2 }, { x: line2.x2, y: line2.y2 })
|
||
) {
|
||
continue
|
||
}
|
||
|
||
const intersectionPoint = calculateIntersection(line1, line2)
|
||
if (intersectionPoint) {
|
||
// line1에 교차점 추가
|
||
if (!line1.intersectionPoints) line1.intersectionPoints = []
|
||
line1.intersectionPoints.push(intersectionPoint)
|
||
|
||
// line2에 교차점 추가
|
||
if (!line2.intersectionPoints) line2.intersectionPoints = []
|
||
line2.intersectionPoints.push(intersectionPoint)
|
||
}
|
||
}
|
||
}
|
||
|
||
// 교차점이 있는 line들을 분할
|
||
linesToProcess.forEach((line) => {
|
||
if (line.intersectionPoints && line.intersectionPoints.length > 0) {
|
||
// 교차점들을 line의 시작점에서부터의 거리순으로 정렬
|
||
const sortedPoints = line.intersectionPoints.sort((a, b) => {
|
||
const distA = Math.hypot(a.x - line.x1, a.y - line.y1)
|
||
const distB = Math.hypot(b.x - line.x1, b.y - line.y1)
|
||
return distA - distB
|
||
})
|
||
|
||
let currentPoint = { x: line.x1, y: line.y1 }
|
||
|
||
// 각 교차점까지의 line segment 생성
|
||
sortedPoints.forEach((intersectionPoint) => {
|
||
if (!isSamePoint(currentPoint, intersectionPoint)) {
|
||
const newLine = new QLine([currentPoint.x, currentPoint.y, intersectionPoint.x, intersectionPoint.y], {
|
||
stroke: line.stroke,
|
||
strokeWidth: line.strokeWidth,
|
||
fontSize: line.fontSize,
|
||
attributes: { ...line.attributes },
|
||
name: line.name,
|
||
visible: line.visible,
|
||
})
|
||
// startPoint와 endPoint 설정
|
||
if (newLine.x1 < newLine.x2) {
|
||
newLine.startPoint = { x: newLine.x1, y: newLine.y1 }
|
||
newLine.endPoint = { x: newLine.x2, y: newLine.y2 }
|
||
} else if (newLine.x1 > newLine.x2) {
|
||
newLine.startPoint = { x: newLine.x2, y: newLine.y2 }
|
||
newLine.endPoint = { x: newLine.x1, y: newLine.y1 }
|
||
} else {
|
||
if (newLine.y1 < newLine.y2) {
|
||
newLine.startPoint = { x: newLine.x1, y: newLine.y1 }
|
||
newLine.endPoint = { x: newLine.x2, y: newLine.y2 }
|
||
} else {
|
||
newLine.startPoint = { x: newLine.x2, y: newLine.y2 }
|
||
newLine.endPoint = { x: newLine.x1, y: newLine.y1 }
|
||
}
|
||
}
|
||
newDividedLines.push(newLine)
|
||
}
|
||
currentPoint = intersectionPoint
|
||
})
|
||
|
||
// 마지막 교차점에서 line 끝점까지의 segment
|
||
const endPoint = { x: line.x2, y: line.y2 }
|
||
if (!isSamePoint(currentPoint, endPoint)) {
|
||
const newLine = new QLine([currentPoint.x, currentPoint.y, endPoint.x, endPoint.y], {
|
||
stroke: line.stroke,
|
||
strokeWidth: line.strokeWidth,
|
||
fontSize: line.fontSize,
|
||
attributes: { ...line.attributes },
|
||
name: line.name,
|
||
visible: line.visible,
|
||
})
|
||
// startPoint와 endPoint 설정
|
||
if (newLine.x1 < newLine.x2) {
|
||
newLine.startPoint = { x: newLine.x1, y: newLine.y1 }
|
||
newLine.endPoint = { x: newLine.x2, y: newLine.y2 }
|
||
} else if (newLine.x1 > newLine.x2) {
|
||
newLine.startPoint = { x: newLine.x2, y: newLine.y2 }
|
||
newLine.endPoint = { x: newLine.x1, y: newLine.y1 }
|
||
} else {
|
||
if (newLine.y1 < newLine.y2) {
|
||
newLine.startPoint = { x: newLine.x1, y: newLine.y1 }
|
||
newLine.endPoint = { x: newLine.x2, y: newLine.y2 }
|
||
} else {
|
||
newLine.startPoint = { x: newLine.x2, y: newLine.y2 }
|
||
newLine.endPoint = { x: newLine.x1, y: newLine.y1 }
|
||
}
|
||
}
|
||
newDividedLines.push(newLine)
|
||
}
|
||
|
||
processedLines.add(line)
|
||
}
|
||
})
|
||
|
||
// allLines 업데이트: 분할된 line들 제거하고 새 line들 추가
|
||
allLines = allLines.filter((line) => !processedLines.has(line))
|
||
allLines = [...allLines, ...newDividedLines]
|
||
}
|
||
|
||
// 교차점 처리 실행
|
||
processIntersections()
|
||
|
||
/*allLines.forEach((line) => {
|
||
const originColor = line.stroke
|
||
|
||
line.set('stroke', 'red')
|
||
canvas.renderAll()
|
||
|
||
line.set('stroke', originColor)
|
||
canvas.renderAll()
|
||
})*/
|
||
|
||
const allPoints = []
|
||
|
||
// test용 좌표
|
||
const polygonLinesPoints = polygonLines.map((line) => {
|
||
return { startPoint: line.startPoint, endPoint: line.endPoint }
|
||
})
|
||
|
||
const innerLinesPoints = innerLines.map((line) => {
|
||
return { startPoint: line.startPoint, endPoint: line.endPoint }
|
||
})
|
||
|
||
polygonLinesPoints.forEach(({ startPoint, endPoint }) => {
|
||
allPoints.push(startPoint)
|
||
allPoints.push(endPoint)
|
||
})
|
||
|
||
innerLinesPoints.forEach(({ startPoint, endPoint }) => {
|
||
allPoints.push(startPoint)
|
||
allPoints.push(endPoint)
|
||
})
|
||
|
||
// 2025-02-19 대각선은 케라바, 직선은 용마루로 세팅
|
||
innerLines.forEach((innerLine) => {
|
||
const startPoint = innerLine.startPoint
|
||
const endPoint = innerLine.endPoint
|
||
|
||
// startPoint와 endPoint의 각도가 0,90,180,270이면 직선으로 판단
|
||
if (Math.abs(startPoint.x - endPoint.x) < 2 || Math.abs(startPoint.y - endPoint.y) < 2) {
|
||
if (!innerLine.attributes || !innerLine.attributes.type || innerLine.attributes.type === 'default') {
|
||
innerLine.attributes = {
|
||
...innerLine.attributes,
|
||
type: LINE_TYPE.SUBLINE.RIDGE,
|
||
}
|
||
}
|
||
} else {
|
||
if (!innerLine.attributes || !innerLine.attributes.type || innerLine.attributes.type === 'default') {
|
||
innerLine.attributes = {
|
||
...innerLine.attributes,
|
||
type: LINE_TYPE.SUBLINE.GABLE,
|
||
}
|
||
}
|
||
}
|
||
})
|
||
|
||
/*innerLines.forEach((line) => {
|
||
const startPoint = line.startPoint
|
||
const endPoint = line.endPoint
|
||
/!*canvas.add(new fabric.Circle({ left: startPoint.x, top: startPoint.y + 10, radius: 5, fill: 'red' }))
|
||
canvas.add(new fabric.Circle({ left: endPoint.x, top: endPoint.y - 10, radius: 5, fill: 'blue' }))*!/
|
||
})*/
|
||
|
||
/**
|
||
* 왼쪽 상단을 startPoint로 전부 변경
|
||
*/
|
||
allLines.forEach((line) => {
|
||
let startPoint // 시작점
|
||
let endPoint // 끝점
|
||
if (line.x1 < line.x2) {
|
||
startPoint = { x: line.x1, y: line.y1 }
|
||
endPoint = { x: line.x2, y: line.y2 }
|
||
} else if (line.x1 > line.x2) {
|
||
startPoint = { x: line.x2, y: line.y2 }
|
||
endPoint = { x: line.x1, y: line.y1 }
|
||
} else {
|
||
if (line.y1 < line.y2) {
|
||
startPoint = { x: line.x1, y: line.y1 }
|
||
endPoint = { x: line.x2, y: line.y2 }
|
||
} else {
|
||
startPoint = { x: line.x2, y: line.y2 }
|
||
endPoint = { x: line.x1, y: line.y1 }
|
||
}
|
||
}
|
||
|
||
line.startPoint = startPoint
|
||
line.endPoint = endPoint
|
||
})
|
||
|
||
//allLines에서 중복을 제거한다.
|
||
allLines = allLines.filter((line, index, self) => {
|
||
return (
|
||
index ===
|
||
self.findIndex((l) => {
|
||
return (
|
||
(isSamePoint(l.startPoint, line.startPoint) && isSamePoint(l.endPoint, line.endPoint)) ||
|
||
(isSamePoint(l.startPoint, line.endPoint) && isSamePoint(l.endPoint, line.startPoint))
|
||
)
|
||
})
|
||
)
|
||
})
|
||
|
||
allLines = allLines.filter((line) => {
|
||
return Math.abs(line.startPoint.x - line.endPoint.x) > 2 || Math.abs(line.startPoint.y - line.endPoint.y) > 2
|
||
})
|
||
|
||
// [SUPERSEDED-EAVES 2026-06-15] 외곽선(처마/케라바)을 바깥으로 옮겨 처마(연·軒)를 만들면, 옮긴 선과 양끝
|
||
// 연결선(모두 auxiliaryLine)·원래 외곽선(eaves/gable)이 닫힌 "띠 사각형"을 이룬다. 이때 원래 외곽선이
|
||
// 면분할 입력에 남아 있으면 바깥 면을 [띠 + 사다리꼴]로 갈라, 사다리꼴을 닫는 시작선이 사라져 면이 통째로
|
||
// 누락된다(GETSPLIT-IO 진단). 원래 외곽선은 이미 면 내부의 가상선이므로 면분할 입력에서 제외 → 띠+사다리꼴이
|
||
// 하나의 면(예: 6각형)으로 병합된다. 띠 패턴(양끝 연결선 + 평행 닫힘선)이 없으면 아무 영향 없음.
|
||
// [2026-06-16] eaves 외에 gable(아래변 이동 L자형)·shed(한쪽흐름 왼쪽변 이동→8각형) 외곽선도 동일 처리.
|
||
// 연결선이 대각(챔퍼)이어도 끝점만 공유하면 매칭됨.
|
||
const auxBandLines = allLines.filter((l) => l.name === 'auxiliaryLine')
|
||
if (auxBandLines.length > 0) {
|
||
const sharesPt = (l, p) => isSamePoint(l.startPoint, p) || isSamePoint(l.endPoint, p)
|
||
const otherEnd = (l, p) => (isSamePoint(l.startPoint, p) ? l.endPoint : l.startPoint)
|
||
const supersededEaves = allLines.filter((E) => {
|
||
// 외곽 경계선(처마 eaves / 케라바 gable / 한쪽흐름 shed)만 후보 — 보조선·내부선(용마루/추녀)은 제외.
|
||
const type = E.attributes?.type
|
||
if (type !== 'eaves' && type !== 'gable' && type !== 'shed') return false
|
||
const A = E.startPoint
|
||
const B = E.endPoint
|
||
// A·B 각각에서 바깥으로 뻗는 연결 보조선 후보(한쪽 끝점만 공유). 한 끝점에 여러 개일 수 있다
|
||
// (스텝 이동: 중점에 좌·우 띠로 가는 대각이 둘 다 붙음). 첫 매치만 쓰면 잘못된 짝을 골라 닫힘선을
|
||
// 못 찾으므로, 모든 CA×CB 조합을 시도해 "두 바깥 끝점을 잇는 평행 닫힘 보조선"이 있는 조합이
|
||
// 하나라도 있으면 띠로 인정한다.
|
||
const candA = auxBandLines.filter((c) => c !== E && sharesPt(c, A) && !sharesPt(c, B))
|
||
const candB = auxBandLines.filter((c) => c !== E && sharesPt(c, B) && !sharesPt(c, A))
|
||
return candA.some((CA) =>
|
||
candB.some((CB) => {
|
||
if (CA === CB) return false
|
||
const Ao = otherEnd(CA, A)
|
||
const Bo = otherEnd(CB, B)
|
||
return auxBandLines.some((c) => c !== CA && c !== CB && sharesPt(c, Ao) && sharesPt(c, Bo))
|
||
}),
|
||
)
|
||
})
|
||
if (supersededEaves.length > 0) {
|
||
logger.log(`[SUPERSEDED-EAVES] 띠로 대체된 원래 외곽선(처마/케라바) ${supersededEaves.length}개 면분할 입력에서 제외`)
|
||
allLines = allLines.filter((l) => !supersededEaves.includes(l))
|
||
}
|
||
}
|
||
|
||
// [MOVED-DIVIDER 2026-06-17] 내부 분할선(예: A타입 중앙 3868)을 옮기면 경계선만 새 위치로 재분할되고
|
||
// 스켈레톤 hip 은 옛 위치에서 만나, 옮긴 보조선 끝점이 hip 에 노드로 안 붙어 분할이 안 되고 면이
|
||
// 겹쳐 생긴다(ROOF-FACE-DIAG: 거의 동일한 면 2개). 해결:
|
||
// (1) 내부 보조선 끝점이 hip 몸통 위(strict)에 있으면 그 hip 을 끝점에서 split → 보조선이 연결됨.
|
||
// (2) split 이 일어난 경우에만, 옛 접합점이 degree-2 공선(共線) 잉여 노드가 되면 merge → 옛 분할점 제거.
|
||
// 게이트: 실제 split 이 일어난 경우(=내부분할선 이동)에만 (2) 수행 → 외곽선 띠(SUPERSEDED) 케이스는 불변.
|
||
{
|
||
const innerAux = allLines.filter((l) => l.name === 'auxiliaryLine')
|
||
const isInner = (l) => l.name === 'hip' || l.name === 'ridge'
|
||
const EPS = 2
|
||
const mkSeg = (proto, sp, ep) => ({
|
||
name: proto.name,
|
||
lineName: proto.lineName,
|
||
attributes: { ...proto.attributes },
|
||
startPoint: { x: sp.x, y: sp.y },
|
||
endPoint: { x: ep.x, y: ep.y },
|
||
x1: sp.x,
|
||
y1: sp.y,
|
||
x2: ep.x,
|
||
y2: ep.y,
|
||
})
|
||
const onBodyStrict = (L, P) => {
|
||
const ax = L.startPoint.x,
|
||
ay = L.startPoint.y
|
||
const dx = L.endPoint.x - ax,
|
||
dy = L.endPoint.y - ay
|
||
const lenSq = dx * dx + dy * dy
|
||
if (lenSq < 1) return false
|
||
const t = ((P.x - ax) * dx + (P.y - ay) * dy) / lenSq
|
||
if (t <= 0.03 || t >= 0.97) return false
|
||
const px = ax + t * dx,
|
||
py = ay + t * dy
|
||
return (P.x - px) ** 2 + (P.y - py) ** 2 < EPS * EPS
|
||
}
|
||
if (innerAux.length > 0) {
|
||
const auxPts = []
|
||
innerAux.forEach((a) => {
|
||
auxPts.push(a.startPoint, a.endPoint)
|
||
})
|
||
// (1) 내부선을 보조선 끝점에서 split
|
||
let didSplit = false
|
||
const afterSplit = []
|
||
allLines.forEach((L) => {
|
||
if (!isInner(L)) {
|
||
afterSplit.push(L)
|
||
return
|
||
}
|
||
const ax = L.startPoint.x,
|
||
ay = L.startPoint.y
|
||
const dx = L.endPoint.x - ax,
|
||
dy = L.endPoint.y - ay
|
||
const lenSq = dx * dx + dy * dy || 1
|
||
const tOf = (P) => ((P.x - ax) * dx + (P.y - ay) * dy) / lenSq
|
||
const cuts = [...new Map(auxPts.filter((P) => onBodyStrict(L, P)).map((P) => [Math.round(tOf(P) * 1000), P])).values()].sort(
|
||
(p, q) => tOf(p) - tOf(q),
|
||
)
|
||
if (cuts.length === 0) {
|
||
afterSplit.push(L)
|
||
return
|
||
}
|
||
didSplit = true
|
||
let cur = L.startPoint
|
||
cuts.forEach((P) => {
|
||
afterSplit.push(mkSeg(L, cur, P))
|
||
cur = P
|
||
})
|
||
afterSplit.push(mkSeg(L, cur, L.endPoint))
|
||
})
|
||
if (didSplit) {
|
||
let work = afterSplit
|
||
const key = (p) => `${Math.round(p.x)},${Math.round(p.y)}`
|
||
// (2) degree-2 공선 내부선 접합점 merge (잉여 옛 분할점 제거)
|
||
let merged = true
|
||
let guard = 0
|
||
while (merged && guard++ < 200) {
|
||
merged = false
|
||
const incident = {}
|
||
work.forEach((L) => {
|
||
;[L.startPoint, L.endPoint].forEach((p) => {
|
||
;(incident[key(p)] || (incident[key(p)] = [])).push(L)
|
||
})
|
||
})
|
||
for (const k in incident) {
|
||
const inc = incident[k]
|
||
if (inc.length !== 2) continue
|
||
const [la, lb] = inc
|
||
if (la === lb || !isInner(la) || !isInner(lb)) continue
|
||
const [kx, ky] = k.split(',').map(Number)
|
||
const farA = Math.abs(la.startPoint.x - kx) < 1 && Math.abs(la.startPoint.y - ky) < 1 ? la.endPoint : la.startPoint
|
||
const farB = Math.abs(lb.startPoint.x - kx) < 1 && Math.abs(lb.startPoint.y - ky) < 1 ? lb.endPoint : lb.startPoint
|
||
const cross = (kx - farA.x) * (farB.y - ky) - (ky - farA.y) * (farB.x - kx)
|
||
const norm = Math.hypot(kx - farA.x, ky - farA.y) * Math.hypot(farB.x - kx, farB.y - ky)
|
||
if (norm < 1 || Math.abs(cross) / norm > 0.02) continue
|
||
work = work.filter((L) => L !== la && L !== lb)
|
||
work.push(mkSeg(la, farA, farB))
|
||
merged = true
|
||
break
|
||
}
|
||
}
|
||
// 동일 좌표 변 중복 제거
|
||
work = work.filter(
|
||
(line, idx, self) =>
|
||
idx ===
|
||
self.findIndex(
|
||
(o) =>
|
||
(isSamePoint(o.startPoint, line.startPoint) && isSamePoint(o.endPoint, line.endPoint)) ||
|
||
(isSamePoint(o.startPoint, line.endPoint) && isSamePoint(o.endPoint, line.startPoint)),
|
||
),
|
||
)
|
||
logger.log(`[MOVED-DIVIDER] 내부 분할선 이동 감지 — hip 재분할 적용 (allLines ${allLines.length}→${work.length})`)
|
||
allLines = work
|
||
}
|
||
}
|
||
}
|
||
|
||
// [INNER-TJUNCTION-SPLIT 2026-06-29] 내부선(추녀 hip / 용마루 ridge)의 끝점이 다른 내부선의 몸통(strict
|
||
// interior)에 닿는 T-접합인데 그 내부선에 노드가 없으면, getPath 가 그 지점에서 꺾지 못해 한쪽 면이
|
||
// 통째로 누락된다. (A/B TYPE 박공→처마: ridge x=747(64.2→266.4) 의 내부 (747,236.4) 에 hip
|
||
// (662.3,236.4)→(747,236.4) 끝점이 닿는데 ridge 가 안 잘려 우측 6각형 면이 안 생김 — GETSPLIT-IO 진단.)
|
||
// MOVED-DIVIDER 는 auxiliaryLine 끝점만, 외곽 split 루프는 외곽선만 자른다 → inner-inner T-접합은 둘 다 미커버.
|
||
// 여기서 다른 내부선 끝점 위치에서 내부선을 split(노드 삽입)만 한다. merge 없음 — 정상 평면분할 노드를 추가만 하므로
|
||
// 기존에 닫히던 면은 불변(노드가 명시될 뿐).
|
||
{
|
||
const isInnerT = (l) => l.name === 'hip' || l.name === 'ridge'
|
||
const EPS_T = 2
|
||
const mkSegT = (proto, sp, ep) => ({
|
||
name: proto.name,
|
||
lineName: proto.lineName,
|
||
attributes: { ...proto.attributes },
|
||
startPoint: { x: sp.x, y: sp.y },
|
||
endPoint: { x: ep.x, y: ep.y },
|
||
x1: sp.x,
|
||
y1: sp.y,
|
||
x2: ep.x,
|
||
y2: ep.y,
|
||
})
|
||
const onBodyStrictT = (L, P) => {
|
||
const ax = L.startPoint.x
|
||
const ay = L.startPoint.y
|
||
const dx = L.endPoint.x - ax
|
||
const dy = L.endPoint.y - ay
|
||
const lenSq = dx * dx + dy * dy
|
||
if (lenSq < 1) return false
|
||
const t = ((P.x - ax) * dx + (P.y - ay) * dy) / lenSq
|
||
if (t <= 0.03 || t >= 0.97) return false
|
||
const px = ax + t * dx
|
||
const py = ay + t * dy
|
||
return (P.x - px) ** 2 + (P.y - py) ** 2 < EPS_T * EPS_T
|
||
}
|
||
const innerLinesT = allLines.filter(isInnerT)
|
||
if (innerLinesT.length > 1) {
|
||
const innerPts = []
|
||
innerLinesT.forEach((l) => {
|
||
innerPts.push(l.startPoint, l.endPoint)
|
||
})
|
||
let didTSplit = false
|
||
const afterT = []
|
||
allLines.forEach((L) => {
|
||
if (!isInnerT(L)) {
|
||
afterT.push(L)
|
||
return
|
||
}
|
||
const ax = L.startPoint.x
|
||
const ay = L.startPoint.y
|
||
const dx = L.endPoint.x - ax
|
||
const dy = L.endPoint.y - ay
|
||
const lenSq = dx * dx + dy * dy || 1
|
||
const tOf = (P) => ((P.x - ax) * dx + (P.y - ay) * dy) / lenSq
|
||
const cuts = [...new Map(innerPts.filter((P) => onBodyStrictT(L, P)).map((P) => [Math.round(tOf(P) * 1000), P])).values()].sort(
|
||
(p, q) => tOf(p) - tOf(q),
|
||
)
|
||
if (cuts.length === 0) {
|
||
afterT.push(L)
|
||
return
|
||
}
|
||
didTSplit = true
|
||
let cur = L.startPoint
|
||
cuts.forEach((P) => {
|
||
afterT.push(mkSegT(L, cur, P))
|
||
cur = P
|
||
})
|
||
afterT.push(mkSegT(L, cur, L.endPoint))
|
||
})
|
||
if (didTSplit) {
|
||
logger.log(`[INNER-TJUNCTION-SPLIT] inner-inner T-접합 노드 삽입 (allLines ${allLines.length}→${afterT.length})`)
|
||
allLines = afterT
|
||
}
|
||
}
|
||
}
|
||
|
||
// 나눠서 중복 제거된 roof return
|
||
let newRoofs = getSplitRoofsPoints(allLines)
|
||
|
||
const createdRoofs = []
|
||
|
||
newRoofs = newRoofs.filter((roof) => roof.length !== 0)
|
||
newRoofs.forEach((roofPoint, index) => {
|
||
let defense, pitch
|
||
|
||
let representLines = []
|
||
let representLine
|
||
|
||
// 지붕을 그리면서 기존 polygon의 line중 연결된 line을 찾는다.
|
||
;[...polygonLines, ...innerLines].forEach((line) => {
|
||
let startFlag = false
|
||
let endFlag = false
|
||
const startPoint = line.startPoint
|
||
const endPoint = line.endPoint
|
||
roofPoint.forEach((point, index) => {
|
||
if (isSamePoint(point, startPoint)) {
|
||
startFlag = true
|
||
}
|
||
if (isSamePoint(point, endPoint)) {
|
||
endFlag = true
|
||
}
|
||
})
|
||
|
||
if (startFlag && endFlag) {
|
||
if (
|
||
!representLines.includes(line) &&
|
||
(line.attributes.type === LINE_TYPE.WALLLINE.EAVES || line.attributes.type === LINE_TYPE.WALLLINE.EAVE_HELP_LINE)
|
||
) {
|
||
representLines.push(line)
|
||
} else if (!representLines.includes(line) && line.attributes.type === LINE_TYPE.WALLLINE.HIPANDGABLE) {
|
||
representLines.push(line)
|
||
}
|
||
}
|
||
})
|
||
|
||
// representLines가 없다면 A,B타입중 하나임
|
||
if (representLines.length === 0) {
|
||
// 1. roofPoint로 폴리곤의 라인들을 생성
|
||
const roofPolygonLines = []
|
||
for (let i = 0; i < roofPoint.length; i++) {
|
||
const nextIndex = (i + 1) % roofPoint.length
|
||
const startPt = roofPoint[i]
|
||
const endPt = roofPoint[nextIndex]
|
||
roofPolygonLines.push({
|
||
x1: startPt.x,
|
||
y1: startPt.y,
|
||
x2: endPt.x,
|
||
y2: endPt.y,
|
||
startPoint: startPt,
|
||
endPoint: endPt,
|
||
})
|
||
}
|
||
|
||
// 3. 평행 여부 확인 함수
|
||
const checkParallel = (line1, line2) => {
|
||
const v1x = line1.x2 - line1.x1
|
||
const v1y = line1.y2 - line1.y1
|
||
const v2x = line2.x2 - line2.x1
|
||
const v2y = line2.y2 - line2.y1
|
||
|
||
const length1 = Math.sqrt(v1x ** 2 + v1y ** 2)
|
||
const length2 = Math.sqrt(v2x ** 2 + v2y ** 2)
|
||
|
||
if (length1 === 0 || length2 === 0) return false
|
||
|
||
const norm1x = v1x / length1
|
||
const norm1y = v1y / length1
|
||
const norm2x = v2x / length2
|
||
const norm2y = v2y / length2
|
||
|
||
const EPSILON = 0.01
|
||
const crossProduct = Math.abs(norm1x * norm2y - norm1y * norm2x)
|
||
const dotProduct = norm1x * norm2x + norm1y * norm2y
|
||
|
||
return crossProduct < EPSILON || Math.abs(Math.abs(dotProduct) - 1) < EPSILON
|
||
}
|
||
|
||
// 4. 점에서 라인까지의 거리 계산 함수
|
||
const getDistanceFromPointToLine = (point, lineP1, lineP2) => {
|
||
const A = point.x - lineP1.x
|
||
const B = point.y - lineP1.y
|
||
const C = lineP2.x - lineP1.x
|
||
const D = lineP2.y - lineP1.y
|
||
|
||
const dot = A * C + B * D
|
||
const lenSq = C * C + D * D
|
||
let param = -1
|
||
|
||
if (lenSq !== 0) {
|
||
param = dot / lenSq
|
||
}
|
||
|
||
let xx, yy
|
||
|
||
if (param < 0) {
|
||
xx = lineP1.x
|
||
yy = lineP1.y
|
||
} else if (param > 1) {
|
||
xx = lineP2.x
|
||
yy = lineP2.y
|
||
} else {
|
||
xx = lineP1.x + param * C
|
||
yy = lineP1.y + param * D
|
||
}
|
||
|
||
const dx = point.x - xx
|
||
const dy = point.y - yy
|
||
|
||
return Math.sqrt(dx * dx + dy * dy)
|
||
}
|
||
|
||
// 5. 두 평행한 라인 사이의 거리 계산 (한 라인의 중점에서 다른 라인까지의 거리)
|
||
const getDistanceBetweenParallelLines = (line1, line2) => {
|
||
const midPoint = {
|
||
x: (line1.x1 + line1.x2) / 2,
|
||
y: (line1.y1 + line1.y2) / 2,
|
||
}
|
||
return getDistanceFromPointToLine(midPoint, { x: line2.x1, y: line2.y1 }, { x: line2.x2, y: line2.y2 })
|
||
}
|
||
|
||
// 6. roofPolygonLines의 모든 라인에서 평행하면서 가장 가까운 EAVES 라인 찾기
|
||
let closestLine = null
|
||
let minDistance = Infinity
|
||
|
||
roofPolygonLines.forEach((roofLine) => {
|
||
;[...polygonLines, ...innerLines].forEach((line) => {
|
||
// EAVES 타입만 필터링
|
||
if (line.attributes?.type !== LINE_TYPE.WALLLINE.EAVES && line.attributes?.type !== LINE_TYPE.WALLLINE.EAVE_HELP_LINE) {
|
||
return
|
||
}
|
||
|
||
const lineObj = {
|
||
x1: line.startPoint.x,
|
||
y1: line.startPoint.y,
|
||
x2: line.endPoint.x,
|
||
y2: line.endPoint.y,
|
||
}
|
||
|
||
if (checkParallel(roofLine, lineObj)) {
|
||
const distance = getDistanceBetweenParallelLines(roofLine, lineObj)
|
||
if (distance < minDistance && distance > 0) {
|
||
minDistance = distance
|
||
closestLine = line
|
||
}
|
||
}
|
||
})
|
||
})
|
||
|
||
if (closestLine) {
|
||
representLines.push(closestLine)
|
||
}
|
||
}
|
||
|
||
// representLines중 가장 긴 line을 찾는다.
|
||
representLines.forEach((line) => {
|
||
if (!representLine) {
|
||
representLine = line
|
||
} else {
|
||
if (getLengthByLine(representLine) < getLengthByLine(line)) {
|
||
representLine = line
|
||
}
|
||
}
|
||
})
|
||
|
||
if (!representLine) {
|
||
representLines.forEach((line) => {
|
||
if (!representLine) {
|
||
representLine = line
|
||
} else {
|
||
if (representLine.length < line.length) {
|
||
representLine = line
|
||
}
|
||
}
|
||
})
|
||
}
|
||
|
||
const direction = polygon.direction ?? representLine?.direction ?? ''
|
||
const polygonDirection = polygon.direction
|
||
|
||
switch (direction) {
|
||
case 'top':
|
||
defense = 'east'
|
||
break
|
||
case 'right':
|
||
defense = 'south'
|
||
break
|
||
case 'bottom':
|
||
defense = 'west'
|
||
break
|
||
case 'left':
|
||
defense = 'north'
|
||
break
|
||
default:
|
||
defense = 'south'
|
||
break
|
||
}
|
||
pitch = polygon.lines[index]?.attributes?.pitch ?? representLine?.attributes?.pitch ?? globalPitch
|
||
|
||
const roof = new QPolygon(roofPoint, {
|
||
fontSize: polygon.fontSize,
|
||
stroke: 'black',
|
||
fill: 'transparent',
|
||
strokeWidth: 3,
|
||
name: POLYGON_TYPE.ROOF,
|
||
originX: 'center',
|
||
originY: 'center',
|
||
selectable: true,
|
||
defense: defense,
|
||
from: 'roofCover',
|
||
direction: polygonDirection ?? defense,
|
||
pitch: pitch,
|
||
})
|
||
|
||
//allLines중 생성된 roof와 관련있는 line을 찾는다.
|
||
|
||
const roofLines = [...polygonLines, ...polygon.innerLines].filter((line) => {
|
||
let startFlag = false
|
||
let endFlag = false
|
||
const startPoint = line.startPoint
|
||
const endPoint = line.endPoint
|
||
roofPoint.forEach((point, index) => {
|
||
if (isSamePoint(point, startPoint)) {
|
||
startFlag = true
|
||
}
|
||
if (isSamePoint(point, endPoint)) {
|
||
endFlag = true
|
||
}
|
||
})
|
||
|
||
return startFlag && endFlag
|
||
})
|
||
|
||
// 1차 매칭: 양 끝점이 정확히 일치하는 부모 line 의 attributes 복사
|
||
const matchedRoofLines = new Set()
|
||
roofLines.forEach((line) => {
|
||
roof.lines.forEach((roofLine) => {
|
||
if (
|
||
(isSamePoint(line.startPoint, roofLine.startPoint) && isSamePoint(line.endPoint, roofLine.endPoint)) ||
|
||
(isSamePoint(line.startPoint, roofLine.endPoint) && isSamePoint(line.endPoint, roofLine.startPoint))
|
||
) {
|
||
roofLine.attributes = { ...line.attributes }
|
||
matchedRoofLines.add(roofLine)
|
||
}
|
||
})
|
||
})
|
||
|
||
// 2차 매칭 (옵션 3 + α): 대각선이 외벽 중간을 분할한 sub-segment 처리
|
||
const _isCollinearWithin = (parent, p) => {
|
||
const ax = parent.startPoint.x,
|
||
ay = parent.startPoint.y
|
||
const bx = parent.endPoint.x,
|
||
by = parent.endPoint.y
|
||
const dx = bx - ax,
|
||
dy = by - ay
|
||
const segLenSq = dx * dx + dy * dy
|
||
if (segLenSq < 1) return false
|
||
const cross = (p.x - ax) * dy - (p.y - ay) * dx
|
||
if (Math.abs(cross) / Math.sqrt(segLenSq) > 2) return false
|
||
const t = ((p.x - ax) * dx + (p.y - ay) * dy) / segLenSq
|
||
return t >= -0.001 && t <= 1.001
|
||
}
|
||
const _segLen = (s, e) => Math.sqrt((s.x - e.x) ** 2 + (s.y - e.y) ** 2)
|
||
|
||
const parentCandidates = [...polygonLines, ...polygon.innerLines]
|
||
roof.lines.forEach((roofLine) => {
|
||
if (matchedRoofLines.has(roofLine)) return
|
||
const parent = parentCandidates.find(
|
||
(p) => p?.startPoint && p?.endPoint && _isCollinearWithin(p, roofLine.startPoint) && _isCollinearWithin(p, roofLine.endPoint),
|
||
)
|
||
if (!parent) return
|
||
const parentLen = _segLen(parent.startPoint, parent.endPoint)
|
||
if (parentLen < 1) return
|
||
const subLen = _segLen(roofLine.startPoint, roofLine.endPoint)
|
||
const ratio = subLen / parentLen
|
||
|
||
const parentAttrs = parent.attributes || {}
|
||
const parentPlane = Number(parentAttrs.planeSize)
|
||
const parentActual = Number(parentAttrs.actualSize)
|
||
const propagatedPlane = Number.isFinite(parentPlane) ? Math.round(parentPlane * ratio) : null
|
||
const propagatedActual = Number.isFinite(parentActual) ? Math.round(parentActual * ratio) : null
|
||
|
||
roofLine.attributes = {
|
||
...parentAttrs,
|
||
...(propagatedPlane != null ? { planeSize: propagatedPlane } : {}),
|
||
...(propagatedActual != null ? { actualSize: propagatedActual } : {}),
|
||
}
|
||
matchedRoofLines.add(roofLine)
|
||
})
|
||
|
||
// canvas.add(roof)
|
||
createdRoofs.push(roof)
|
||
canvas.remove(polygon)
|
||
canvas.renderAll()
|
||
})
|
||
|
||
//지붕 완료 후 보조선을 전부 제거한다.
|
||
|
||
auxiliaryLines.forEach((line) => {
|
||
canvas.remove(line)
|
||
})
|
||
|
||
createdRoofs.forEach((roof) => {
|
||
canvas.add(roof)
|
||
})
|
||
|
||
canvas.renderAll()
|
||
canvas.discardActiveObject()
|
||
}
|
||
|
||
const getSplitRoofsPoints = (allLines) => {
|
||
// 모든 좌표점들을 수집
|
||
const allPoints = []
|
||
allLines.forEach((line, lineIndex) => {
|
||
allPoints.push({ point: line.startPoint, lineIndex, pointType: 'start' })
|
||
allPoints.push({ point: line.endPoint, lineIndex, pointType: 'end' })
|
||
})
|
||
|
||
// X 좌표 통일
|
||
for (let i = 0; i < allPoints.length; i++) {
|
||
for (let j = i + 1; j < allPoints.length; j++) {
|
||
const point1 = allPoints[i].point
|
||
const point2 = allPoints[j].point
|
||
|
||
if (Math.abs(point1.x - point2.x) < 1) {
|
||
const maxX = Math.max(point1.x, point2.x)
|
||
point1.x = maxX
|
||
point2.x = maxX
|
||
}
|
||
}
|
||
}
|
||
|
||
// Y 좌표 통일
|
||
for (let i = 0; i < allPoints.length; i++) {
|
||
for (let j = i + 1; j < allPoints.length; j++) {
|
||
const point1 = allPoints[i].point
|
||
const point2 = allPoints[j].point
|
||
|
||
if (Math.abs(point1.y - point2.y) < 1) {
|
||
const maxY = Math.max(point1.y, point2.y)
|
||
point1.y = maxY
|
||
point2.y = maxY
|
||
}
|
||
}
|
||
}
|
||
|
||
// ==== Utility functions ====
|
||
|
||
function isSamePoint(p1, p2, epsilon = 1) {
|
||
return Math.abs(p1.x - p2.x) <= epsilon && Math.abs(p1.y - p2.y) <= epsilon
|
||
}
|
||
|
||
function normalizePoint(p, epsilon = 1) {
|
||
return {
|
||
x: Math.round(p.x / epsilon) * epsilon,
|
||
y: Math.round(p.y / epsilon) * epsilon,
|
||
}
|
||
}
|
||
|
||
function pointToKey(p, epsilon = 1) {
|
||
const norm = normalizePoint(p, epsilon)
|
||
return `${norm.x},${norm.y}`
|
||
}
|
||
|
||
// 거리 계산
|
||
function calcDistance(p1, p2) {
|
||
return Math.hypot(p2.x - p1.x, p2.y - p1.y)
|
||
}
|
||
|
||
// ==== Direct edge check ====
|
||
|
||
function isDirectlyConnected(start, end, graph, epsilon = 1) {
|
||
const startKey = pointToKey(start, epsilon)
|
||
return (graph[startKey] || []).some((neighbor) => isSamePoint(neighbor.point, end, epsilon))
|
||
}
|
||
|
||
// ==== Dijkstra pathfinding ====
|
||
|
||
// function findShortestPath(start, end, graph, epsilon = 1) {
|
||
// const startKey = pointToKey(start, epsilon)
|
||
// const endKey = pointToKey(end, epsilon)
|
||
//
|
||
// const distances = {}
|
||
// const previous = {}
|
||
// const visited = new Set()
|
||
// const queue = [{ key: startKey, dist: 0 }]
|
||
//
|
||
// for (const key in graph) distances[key] = Infinity
|
||
// distances[startKey] = 0
|
||
//
|
||
// while (queue.length > 0) {
|
||
// queue.sort((a, b) => a.dist - b.dist)
|
||
// const { key } = queue.shift()
|
||
// if (visited.has(key)) continue
|
||
// visited.add(key)
|
||
//
|
||
// for (const neighbor of graph[key] || []) {
|
||
// const neighborKey = pointToKey(neighbor.point, epsilon)
|
||
// const alt = distances[key] + neighbor.distance
|
||
// if (alt < distances[neighborKey]) {
|
||
// distances[neighborKey] = alt
|
||
// previous[neighborKey] = key
|
||
// queue.push({ key: neighborKey, dist: alt })
|
||
// }
|
||
// }
|
||
// }
|
||
//
|
||
// const path = []
|
||
// let currentKey = endKey
|
||
//
|
||
// if (!previous[currentKey]) return null
|
||
//
|
||
// while (currentKey !== startKey) {
|
||
// const [x, y] = currentKey.split(',').map(Number)
|
||
// path.unshift({ x, y })
|
||
// currentKey = previous[currentKey]
|
||
// }
|
||
//
|
||
// const [sx, sy] = startKey.split(',').map(Number)
|
||
// path.unshift({ x: sx, y: sy })
|
||
//
|
||
// return path
|
||
// }
|
||
|
||
function findShortestPath(start, end, graph, epsilon = 1) {
|
||
const startKey = pointToKey(start, epsilon)
|
||
const endKey = pointToKey(end, epsilon)
|
||
|
||
// 거리와 이전 노드 추적
|
||
const distances = { [startKey]: 0 }
|
||
const previous = {}
|
||
const visited = new Set()
|
||
|
||
// 우선순위 큐 (거리가 짧은 순으로 정렬)
|
||
const queue = [{ key: startKey, dist: 0 }]
|
||
|
||
// 모든 노드 초기화
|
||
for (const key in graph) {
|
||
if (key !== startKey) {
|
||
distances[key] = Infinity
|
||
}
|
||
}
|
||
|
||
// 우선순위 큐에서 다음 노드 선택
|
||
const getNextNode = () => {
|
||
if (queue.length === 0) return null
|
||
queue.sort((a, b) => a.dist - b.dist)
|
||
return queue.shift()
|
||
}
|
||
|
||
let current
|
||
while ((current = getNextNode())) {
|
||
const currentKey = current.key
|
||
|
||
// 목적지에 도달하면 종료
|
||
if (currentKey === endKey) break
|
||
|
||
// 이미 방문한 노드는 건너뜀
|
||
if (visited.has(currentKey)) continue
|
||
visited.add(currentKey)
|
||
|
||
// 인접 노드 탐색
|
||
for (const neighbor of graph[currentKey] || []) {
|
||
const neighborKey = pointToKey(neighbor.point, epsilon)
|
||
if (visited.has(neighborKey)) continue
|
||
|
||
const alt = distances[currentKey] + neighbor.distance
|
||
|
||
// 더 짧은 경로를 찾은 경우 업데이트
|
||
if (alt < (distances[neighborKey] || Infinity)) {
|
||
distances[neighborKey] = alt
|
||
previous[neighborKey] = currentKey
|
||
|
||
// 우선순위 큐에 추가
|
||
queue.push({ key: neighborKey, dist: alt })
|
||
}
|
||
}
|
||
}
|
||
|
||
// 경로 재구성
|
||
const path = []
|
||
let currentKey = endKey
|
||
|
||
// 시작점에 도달할 때까지 역추적
|
||
while (previous[currentKey] !== undefined) {
|
||
const [x, y] = currentKey.split(',').map(Number)
|
||
path.unshift({ x, y })
|
||
currentKey = previous[currentKey]
|
||
}
|
||
|
||
// 시작점 추가
|
||
if (path.length > 0) {
|
||
const [sx, sy] = startKey.split(',').map(Number)
|
||
path.unshift({ x: sx, y: sy })
|
||
}
|
||
|
||
return path.length > 0 ? path : null
|
||
}
|
||
|
||
// 최종 함수
|
||
function getPath(start, end, graph, epsilon = 1) {
|
||
// startPoint와 arrivalPoint가 될 수 있는 점은 line.attributes.type이 'default' 혹은 null이 아닌 line인 경우에만 가능
|
||
const isValidPoint = (point) => {
|
||
return allLines.some((line) => {
|
||
const isOnLine = isSamePoint(line.startPoint, point, epsilon) || isSamePoint(line.endPoint, point, epsilon)
|
||
const hasValidType = line.attributes?.type && line.attributes.type !== 'default'
|
||
return isOnLine && hasValidType
|
||
})
|
||
}
|
||
|
||
if (!isValidPoint(start) || !isValidPoint(end)) {
|
||
console.log('시작점 또는 도착점이 유효하지 않음. 무시.')
|
||
return []
|
||
}
|
||
|
||
if (isDirectlyConnected(start, end, graph, epsilon)) {
|
||
console.log('직선 연결 있음. 무시.')
|
||
return []
|
||
}
|
||
|
||
const path = findShortestPath(start, end, graph, epsilon)
|
||
if (!path || path.length < 3) {
|
||
console.log('경로 존재하나 3개 미만 좌표. 무시.')
|
||
return []
|
||
}
|
||
|
||
// 사용된 노드들을 graph에서 제거
|
||
if (path.length > 0) {
|
||
path.forEach((point) => {
|
||
const pointKey = pointToKey(point, epsilon)
|
||
delete graph[pointKey]
|
||
// 다른 노드들의 연결에서도 이 노드를 제거
|
||
Object.keys(graph).forEach((key) => {
|
||
graph[key] = graph[key].filter((neighbor) => !isSamePoint(neighbor.point, point, epsilon))
|
||
})
|
||
})
|
||
}
|
||
|
||
return path
|
||
}
|
||
|
||
const roofs = []
|
||
const remainingLines = [...allLines] // 사용 가능한 line들의 복사본
|
||
|
||
// isStart가 true인 line들만 시작점으로 사용
|
||
const startLines = remainingLines.filter((line) => line.attributes?.isStart === true)
|
||
|
||
startLines.forEach((startLine) => {
|
||
// 현재 남아있는 line들로 그래프 생성
|
||
const graph = {}
|
||
|
||
// [지붕면 공유변 fix] graph 는 startLine 자신만 제외한 전체 allLines 로 구성한다.
|
||
// remainingLines(이미 사용된 startLine 이 제거됨)로 만들면, 두 면이 공유하는 외곽 변이 먼저 처리된
|
||
// 면에서 소비되어 다음 면 graph 에서 사라지고 그 면이 빗변으로 우회한다. startLine 소비는 후보
|
||
// 목록(remainingLines)에서만 하고, 경로 탐색용 graph 에는 항상 남겨 공유 변을 재사용하게 한다.
|
||
for (const line of allLines.filter((line2) => line2 !== startLine)) {
|
||
const p1 = line.startPoint
|
||
const p2 = line.endPoint
|
||
const key1 = pointToKey(p1)
|
||
const key2 = pointToKey(p2)
|
||
const distance = calcDistance(p1, p2)
|
||
const isStartLine = line.attributes?.isStart === true
|
||
|
||
if (!graph[key1]) graph[key1] = []
|
||
if (!graph[key2]) graph[key2] = []
|
||
|
||
// isStart가 아닌 line을 우선하도록 distance 조정
|
||
const adjustedDistance = isStartLine ? distance + 1000 : distance
|
||
|
||
graph[key1].push({ point: p2, distance: adjustedDistance, line })
|
||
graph[key2].push({ point: p1, distance: adjustedDistance, line })
|
||
}
|
||
|
||
const startPoint = { ...startLine.startPoint } // 시작점
|
||
let arrivalPoint = { ...startLine.endPoint } // 도착점
|
||
|
||
const roof = getPath(startPoint, arrivalPoint, graph)
|
||
if (roof.length > 0) {
|
||
roofs.push(roof)
|
||
|
||
// 사용된 startLine을 remainingLines에서 제거
|
||
const startLineIndex = remainingLines.findIndex((line) => line === startLine)
|
||
if (startLineIndex !== -1) {
|
||
remainingLines.splice(startLineIndex, 1)
|
||
}
|
||
}
|
||
})
|
||
|
||
// [GETSPLIT-IO 2026-06-15] getSplitRoofsPoints 의 정확한 입력(allLines, 좌표스냅·교차분할·중복제거 후)과
|
||
// 출력(roofs 면 polygon)을 debug/debug.log 로 영속화. 면분할 오할당 진단용 — 로컬 전용.
|
||
debugCapture.log('GETSPLIT-IO', {
|
||
allLines: allLines.map((l) => ({
|
||
name: l.name,
|
||
lineName: l.lineName ?? 'none',
|
||
type: l.attributes?.type ?? 'none',
|
||
isStart: l.attributes?.isStart ?? false,
|
||
sx: Math.round(l.startPoint.x * 100) / 100,
|
||
sy: Math.round(l.startPoint.y * 100) / 100,
|
||
ex: Math.round(l.endPoint.x * 100) / 100,
|
||
ey: Math.round(l.endPoint.y * 100) / 100,
|
||
})),
|
||
roofs: roofs.map((r) => r.map((p) => ({ x: Math.round(p.x * 100) / 100, y: Math.round(p.y * 100) / 100 }))),
|
||
})
|
||
|
||
return removeDuplicatePolygons(
|
||
roofs.filter((roof) => roof.length < 100),
|
||
allLines.some((line) => line.name === 'auxiliaryLine'),
|
||
)
|
||
}
|
||
|
||
const splitPolygonWithSeparate = (separates) => {
|
||
separates.forEach((separate) => {
|
||
const points = separate.lines.map((line) => {
|
||
return { x: line.x1, y: line.y1 }
|
||
})
|
||
let defense = ''
|
||
switch (separate.attributes.direction) {
|
||
case 'top':
|
||
defense = 'east'
|
||
break
|
||
case 'right':
|
||
defense = 'south'
|
||
break
|
||
case 'bottom':
|
||
defense = 'west'
|
||
break
|
||
case 'left':
|
||
defense = 'north'
|
||
break
|
||
}
|
||
|
||
const roof = new QPolygon(points, {
|
||
fontSize: separate.fontSize,
|
||
stroke: 'black',
|
||
fill: 'transparent',
|
||
strokeWidth: 3,
|
||
name: POLYGON_TYPE.ROOF,
|
||
originX: 'center',
|
||
originY: 'center',
|
||
selectable: true,
|
||
defense: defense,
|
||
pitch: separate.attributes.pitch,
|
||
direction: defense,
|
||
})
|
||
|
||
canvas.add(roof)
|
||
})
|
||
|
||
canvas.renderAll()
|
||
}
|
||
|
||
/**
|
||
* 폴리곤의 라인 속성을 복도치수, 실제치수에 따라 actualSize 설정
|
||
* @param polygon
|
||
* @param forceUpdate
|
||
*/
|
||
const setPolygonLinesActualSize = (polygon, forceUpdate = false) => {
|
||
if (!polygon.lines || polygon.lines.length === 0 || !polygon.roofMaterial) {
|
||
return
|
||
}
|
||
|
||
// 배치면으로 그린 내용은 업데이트를 해준다.
|
||
if (polygon.from === 'surface') {
|
||
forceUpdate = true
|
||
}
|
||
|
||
/*if (polygon.from !== 'surface') {
|
||
// createdRoofs들의 모든 lines를 확인해서 length값이 1이하인 차이가 있으면 통일 시킨다.
|
||
const allRoofs = canvas.getObjects().filter((obj) => obj.name === POLYGON_TYPE.ROOF)
|
||
const allRoofLines = allRoofs.flatMap((roof) => roof.lines)
|
||
for (let i = 0; i < allRoofLines.length; i++) {
|
||
for (let j = i + 1; j < allRoofLines.length; j++) {
|
||
const line1 = allRoofLines[i]
|
||
const line2 = allRoofLines[j]
|
||
const diff = Math.abs(line1.length - line2.length)
|
||
if (diff > 0 && diff <= 2) {
|
||
const maxLength = Math.max(line1.length, line2.length)
|
||
line1.setLengthByValue(maxLength * 10)
|
||
line2.setLengthByValue(maxLength * 10)
|
||
// attributes도 통일
|
||
const maxPlaneSize = Math.max(line1.attributes.planeSize || 0, line2.attributes.planeSize || 0)
|
||
const maxActualSize = Math.max(line1.attributes.actualSize || 0, line2.attributes.actualSize || 0)
|
||
line1.attributes.planeSize = maxPlaneSize
|
||
line1.attributes.actualSize = maxActualSize
|
||
line2.attributes.planeSize = maxPlaneSize
|
||
line2.attributes.actualSize = maxActualSize
|
||
}
|
||
}
|
||
}
|
||
}*/
|
||
|
||
polygon.lines.forEach((line, index) => {
|
||
if (line.attributes.isCalculated && !forceUpdate) {
|
||
return
|
||
}
|
||
//text 와 planSize 및 actualSize가 안맞는 문제
|
||
/*const nextText = polygon?.texts?.[index]?.text
|
||
const nextPlaneSize = Number(nextText)
|
||
if (nextText != null && nextText !== '' && Number.isFinite(nextPlaneSize)) {
|
||
if (line.attributes.actualSize !== nextPlaneSize && line.attributes.planeSize !== nextPlaneSize) {
|
||
line.attributes.planeSize = nextPlaneSize
|
||
}
|
||
}*/
|
||
|
||
setActualSize(line, polygon.direction, +polygon.roofMaterial?.pitch, forceUpdate)
|
||
})
|
||
|
||
// [hip 대칭 통일 2026-05-18] 할당 단계도 SK 빌드와 동일하게 hip 좌우대칭 라인을
|
||
// 평균 0.5 step 으로 통일. 좌표 float drift 로 hip 4개가 3832.5/3831.5/3833.5
|
||
// 처럼 갈리던 문제 차단.
|
||
// canvas 전체 ROOF 폴리곤의 hip 라인을 모아 cross-polygon 그룹화 (한 hip 이 두 face 에
|
||
// 공유되는 경우도 같은 그룹).
|
||
const allHips = []
|
||
const seenIds = new Set()
|
||
canvas?.getObjects()?.forEach((obj) => {
|
||
if (obj?.name !== POLYGON_TYPE.ROOF || !obj.lines) return
|
||
obj.lines.forEach((l) => {
|
||
if (l?.attributes?.type === LINE_TYPE.SUBLINE.HIP && !seenIds.has(l.id)) {
|
||
seenIds.add(l.id)
|
||
allHips.push(l)
|
||
}
|
||
})
|
||
})
|
||
if (allHips.length >= 2) {
|
||
equalizeSymmetricHips(allHips, canvas)
|
||
}
|
||
|
||
addLengthText(polygon)
|
||
}
|
||
|
||
return {
|
||
addPolygon,
|
||
addPolygonByLines,
|
||
removePolygon,
|
||
drawDirectionArrow,
|
||
addLengthText,
|
||
splitPolygonWithLines,
|
||
splitPolygonWithSeparate,
|
||
setPolygonLinesActualSize,
|
||
}
|
||
}
|