qcast-front/src/hooks/usePolygon.js

2405 lines
90 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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
}
}
}
// 나눠서 중복 제거된 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,
}
}