1277 lines
43 KiB
JavaScript

import { fabric } from 'fabric'
import { v4 as uuidv4 } from 'uuid'
import { QLine } from '@/components/fabric/QLine'
import { distanceBetweenPoints, findTopTwoIndexesByDistance, getDirectionByPoint, sortedPointLessEightPoint, sortedPoints } from '@/util/canvas-util'
import { calculateAngle, drawGableRoof, drawRoofByAttribute, drawShedRoof, toGeoJSON } from '@/util/qpolygon-utils'
import * as turf from '@turf/turf'
import { LINE_TYPE, POLYGON_TYPE } from '@/common/common'
import Big from 'big.js'
import { drawSkeletonRidgeRoof, drawSkeletonRidgeRoofFromBaseLines, verifyMoveBoundary } from '@/util/skeleton-utils'
// ========================================================================
// [DEBUG] 캔버스 라인 라벨 — 로컬 전용 (NEXT_PUBLIC_RUN_MODE === 'local')
// ----------------------------------------------------------------------
// 별도 util 파일을 두지 않고 QPolygon.js 안에 인라인.
// dev/prd 에선 가드가 즉시 false → 코드 미실행. 외부 의존성 없음.
// 라인 자체의 stroke/fill 은 절대 손대지 않고 fabric.Text 만 추가.
// ========================================================================
const DEBUG_LABEL_NAME = '__debugLabel'
function __isDebugLabelsEnabled() {
return process.env.NEXT_PUBLIC_RUN_MODE === 'local'
}
function __classifyLineForLabel(obj) {
const name = obj.name
const lineName = obj.lineName
const type = obj.attributes?.type
if (name === 'baseLine') return 'B'
if (name === 'outerLine' || name === 'eaves' || lineName === 'roofLine') return 'R'
if (name === LINE_TYPE.SUBLINE.HIP || lineName === LINE_TYPE.SUBLINE.HIP) return 'H'
if (name === LINE_TYPE.SUBLINE.RIDGE || lineName === LINE_TYPE.SUBLINE.RIDGE) return 'RG'
if (name === LINE_TYPE.SUBLINE.VALLEY || lineName === LINE_TYPE.SUBLINE.VALLEY) return 'V'
if (name === LINE_TYPE.SUBLINE.GABLE || lineName === LINE_TYPE.SUBLINE.GABLE) return 'G'
if (name === LINE_TYPE.SUBLINE.VERGE || lineName === LINE_TYPE.SUBLINE.VERGE) return 'VG'
if (
type === LINE_TYPE.WALLLINE.EAVE_HELP_LINE ||
lineName === LINE_TYPE.WALLLINE.EAVE_HELP_LINE ||
name === LINE_TYPE.WALLLINE.EAVE_HELP_LINE
) return 'E'
if (
typeof obj.x1 === 'number' &&
typeof obj.y1 === 'number' &&
typeof obj.x2 === 'number' &&
typeof obj.y2 === 'number'
) return 'SK'
return null
}
function __attachDebugLabels(canvas, parentId) {
if (!__isDebugLabelsEnabled()) return
if (!canvas || !parentId) return
const counters = {}
const objects = canvas.getObjects().filter((o) => o.parentId === parentId && o.name !== DEBUG_LABEL_NAME)
objects.forEach((obj) => {
const prefix = __classifyLineForLabel(obj)
if (!prefix) return
counters[prefix] = (counters[prefix] || 0) + 1
const label = `${prefix}-${counters[prefix]}`
const mx = (obj.x1 + obj.x2) / 2
const my = (obj.y1 + obj.y2) / 2
const text = new fabric.Text(label, {
left: mx,
top: my,
originX: 'center',
originY: 'center',
fontSize: 12,
fill: '#000',
fontFamily: 'monospace',
fontWeight: 'bold',
backgroundColor: 'rgba(255,255,0,0.85)',
selectable: false,
evented: false,
hasControls: false,
hasBorders: false,
name: DEBUG_LABEL_NAME,
parentId: parentId,
excludeFromExport: true,
})
canvas.add(text)
text.bringToFront()
})
canvas.renderAll()
}
export const QPolygon = fabric.util.createClass(fabric.Polygon, {
type: 'QPolygon',
// lines: [],
// texts: [],
id: null,
length: 0,
// hips: [],
// ridges: [],
// connectRidges: [],
// cells: [],
parentId: null,
// innerLines: [],
// children: [],
initOptions: null,
direction: null,
arrow: null,
toFixed: 1,
initialize: function (points, options, canvas) {
this.lines = []
this.texts = []
this.hips = []
this.ridges = []
this.cells = []
this.innerLines = []
this.children = []
this.separatePolygon = []
this.toFixed = options.toFixed ?? 1
this.baseLines = []
this.adjustRoofLines = []
// this.colorLines = []
// 소수점 전부 제거
points.forEach((point) => {
point.x = Number(point.x.toFixed(this.toFixed))
point.y = Number(point.y.toFixed(this.toFixed))
})
options.selectable = options.selectable ?? true
options.sort = options.sort ?? true
options.parentId = options.parentId ?? null
this.isSortedPoints = false
if (!options.sort && points.length <= 8) {
points = sortedPointLessEightPoint(points)
this.isSortedPoints = true
} else {
let isDiagonal = false
points.forEach((point, i) => {
if (isDiagonal) {
return
}
const nextPoint = points[(i + 1) % points.length]
const angle = calculateAngle(point, nextPoint)
if (!(Math.abs(angle) === 0 || Math.abs(angle) === 180 || Math.abs(angle) === 90)) {
isDiagonal = true
}
})
if (!isDiagonal) {
points = sortedPoints(points)
this.isSortedPoints = true
}
}
this.callSuper('initialize', points, options)
if (options.id) {
this.id = options.id
} else {
this.id = uuidv4()
}
if (canvas) {
this.canvas = canvas
}
this.initOptions = options
this.initLines()
this.init()
this.setShape()
const originWidth = this.originWidth ?? this.width
const originHeight = this.originHeight ?? this.height
this.originWidth = this.angle === 90 || this.angle === 270 ? originHeight : originWidth
this.originHeight = this.angle === 90 || this.angle === 270 ? originWidth : originHeight
},
setShape() {
let shape = 0
if (this.lines.length !== 6) {
return
}
//외각선 기준
const topIndex = findTopTwoIndexesByDistance(this.lines).sort((a, b) => a - b) //배열중에 큰 2값을 가져옴 TODO: 나중에는 인자로 받아서 다각으로 수정 해야됨
//일단 배열 6개 짜리 기준의 선 번호
if (topIndex[0] === 4) {
if (topIndex[1] === 5) {
//1번
shape = 1
}
} else if (topIndex[0] === 1) {
//4번
if (topIndex[1] === 2) {
shape = 4
}
} else if (topIndex[0] === 0) {
if (topIndex[1] === 1) {
//2번
shape = 2
} else if (topIndex[1] === 5) {
//3번
shape = 3
}
}
this.shape = shape
},
init: function () {
this.addLengthText()
this.on('moving', () => {
this.initLines()
this.addLengthText()
this.setCoords()
})
this.on('modified', () => {
// 드래그 종료 시점의 delta 계산 (mousedown 에서 저장한 값 사용)
const hasPreDrag = this._preDragLeft != null && this._preDragTop != null
const dxModified = hasPreDrag ? this.left - this._preDragLeft : 0
const dyModified = hasPreDrag ? this.top - this._preDragTop : 0
this.initLines()
this.addLengthText()
this.setCoords()
// 보조선 / 자식 객체들도 같이 이동시킨다 (ROOF 가 아닌 WALL 등에서도 동작하도록)
if ((dxModified !== 0 || dyModified !== 0) && this.canvas) {
const directChildren = this.canvas.getObjects().filter((obj) => {
if (obj === this) return false
if (obj.parentId !== this.id) return false
if (obj.name === 'lengthText') return false
return true
})
const childIds = new Set(directChildren.map((c) => c.id).filter(Boolean))
directChildren.forEach((obj) => {
const next = {}
if (obj.left != null) next.left = obj.left + dxModified
if (obj.top != null) next.top = obj.top + dyModified
if (obj.x1 != null) next.x1 = obj.x1 + dxModified
if (obj.y1 != null) next.y1 = obj.y1 + dyModified
if (obj.x2 != null) next.x2 = obj.x2 + dxModified
if (obj.y2 != null) next.y2 = obj.y2 + dyModified
obj.set(next)
if (obj.startPoint) obj.startPoint = { x: obj.startPoint.x + dxModified, y: obj.startPoint.y + dyModified }
if (obj.endPoint) obj.endPoint = { x: obj.endPoint.x + dxModified, y: obj.endPoint.y + dyModified }
obj.setCoords?.()
})
let movedTextCount = 0
this.canvas.getObjects().forEach((obj) => {
if (childIds.size > 0 && childIds.has(obj.parentId)) {
obj.set({ left: (obj.left ?? 0) + dxModified, top: (obj.top ?? 0) + dyModified })
if (obj.minX != null) obj.minX += dxModified
if (obj.maxX != null) obj.maxX += dxModified
if (obj.minY != null) obj.minY += dyModified
if (obj.maxY != null) obj.maxY += dyModified
obj.setCoords?.()
movedTextCount++
}
})
this.canvas.renderAll()
// ROOF 의 경우 polygonMoved 가 같은 일을 다시 하므로, _preDragLeft 는 거기서 정리됨
if (this.name !== POLYGON_TYPE.ROOF) {
this._preDragLeft = null
this._preDragTop = null
}
}
})
this.on('selected', () => {
Object.keys(this.controls).forEach((controlKey) => {
this.setControlVisible(controlKey, false)
})
this.set({ hasBorders: false })
})
this.on('removed', () => {
// const children = getAllRelatedObjects(this.id, this.canvas)
const children = this.canvas.getObjects().filter((obj) => obj.parentId === this.id)
children.forEach((child) => {
this.canvas.remove(child)
//그룹일때
if (child.hasOwnProperty('_objects')) {
child._objects.forEach((obj) => {
if (obj.hasOwnProperty('texts')) {
obj.texts.forEach((text) => {
this.canvas?.remove(text)
})
}
})
}
})
})
// 드래그 시작 전 위치를 기록해두고 polygonMoved 에서 델타 계산용으로 사용
this.on('mousedown', () => {
this._preDragLeft = this.left
this._preDragTop = this.top
})
//QPolygon 좌표 이동시 좌표 재계산
this.on('polygonMoved', () => {
//폴리곤일때만 사용
// 드래그 전 left/top 이 있으면 delta 계산, 없으면 0
const hasPreDrag = this._preDragLeft != null && this._preDragTop != null
const deltaX = hasPreDrag ? this.left - this._preDragLeft : 0
const deltaY = hasPreDrag ? this.top - this._preDragTop : 0
let matrix = this.calcTransformMatrix()
let transformedPoints = this.get('points')
.map((p) => {
return new fabric.Point(p.x - this.pathOffset.x, p.y - this.pathOffset.y)
})
.map((p) => {
return fabric.util.transformPoint(p, matrix)
})
this.points = transformedPoints
// 바운딩 박스 재계산 (width, height 업데이트 - fill 영역 수정)
const calcDim = this._calcDimensions({})
this.width = calcDim.width
this.height = calcDim.height
const newPathOffset = {
x: calcDim.left + this.width / 2,
y: calcDim.top + this.height / 2,
}
this.set('pathOffset', newPathOffset)
// 변환을 points에 적용했으므로 left, top, angle, scale 모두 리셋 (이중 변환 방지)
this.set({
left: newPathOffset.x,
top: newPathOffset.y,
angle: 0,
scaleX: 1,
scaleY: 1,
})
this.setCoords()
this.initLines()
// 이동 후 기존 lengthText 가 이전 위치에 남아있지 않도록 재생성
this.addLengthText()
// 보조선(helpLine 등) 및 그 부속 객체(lengthText, pitchText 등)를 폴리곤 이동량만큼 같이 이동
if ((deltaX !== 0 || deltaY !== 0) && this.canvas) {
// drawHelpLine 이 추가하는 보조선들은 this.innerLines 배열에 들어가지 않을 수 있으므로
// canvas 에서 parentId === this.id 인 자식 객체들을 직접 찾는다.
// (단, 이 폴리곤의 outer lengthText 는 위에서 addLengthText() 로 이미 재생성했으므로 제외)
const directChildren = this.canvas.getObjects().filter((obj) => {
if (obj === this) return false
if (obj.parentId !== this.id) return false
if (obj.name === 'lengthText') return false // 이미 재생성됨
return true
})
const childIds = new Set(directChildren.map((c) => c.id).filter(Boolean))
const translateLineLike = (obj) => {
const next = {}
if (obj.left != null) next.left = obj.left + deltaX
if (obj.top != null) next.top = obj.top + deltaY
if (obj.x1 != null) next.x1 = obj.x1 + deltaX
if (obj.y1 != null) next.y1 = obj.y1 + deltaY
if (obj.x2 != null) next.x2 = obj.x2 + deltaX
if (obj.y2 != null) next.y2 = obj.y2 + deltaY
obj.set(next)
if (obj.startPoint) {
obj.startPoint = { x: obj.startPoint.x + deltaX, y: obj.startPoint.y + deltaY }
}
if (obj.endPoint) {
obj.endPoint = { x: obj.endPoint.x + deltaX, y: obj.endPoint.y + deltaY }
}
obj.setCoords?.()
}
directChildren.forEach(translateLineLike)
// 보조선의 텍스트(lengthText, pitchText 등)는 보조선 line.id 를 parentId 로 가짐 -> 같이 이동
let movedTextCount = 0
this.canvas.getObjects().forEach((obj) => {
if (childIds.size > 0 && childIds.has(obj.parentId)) {
obj.set({
left: (obj.left ?? 0) + deltaX,
top: (obj.top ?? 0) + deltaY,
})
if (obj.minX != null) obj.minX += deltaX
if (obj.maxX != null) obj.maxX += deltaX
if (obj.minY != null) obj.minY += deltaY
if (obj.maxY != null) obj.maxY += deltaY
obj.setCoords?.()
movedTextCount++
}
})
this.canvas.renderAll()
}
this._preDragLeft = null
this._preDragTop = null
})
// polygon.fillCell({ width: 50, height: 30, padding: 10 })
},
initLines() {
let attributes = null
if (this.lines.length > 0) {
attributes = this.lines.map((line) => line.attributes)
}
this.lines = []
this.getCurrentPoints().forEach((point, i) => {
const nextPoint = this.getCurrentPoints()[(i + 1) % this.points.length]
const line = new QLine([point.x, point.y, nextPoint.x, nextPoint.y], {
stroke: this.stroke,
strokeWidth: this.strokeWidth,
fontSize: this.fontSize,
attributes: attributes
? attributes[i]
: {
offset: 0,
},
textVisible: false,
parent: this,
parentId: this.id,
direction: getDirectionByPoint(point, nextPoint),
idx: i + 1,
})
line.startPoint = point
line.endPoint = nextPoint
this.lines.push(line)
this.calculateDegree()
})
},
calculateDegree() {
const degrees = []
// polygon.lines를 순회하며 각도를 구해 출력
this.lines.forEach((line) => {
const dx = line.x2 - line.x1
const dy = line.y2 - line.y1
const rad = Math.atan2(dy, dx)
const degree = (rad * 180) / Math.PI
degrees.push(degree)
})
function isMultipleOf45(degree, epsilon = 1) {
return Math.abs(degree % 45) <= epsilon || Math.abs((degree % 45) - 45) <= epsilon
}
this.isMultipleOf45 = degrees.every((degree) => isMultipleOf45(degree))
},
/**
* 보조선 그리기
* @param settingModalFirstOptions
* @param forceRedraw - true 면 verifyMoveBoundary 'crossed' 가드를 우회하고 강제로 재빌드.
* (예: 오버 이동 상태에서 wall.baseLines/roof.lines 는 그대로 두고 SK 만 재생성하고 싶을 때)
*/
drawHelpLine(settingModalFirstOptions, forceRedraw = false) {
// [정책] 오버 이동(verifyMoveBoundary='crossed') 처리:
// - roofLine 은 그대로, wall.baseLines 는 이미 오버된 좌표로 mutate 된 상태.
// - 보조선 싹 지우고 '오버된 wall.baseLines 좌표 그대로' SK 재빌드.
// - 일반 경로(drawSkeletonRidgeRoof) 는 45도 대각 확장/roof 경계 클램핑이 들어가
// 오버 polygon 이 왜곡 → 용마루 지붕 케이스는 전용 함수(drawSkeletonRidgeRoofFromBaseLines)로 분기.
// - forceRedraw 인자는 명시적 수동 호출 의도 표현 용도로만 유지.
let __verdict = 'unknown'
try {
__verdict = verifyMoveBoundary(this.id, this.canvas)
if (__verdict === 'crossed') {
console.warn(`[drawHelpLine] 경계 넘음(crossed) → 오버 전용 경로(drawSkeletonRidgeRoofFromBaseLines) 사용 (forceRedraw=${forceRedraw})`)
}
} catch (e) {
console.warn('[drawHelpLine] verifyMoveBoundary 예외 → 기존 흐름 진행:', e)
}
/* innerLines 초기화 */
this.canvas
.getObjects()
.filter(
(obj) =>
obj.parentId === this.id &&
obj.name !== POLYGON_TYPE.WALL &&
obj.name !== POLYGON_TYPE.ROOF &&
obj.name !== 'lengthText' &&
obj.name !== 'outerLine' &&
obj.name !== 'baseLine',
// && obj.name !== 'outerLinePoint',
)
.forEach((obj) => this.canvas.remove(obj))
this.innerLines = []
this.adjustRoofLines = []
this.canvas.renderAll()
let textMode = 'plane'
const dimensionDisplay = settingModalFirstOptions?.dimensionDisplay.find((opt) => opt.selected).id
? settingModalFirstOptions?.dimensionDisplay.find((opt) => opt.selected).id
: 1
switch (dimensionDisplay) {
case 1:
textMode = 'plane'
break
case 2:
textMode = 'actual'
break
case 3:
textMode = 'none'
break
}
const types = this.lines.map((line) => line.attributes.type)
const isGableRoof = function (types) {
if (!types.includes(LINE_TYPE.WALLLINE.GABLE)) {
return false
}
const gableTypes = [LINE_TYPE.WALLLINE.GABLE, LINE_TYPE.WALLLINE.JERKINHEAD]
const oddTypes = types.filter((type, i) => i % 2 === 0)
const evenTypes = types.filter((type, i) => i % 2 === 1)
const oddAllEaves = oddTypes.every((type) => type === LINE_TYPE.WALLLINE.EAVES)
const evenAllGable = evenTypes.every((type) => gableTypes.includes(type))
const evenAllEaves = evenTypes.every((type) => type === LINE_TYPE.WALLLINE.EAVES)
const oddAllGable = oddTypes.every((type) => gableTypes.includes(type))
return (oddAllEaves && evenAllGable) || (evenAllEaves && oddAllGable)
}
const isShedRoof = function (types, lines) {
const gableTypes = [LINE_TYPE.WALLLINE.GABLE, LINE_TYPE.WALLLINE.JERKINHEAD]
if (!types.includes(LINE_TYPE.WALLLINE.SHED)) {
return false
}
const shedLines = lines.filter((line) => line.attributes?.type === LINE_TYPE.WALLLINE.SHED)
const areShedLinesParallel = function (shedLines) {
return shedLines.every((shed, i) => {
const nextShed = shedLines[(i + 1) % shedLines.length]
const angle1 = calculateAngle(shed.startPoint, shed.endPoint)
const angle2 = calculateAngle(nextShed.startPoint, nextShed.endPoint)
return angle1 === angle2
})
}
if (!areShedLinesParallel(shedLines)) {
return false
}
const getParallelEavesLines = function (shedLines, lines) {
const referenceAngle = calculateAngle(shedLines[0].startPoint, shedLines[0].endPoint)
const otherSideLines = lines.filter((line) => {
const lineAngle = calculateAngle(line.startPoint, line.endPoint)
return Math.abs(referenceAngle - lineAngle) === 180
})
const containNotEaves = otherSideLines.filter((line) => line.attributes?.type !== LINE_TYPE.WALLLINE.EAVES)
if (containNotEaves.length === 0) {
return otherSideLines
} else {
return []
}
}
const parallelEaves = getParallelEavesLines(shedLines, lines)
if (parallelEaves.length === 0) {
return false
}
const remainingLines = lines.filter((line) => !shedLines.includes(line) && !parallelEaves.includes(line))
return remainingLines.every((line) => gableTypes.includes(line.attributes.type))
}
if (types.every((type) => type === LINE_TYPE.WALLLINE.EAVES)) {
// 용마루 -- straight-skeleton
// 오버(crossed) 이동 시 wall.baseLines 좌표 그대로 사용하는 전용 경로로 분기.
if (__verdict === 'crossed') {
drawSkeletonRidgeRoofFromBaseLines(this.id, this.canvas, textMode)
} else {
drawSkeletonRidgeRoof(this.id, this.canvas, textMode)
}
} else if (isGableRoof(types)) {
// A형, B형 박공 지붕
// console.log('패턴 지붕')
drawGableRoof(this.id, this.canvas, textMode)
} else if (isShedRoof(types, this.lines)) {
// console.log('한쪽흐름 지붕')
drawShedRoof(this.id, this.canvas, textMode)
} else {
// console.log('변별로 설정')
drawRoofByAttribute(this.id, this.canvas, textMode)
}
// [DEBUG] 로컬(NEXT_PUBLIC_RUN_MODE==='local') 에서만 라벨 부착. 그 외 즉시 return.
__attachDebugLabels(this.canvas, this.id)
},
/**
* 오버 이동(crossed) 상태여도 wall.baseLines / roof.lines / outerLine / lengthText 는 그대로 두고
* 보조선(innerLines: eaveHelpLine, HIP, extensionLine, SK ridge 등) 만 제거 후 SK 재빌드.
*
* drawHelpLine 의 진입부 verifyMoveBoundary 가드만 우회하는 wrapper. 이후 흐름은 동일.
* - 초기화 필터(line 405-411) 가 이미 wall/roof/baseLine/outerLine/lengthText 를 제외하고 있어
* 해당 객체들은 보존됨.
* - 사용자가 오버된 상태에서 "SK 만 다시 그려보기" 의도로 명시적으로 호출할 때 사용.
*/
redrawHelpLineForced(settingModalFirstOptions) {
return this.drawHelpLine(settingModalFirstOptions, true)
},
addLengthText() {
if ([POLYGON_TYPE.MODULE, 'arrow', POLYGON_TYPE.MODULE_SETUP_SURFACE, POLYGON_TYPE.OBJECT_SURFACE].includes(this.name)) {
return
}
if (!this.fontSize) {
return
}
this.canvas
?.getObjects()
.filter((obj) => obj.name === 'lengthText' && obj.parentId === this.id)
.forEach((text) => {
this.canvas.remove(text)
})
let points = this.getCurrentPoints()
this.texts = []
points.forEach((start, i) => {
const end = points[(i + 1) % points.length]
const dx = Big(end.x).minus(Big(start.x))
const dy = Big(end.y).minus(Big(start.y))
const length = dx.pow(2).plus(dy.pow(2)).sqrt().times(10).round().toNumber()
const direction = getDirectionByPoint(start, end)
let left, top
if (direction === 'bottom') {
left = (start.x + end.x) / 2 - 50
top = (start.y + end.y) / 2
} else if (direction === 'top') {
left = (start.x + end.x) / 2 + 30
top = (start.y + end.y) / 2
} else if (direction === 'left') {
left = (start.x + end.x) / 2
top = (start.y + end.y) / 2 - 30
} else if (direction === 'right') {
left = (start.x + end.x) / 2
top = (start.y + end.y) / 2 + 30
}
let midPoint
midPoint = new fabric.Point(left, top)
const degree = Big(Math.atan2(dy.toNumber(), dx.toNumber())).times(180).div(Math.PI).toNumber()
// Create a new text object if it doesn't exist
const text = new fabric.Text(length.toString(), {
left: midPoint.x,
top: midPoint.y,
fontSize: this.fontSize,
parentId: this.id,
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,
actualSize: this.lines[i].attributes?.actualSize,
planeSize: this.lines[i].attributes?.planeSize,
name: 'lengthText',
parent: this,
})
this.texts.push(text)
this.canvas.add(text)
this.canvas.renderAll()
})
},
setFontSize(fontSize) {
this.fontSize = fontSize
this.canvas
?.getObjects()
.filter((obj) => obj.name === 'lengthText' && obj.parent === this)
.forEach((text) => {
text.set({ fontSize: fontSize })
})
},
_render: function (ctx) {
this.callSuper('_render', ctx)
},
_set: function (key, value) {
this.callSuper('_set', key, value)
},
setCanvas(canvas) {
this.canvas = canvas
},
fillCellABType(
cell = {
width: 50,
height: 100,
padding: 5,
wallDirection: 'left',
referenceDirection: 'none',
startIndex: -1,
isCellCenter: false,
},
) {
const points = this.points
const minX = Math.min(...points.map((p) => p.x)) //왼쪽
const maxX = Math.max(...points.map((p) => p.x)) //오른쪽
const minY = Math.min(...points.map((p) => p.y)) //위
const maxY = Math.max(...points.map((p) => p.y)) //아래
const boundingBoxWidth = maxX - minX
const boundingBoxHeight = maxY - minY
const rectWidth = cell.width
const rectHeight = cell.height
const cols = Math.floor((boundingBoxWidth + cell.padding) / (rectWidth + cell.padding))
const rows = Math.floor((boundingBoxHeight + cell.padding) / (rectHeight + cell.padding))
//전체 높이에서 패딩을 포함하고 rows를 곱해서 여백길이를 계산 후에 2로 나누면 반높이를 넣어서 중간으로 정렬
let centerHeight = rows > 1 ? (boundingBoxHeight - (rectHeight + cell.padding / 2) * rows) / 2 : (boundingBoxHeight - rectHeight * rows) / 2 //rows 1개 이상이면 cell 을 반 나눠서 중간을 맞춘다
let centerWidth = cols > 1 ? (boundingBoxWidth - (rectWidth + cell.padding / 2) * cols) / 2 : (boundingBoxWidth - rectWidth * cols) / 2
const drawCellsArray = [] //그려진 셀의 배열
let idx = 1
let startXPos, startYPos
for (let i = 0; i < cols; i++) {
for (let j = 0; j < rows; j++) {
const rectPoints = []
if (cell.referenceDirection !== 'none') {
//4각형은 기준점이 없다
if (cell.referenceDirection === 'top') {
//top, bottom은 A패턴만
if (cell.wallDirection === 'left') {
startXPos = minX + i * rectWidth
startYPos = minY + j * rectHeight
if (i > 0) {
startXPos = startXPos + i * cell.padding //옆으로 패딩
}
} else {
startXPos = maxX - (1 + i) * rectWidth - 0.01
startYPos = minY + j * rectHeight + 0.01
if (i > 0) {
startXPos = startXPos - i * cell.padding //옆으로 패딩
}
}
if (j > 0) {
startYPos = startYPos + j * cell.padding
}
rectPoints.push(
{ x: startXPos, y: startYPos },
{ x: startXPos + rectWidth, y: startYPos },
{ x: startXPos, y: startYPos + rectHeight },
{ x: startXPos + rectWidth, y: startYPos + rectHeight },
)
} else if (cell.referenceDirection === 'bottom') {
if (cell.wallDirection === 'left') {
startXPos = minX + i * rectWidth
startYPos = maxY - j * rectHeight - 0.01
if (i > 0) {
startXPos = startXPos + i * cell.padding
}
rectPoints.push(
{ x: startXPos, y: startYPos },
{ x: startXPos + rectWidth, y: startYPos },
{ x: startXPos, y: startYPos - rectHeight },
{ x: startXPos + rectWidth, y: startYPos - rectHeight },
)
} else {
startXPos = maxX - i * rectWidth - 0.01
startYPos = maxY - j * rectHeight - 0.01
if (i > 0) {
startXPos = startXPos - i * cell.padding
}
rectPoints.push(
{ x: startXPos, y: startYPos },
{ x: startXPos - rectWidth, y: startYPos },
{ x: startXPos, y: startYPos - rectHeight },
{ x: startXPos - rectWidth, y: startYPos - rectHeight },
)
startXPos = startXPos - rectWidth //우 -> 좌 들어가야해서 마이너스 처리
}
startYPos = startYPos - rectHeight //밑에서 위로 올라가는거라 마이너스 처리
if (j > 0) {
startYPos = startYPos - j * cell.padding
}
} else if (cell.referenceDirection === 'left') {
//여기서부턴 B패턴임
if (cell.wallDirection === 'top') {
startXPos = minX + i * rectWidth
startYPos = minY + j * rectHeight
if (i > 0) {
startXPos = startXPos + i * cell.padding //밑으로
}
if (j > 0) {
startYPos = startYPos + j * cell.padding //옆으로 패딩
}
rectPoints.push(
{ x: startXPos, y: startYPos },
{ x: startXPos + rectWidth, y: startYPos },
{ x: startXPos, y: startYPos + rectHeight },
{ x: startXPos + rectWidth, y: startYPos + rectHeight },
)
} else {
startXPos = minX + i * rectWidth
startYPos = maxY - j * rectHeight - 0.01
if (i > 0) {
startXPos = startXPos + i * cell.padding
}
if (j > 0) {
startYPos = startYPos - j * cell.padding
}
rectPoints.push(
{ x: startXPos, y: startYPos },
{ x: startXPos + rectWidth, y: startYPos },
{ x: startXPos, y: startYPos - rectHeight },
{ x: startXPos + rectWidth, y: startYPos - rectHeight },
)
startYPos = startYPos - rectHeight //밑에서 위로 올라가는거라 마이너스 처리
}
} else if (cell.referenceDirection === 'right') {
if (cell.wallDirection === 'top') {
startXPos = maxX - i * rectWidth - 0.01
startYPos = minY + j * rectHeight + 0.01
if (j > 0) {
startYPos = startYPos + j * cell.padding //위에서 밑으로라 +
}
rectPoints.push(
{ x: startXPos, y: startYPos },
{ x: startXPos - rectWidth, y: startYPos },
{ x: startXPos, y: startYPos + rectHeight },
{ x: startXPos - rectWidth, y: startYPos + rectHeight },
)
} else {
startXPos = maxX - i * rectWidth - 0.01
startYPos = maxY - j * rectHeight - 0.01
if (j > 0) {
startYPos = startYPos - j * cell.padding
}
rectPoints.push(
{ x: startXPos, y: startYPos },
{ x: startXPos - rectWidth, y: startYPos },
{ x: startXPos, y: startYPos - rectHeight },
{ x: startXPos - rectWidth, y: startYPos - rectHeight },
)
startYPos = startYPos - rectHeight //밑에서 위로 올라가는거라 마이너스 처리
}
if (i > 0) {
startXPos = startXPos - i * cell.padding //옆으로 패딩
}
startXPos = startXPos - rectWidth // 우측에서 -> 좌측으로 그려짐
}
} else {
// centerWidth = 0 //나중에 중간 정렬 이면 어쩌구 함수 만들어서 넣음
if (['left', 'right'].includes(cell.wallDirection)) {
centerWidth = cell.isCellCenter ? centerWidth : 0
} else if (['top', 'bottom'].includes(cell.wallDirection)) {
centerHeight = cell.isCellCenter ? centerHeight : 0
}
if (cell.wallDirection === 'left') {
startXPos = minX + i * rectWidth + centerWidth
startYPos = minY + j * rectHeight + centerHeight
if (i > 0) {
startXPos = startXPos + i * cell.padding
}
if (j > 0 && j < rows) {
startYPos = startYPos + j * cell.padding
}
} else if (cell.wallDirection === 'right') {
startXPos = maxX - (1 + i) * rectWidth - 0.01 - centerWidth
startYPos = minY + j * rectHeight + 0.01 + centerHeight
if (i > 0) {
startXPos = startXPos - i * cell.padding
}
if (j > 0 && j < rows) {
startYPos = startYPos + j * cell.padding
}
} else if (cell.wallDirection === 'top') {
startXPos = minX + i * rectWidth - 0.01 + centerWidth
startYPos = minY + j * rectHeight + 0.01 + centerHeight
if (i > 0) {
startXPos = startXPos + i * cell.padding
}
if (j > 0 && j < rows) {
startYPos = startYPos + j * cell.padding
}
} else if (cell.wallDirection === 'bottom') {
startXPos = minX + i * rectWidth + 0.01 + centerWidth
startYPos = maxY - (j + 1) * rectHeight - 0.01 - centerHeight
if (i > 0) {
startXPos = startXPos + i * cell.padding
}
if (j > 0 && j < rows) {
startYPos = startYPos - j * cell.padding
}
}
}
const allPointsInside = rectPoints.every((point) => this.inPolygonABType(point.x, point.y, points))
if (allPointsInside) {
//먼저 그룹화를 시켜놓고 뒤에서 글씨를 넣어서 변경한다
const text = new fabric.Text(``, {
fontFamily: 'serif',
fontSize: 30,
fill: 'black',
type: 'cellText',
})
const rect = new fabric.Rect({
// left: startXPos,
// top: startYPos,
width: rectWidth,
height: rectHeight,
fill: '#BFFD9F',
stroke: 'black',
selectable: true, // 선택 가능하게 설정
// lockMovementX: true, // X 축 이동 잠금
// lockMovementY: true, // Y 축 이동 잠금
// lockRotation: true, // 회전 잠금
// lockScalingX: true, // X 축 크기 조정 잠금
// lockScalingY: true, // Y 축 크기 조정 잠금
opacity: 0.8,
name: 'cell',
idx: idx,
type: 'cellRect',
})
const group = new fabric.Group([rect, text], {
left: startXPos,
top: startYPos,
})
idx++
drawCellsArray.push(group) //배열에 넣어서 반환한다
this.canvas.add(group)
this.canvas?.renderAll()
}
}
}
this.cells = drawCellsArray
return drawCellsArray
},
inPolygon(point) {
const vertices = this.points
let intersects = 0
for (let i = 0; i < vertices.length; i++) {
let vertex1 = vertices[i]
let vertex2 = vertices[(i + 1) % vertices.length]
if (vertex1.y > vertex2.y) {
let tmp = vertex1
vertex1 = vertex2
vertex2 = tmp
}
if (point.y === vertex1.y || point.y === vertex2.y) {
point.y += 0.01
}
if (point.y <= vertex1.y || point.y > vertex2.y) {
continue
}
let xInt = ((point.y - vertex1.y) * (vertex2.x - vertex1.x)) / (vertex2.y - vertex1.y) + vertex1.x
if (xInt <= point.x) {
intersects++
}
}
return intersects % 2 === 1
},
inPolygonImproved(point) {
const vertices = this.getCurrentPoints()
let inside = false
const testX = Number(point.x.toFixed(this.toFixed))
const testY = Number(point.y.toFixed(this.toFixed))
for (let i = 0, j = vertices.length - 1; i < vertices.length; j = i++) {
const xi = Number(vertices[i].x.toFixed(this.toFixed))
const yi = Number(vertices[i].y.toFixed(this.toFixed))
const xj = Number(vertices[j].x.toFixed(this.toFixed))
const yj = Number(vertices[j].y.toFixed(this.toFixed))
// 점이 정점 위에 있는지 확인
if (Math.abs(xi - testX) <= 0.01 && Math.abs(yi - testY) <= 0.01) {
return true
}
// 점이 선분 위에 있는지 확인
if (this.isPointOnSegment(point, { x: xi, y: yi }, { x: xj, y: yj })) {
return true
}
// Ray casting 알고리즘 - 부동소수점 정밀도 개선
if (yi > testY !== yj > testY) {
const denominator = yj - yi
if (Math.abs(denominator) > 1e-10) {
// 0으로 나누기 방지
const intersection = ((xj - xi) * (testY - yi)) / denominator + xi
if (testX < intersection) {
inside = !inside
}
}
}
}
return inside
},
isPointOnSegment(point, segStart, segEnd) {
const tolerance = 0.1
const dxSegment = segEnd.x - segStart.x
const dySegment = segEnd.y - segStart.y
const dxPoint = point.x - segStart.x
const dyPoint = point.y - segStart.y
// 벡터의 외적을 계산하여 점이 선분 위에 있는지 확인
const crossProduct = Math.abs(dxPoint * dySegment - dyPoint * dxSegment)
if (crossProduct > tolerance) {
return false
}
// 점이 선분의 범위 내에 있는지 확인
const dotProduct = dxPoint * dxSegment + dyPoint * dySegment
const squaredLength = dxSegment * dxSegment + dySegment * dySegment
return dotProduct >= 0 && dotProduct <= squaredLength
},
setCoords: function () {
// 부모 클래스의 setCoords 호출
this.callSuper('setCoords')
// QPolygon의 경우 추가 처리 - 항상 강제로 재계산
if (this.canvas) {
// 모든 좌표 관련 캐시 초기화
delete this.oCoords
delete this.aCoords
delete this.__corner
// 다시 부모 setCoords 호출
this.callSuper('setCoords')
// 한 번 더 강제로 bounding rect 재계산
this._clearCache && this._clearCache()
}
},
containsPoint: function (point) {
// 먼저 좌표 업데이트
this.setCoords()
// viewport transform만 역변환 (캔버스 줌/팬 보정)
// 결과는 WORLD 좌표 (캔버스 좌표계)
let canvasPoint = point
if (this.canvas) {
const vpt = this.canvas.viewportTransform
if (vpt) {
const inverted = fabric.util.invertTransform(vpt)
canvasPoint = fabric.util.transformPoint(point, inverted)
}
}
// canvasPoint는 WORLD 좌표
// inPolygonImproved에서 getCurrentPoints()도 WORLD 좌표를 반환
// 따라서 좌표 시스템이 일치함
const checkPoint = {
x: Number(canvasPoint.x.toFixed(this.toFixed)),
y: Number(canvasPoint.y.toFixed(this.toFixed)),
}
if (this.name === POLYGON_TYPE.ROOF && this.isFixed) {
const isInside = this.inPolygonImproved(checkPoint)
if (!this.selectable) {
this.set('selectable', isInside)
}
return isInside
} else {
return this.inPolygonImproved(checkPoint)
}
},
inPolygonABType(x, y, polygon) {
let inside = false
let n = polygon.length
for (let i = 0, j = n - 1; i < n; j = i++) {
let xi = polygon[i].x
let yi = polygon[i].y
let xj = polygon[j].x
let yj = polygon[j].y
// console.log('xi : ', xi, 'yi : ', yi, 'xj : ', xj, 'yj : ', yj)
let intersect = yi > y !== yj > y && x < ((xj - xi) * (y - yi)) / (yj - yi) + xi
if (intersect) inside = !inside
}
return inside
},
inPolygon2(rectPoints) {
const polygonCoords = toGeoJSON(this.points)
const rectCoords = toGeoJSON(rectPoints)
const outerPolygon = turf.polygon([polygonCoords])
const innerPolygon = turf.polygon([rectCoords])
// 각 점이 다각형 내부에 있는지 확인
const allPointsInside = rectCoords.every((coord) => {
const point = turf.point(coord)
return turf.booleanPointInPolygon(point, outerPolygon)
})
// 사각형의 변 정의
const rectEdges = [
[rectCoords[0], rectCoords[1]],
[rectCoords[1], rectCoords[2]],
[rectCoords[2], rectCoords[3]],
[rectCoords[3], rectCoords[0]],
]
// 다각형의 변 정의
const outerEdges = turf.lineString(outerPolygon.geometry.coordinates[0])
// 사각형의 변들이 다각형의 변과 교차하는지 확인
const noEdgesIntersect = rectEdges.every((edge) => {
const line = turf.lineString(edge)
const intersects = turf.lineIntersect(line, outerEdges)
return intersects.features.length === 0
})
return allPointsInside && noEdgesIntersect
},
distanceFromEdge(point) {
const vertices = this.getCurrentPoints()
let minDistance = Infinity
for (let i = 0; i < vertices.length; i++) {
let vertex1 = vertices[i]
let vertex2 = vertices[(i + 1) % vertices.length]
const dx = vertex2.x - vertex1.x
const dy = vertex2.y - vertex1.y
const t = ((point.x - vertex1.x) * dx + (point.y - vertex1.y) * dy) / (dx * dx + dy * dy)
let closestPoint
if (t < 0) {
closestPoint = vertex1
} else if (t > 1) {
closestPoint = vertex2
} else {
closestPoint = new fabric.Point(vertex1.x + t * dx, vertex1.y + t * dy)
}
const distance = distanceBetweenPoints(point, closestPoint)
if (distance < minDistance) {
minDistance = distance
}
}
return minDistance
},
getCurrentPoints() {
const pathOffset = this.get('pathOffset')
const matrix = this.calcTransformMatrix()
return this.get('points')
.map(function (p) {
return new fabric.Point(p.x - pathOffset.x, p.y - pathOffset.y)
})
.map(function (p) {
return fabric.util.transformPoint(p, matrix)
})
},
setWall: function (wall) {
this.wall = wall
},
setViewLengthText(isView) {
this.canvas
?.getObjects()
.filter((obj) => obj.name === 'lengthText' && obj.parentId === this.id)
.forEach((text) => {
text.set({ visible: isView })
})
},
setScaleX(scale) {
this.scaleX = scale
this.addLengthText()
},
setScaleY(scale) {
this.scaleY = scale
this.addLengthText()
},
calcOriginCoords() {
const points = this.points
const minX = Math.min(...points.map((p) => p.x))
const maxX = Math.max(...points.map((p) => p.x))
const minY = Math.min(...points.map((p) => p.y))
const maxY = Math.max(...points.map((p) => p.y))
let left = 0
let top = 0
if (this.originX === 'center') {
left = (minX + maxX) / 2
} else if (this.originX === 'left') {
left = minX
} else if (this.originX === 'right') {
left = maxX
}
if (this.originY === 'center') {
top = (minY + maxY) / 2
} else if (this.originY === 'top') {
top = minY
} else if (this.originY === 'bottom') {
top = maxY
}
return { left, top }
},
})