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, equalizeSymmetricHips, snapNearAxisEdges, 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' import { logger } from '@/util/logger' // ======================================================================== // [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 } export function reattachDebugLabels(canvas, parentId) { __attachDebugLabels(canvas, parentId) } function __attachDebugLabels(canvas, parentId) { if (!__isDebugLabelsEnabled()) return if (!canvas || !parentId) return // [KERAB-LABEL-REATTACH 2026-05-29] 케라바 토글 등으로 라인이 추가/변경된 후 재호출 가능하도록 // 같은 parentId 의 기존 __debugLabel 먼저 제거 → 카운트 reset 후 새로 부여. canvas .getObjects() .filter((o) => o.name === DEBUG_LABEL_NAME && o.parentId === parentId) .forEach((o) => canvas.remove(o)) 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 = [] // [1956-RACK-TILT 2026-05-13] 빨강 점선 폴리곤군(가대선/모듈설치면) FP drift 보정. // - trestle, dormerTrestle: createRoofRack 출력 // - trestlePolygon: useMode.handleOuterlinesTest 출력 // - moduleSetupSurface: useModuleBasicSetting (eavesMargin/ridgeMargin/kerabaMargin 적용) // 가로/세로 edge 의 FP drift 가 저장된 JSON 에 박혀있는 경우 복원 시 라인이 기울어 보이는 문제. if ( options.name === 'trestle' || options.name === 'dormerTrestle' || options.name === 'trestlePolygon' || options.name === 'moduleSetupSurface' ) { points = snapNearAxisEdges(points, 2) } // [PLAN-CORRUPT-COORD 2026-05-18] 손상 plan 로드 가드 — NaN 좌표가 JSON.stringify 로 null 직렬화되어 // DB 에 박힌 케이스(예: S241X135260518004 의 module/lines 60+ 좌표 null) 에서 // toFixed throw 로 페이지 전체 크래시되는 문제 차단. null/NaN/non-finite 좌표는 0 으로 치환 + // __corrupted 플래그 설정 → useModuleBasicSetting 의 0-dim batch object skip 가드와 연쇄해 cascade 차단. let __corruptedCoord = false if (Array.isArray(points)) { points.forEach((point) => { if (!point) return if (point.x == null || !Number.isFinite(Number(point.x))) { point.x = 0 __corruptedCoord = true } if (point.y == null || !Number.isFinite(Number(point.y))) { point.y = 0 __corruptedCoord = true } }) } if (__corruptedCoord) { this.__corrupted = true logger.warn('[PLAN-CORRUPT-COORD 2026-05-18] corrupted polygon coords substituted with 0', { id: options.id, name: options.name, parentId: options.parentId, }) } // 소수점 전부 제거 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 등에서도 동작하도록) // 단, fabric.Group(도머 등)은 절대 위치 유지해야 하므로 제외 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 if (obj.type === 'group') return false // 도머 등 Group 은 이동 대상에서 제외 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++ } }) // [WALL→ROOF 동기 이동] // 지붕형상 설정 후 wall 과 roof 는 별개의 폴리곤이고, hip/ridge/baseLine 같은 보조선은 // 모두 parentId=roof.id 로 묶여 있어 wall 의 directChildren 에 포함되지 않는다. // wall 만 단독 이동시키면 roof+보조선이 옛 위치에 남아 시각적으로 어긋나므로 // wall 이동 시 연결된 roof 를 찾아 polygonMoved 로 같은 delta 만큼 이동시킨다. // (polygonMoved 가 자체적으로 직속 자식 평행이동까지 처리하므로 별도 순회 불필요) if (this.name === POLYGON_TYPE.WALL) { const linkedRoof = this.canvas.getObjects().find((obj) => obj.name === POLYGON_TYPE.ROOF && obj.wall === this) if (linkedRoof) { linkedRoof._preDragLeft = linkedRoof.left linkedRoof._preDragTop = linkedRoof.top linkedRoof.set({ left: (linkedRoof.left ?? 0) + dxModified, top: (linkedRoof.top ?? 0) + dyModified, }) linkedRoof.fire('polygonMoved') // polygonMoved 가 ROOF 분기에서 _preDragLeft 를 정리하지 않으므로 여기서 정리 linkedRoof._preDragLeft = null linkedRoof._preDragTop = null // roof 의 attributes.roofId 매칭 객체(예: 일부 baseLine, 텍스트류)는 // roof.id 와 다른 식별자로 묶여 polygonMoved 의 parentId === roof.id 필터에 // 잡히지 않을 수 있다. 누락 방지를 위해 추가 평행이동. const extraKids = this.canvas.getObjects().filter((obj) => { if (obj === linkedRoof) return false if (obj.parentId === linkedRoof.id) return false // 이미 polygonMoved 가 처리 return obj.attributes?.roofId === linkedRoof.id }) extraKids.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 } 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?.() }) } } 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 // 이미 재생성됨 if (obj.type === 'group') return false // 도머 등 Group 은 절대 위치 유지 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) } // 대칭 hip line 쌍의 planeSize/actualSize 통일 (±5mm 그룹 평균, 0.5 step) // 부동소수점 round-off 로 좌우 hip 이 1mm 차이 나는 케이스 보정. if (this.innerLines && this.innerLines.length > 0) { const hips = this.innerLines.filter((l) => l && l.name === LINE_TYPE.SUBLINE.HIP) equalizeSymmetricHips(hips, this.canvas) this.canvas?.renderAll() } // [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() // [2026-05-18 라벨 저장값 통일] planeSize(정수 또는 0.5 step) 그대로. const __displayRaw = this.lines[i]?.attributes?.planeSize ?? length const __display = Number(__displayRaw).toFixed(1).replace(/\.0$/, '') 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() // 이동/undo/redo 시 기하학 drift(round-off)로 text 수치가 변동되지 않도록 // 저장된 attributes 우선 사용. 현재 corridorDimension.column에 맞춰 값 결정: // - realDimension(배치면 메뉴): actualSize 우선 // - corridorDimension(지붕덮개 메뉴): planeSize 우선 const preservedActualSize = this.lines[i]?.attributes?.actualSize const preservedPlaneSize = this.lines[i]?.attributes?.planeSize const currentColumn = (typeof window !== 'undefined' && window.__currentCorridorColumn) || 'corridorDimension' const displayValue = currentColumn === 'realDimension' ? preservedActualSize != null ? preservedActualSize : preservedPlaneSize != null ? preservedPlaneSize : length : preservedPlaneSize != null ? preservedPlaneSize : preservedActualSize != null ? preservedActualSize : length // Create a new text object if it doesn't exist const text = new fabric.Text(__display, { 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 } }, })