- gableWallLines 필터 + cleanupDangling에서 lineName='roofLine' 제외 — 골짜기 외곽선 삭제 방지 - snapRoofLineCorner: 出幅 이동 코너에 맞춰 인접 세로 roofLine(L-1/L-3) 끝점 갱신 - 변환변 수평 roofLine(L-8) 신규 생성(중복 가드 포함) - R8: 인접 처마 코너에서 > 완성 힙 생성 - QPolygon 라벨 체계 개편(L/H/W/R/HE/B) + classifyLineForLabel 공개화 - isHip에 isDiagonalHip 가드 — 축정렬 hip 오분류 방지 - drawRoofLine에 lineName='roofLine' 부여 — purge 제외·라벨 L 보장 Co-Authored-By: Claude Opus 4 <noreply@anthropic.com>
1669 lines
70 KiB
JavaScript
1669 lines
70 KiB
JavaScript
import { useRecoilState, useRecoilValue, useResetRecoilState } from 'recoil'
|
|
import { canvasState, currentAngleTypeSelector, currentObjectState } from '@/store/canvasAtom'
|
|
import { useContext, useEffect, useRef, useState } from 'react'
|
|
import { useAxios } from '@/hooks/useAxios'
|
|
import { useSwal } from '@/hooks/useSwal'
|
|
import { usePolygon } from '@/hooks/usePolygon'
|
|
import {
|
|
addedRoofsState,
|
|
basicSettingState,
|
|
correntObjectNoState,
|
|
corridorDimensionSelector,
|
|
outlineDisplaySelector,
|
|
roofDisplaySelector,
|
|
roofMaterialsSelector,
|
|
selectedRoofMaterialSelector,
|
|
} from '@/store/settingAtom'
|
|
import { usePopup } from '@/hooks/usePopup'
|
|
import { POLYGON_TYPE } from '@/common/common'
|
|
import { v4 as uuidv4 } from 'uuid'
|
|
import ActualSizeSetting from '@/components/floor-plan/modal/roofAllocation/ActualSizeSetting'
|
|
import { useMessage } from '@/hooks/useMessage'
|
|
import { useCanvasMenu } from '@/hooks/common/useCanvasMenu'
|
|
import { useRoofFn } from '@/hooks/common/useRoofFn'
|
|
import { globalLocaleStore } from '@/store/localeAtom'
|
|
import { getChonByDegree, getDegreeByChon } from '@/util/canvas-util'
|
|
import { moduleSelectionDataState } from '@/store/selectedModuleOptions'
|
|
import { useCanvasPopupStatusController } from '@/hooks/common/useCanvasPopupStatusController'
|
|
import { outerLinePointsState } from '@/store/outerLineAtom'
|
|
import { QcastContext } from '@/app/QcastProvider'
|
|
import { usePlan } from '@/hooks/usePlan'
|
|
import { roofsState } from '@/store/roofAtom'
|
|
import { useText } from '@/hooks/useText'
|
|
import { fabric } from 'fabric'
|
|
import { QLine } from '@/components/fabric/QLine'
|
|
import { useUndoRedo } from '@/hooks/useUndoRedo'
|
|
import { reattachRoofFaceDebugLabels } from '@/components/fabric/QPolygon'
|
|
import { calcLineActualSize2 } from '@/util/qpolygon-utils'
|
|
// [LOW-PITCH-WARN 2026-05-06] 저구배 + 특정 기와 사용 시 시공 매뉴얼 안내 alert
|
|
import { isLowPitchRestricted, LOW_PITCH_RESTRICTED_ROOF_IDS, notifyLowPitchRestrictionForRoofs } from '@/util/roof-pitch-warning'
|
|
// [LOW-PITCH-DIAG 2026-05-06 TEMP] 진단용 — 검증 후 import + 호출 함께 제거
|
|
import { debugCapture } from '@/util/debugCapture'
|
|
import { logger } from '@/util/logger'
|
|
|
|
export function useRoofAllocationSetting(id) {
|
|
const canvas = useRecoilValue(canvasState)
|
|
const [correntObjectNo, setCorrentObjectNo] = useRecoilState(correntObjectNoState)
|
|
const roofDisplay = useRecoilValue(roofDisplaySelector)
|
|
const { drawDirectionArrow, addLengthText, splitPolygonWithLines, splitPolygonWithSeparate } = usePolygon()
|
|
const [popupId, setPopupId] = useState(uuidv4())
|
|
const { addPopup, closePopup, closeAll } = usePopup()
|
|
const currentObject = useRecoilValue(currentObjectState)
|
|
const { setSelectedMenu } = useCanvasMenu()
|
|
const roofMaterials = useRecoilValue(roofMaterialsSelector)
|
|
const selectedRoofMaterial = useRecoilValue(selectedRoofMaterialSelector)
|
|
const [basicSetting, setBasicSetting] = useRecoilState(basicSettingState)
|
|
const [currentRoofMaterial, setCurrentRoofMaterial] = useState(roofMaterials[0])
|
|
/** 팝업 내 기준 지붕재 */
|
|
const [roofList, setRoofList] = useRecoilState(addedRoofsState)
|
|
/** 배치면 초기설정에서 선택한 지붕재 배열 */
|
|
const [editingLines, setEditingLines] = useState([])
|
|
const [currentRoofList, setCurrentRoofList] = useState([])
|
|
const currentAngleType = useRecoilValue(currentAngleTypeSelector)
|
|
const globalLocaleState = useRecoilValue(globalLocaleStore)
|
|
const [basicInfo, setBasicInfo] = useState(null)
|
|
const { get, post } = useAxios(globalLocaleState)
|
|
const { getMessage } = useMessage()
|
|
const { swalFire } = useSwal()
|
|
const { setIsGlobalLoading } = useContext(QcastContext)
|
|
const { setSurfaceShapePattern } = useRoofFn()
|
|
const { saveCanvas } = usePlan()
|
|
const [roofsStore, setRoofsStore] = useRecoilState(roofsState)
|
|
const [moduleSelectionData, setModuleSelectionData] = useRecoilState(moduleSelectionDataState)
|
|
const outerLinePoints = useRecoilValue(outerLinePointsState)
|
|
const resetPoints = useResetRecoilState(outerLinePointsState)
|
|
const [corridorDimension, setCorridorDimension] = useRecoilState(corridorDimensionSelector)
|
|
const { changeCorridorDimensionText } = useText()
|
|
const { saveSnapshot, clearStacks } = useUndoRedo()
|
|
const outlineDisplay = useRecoilValue(outlineDisplaySelector)
|
|
// [UNSELECT-RESTORE] 보조선/능선/골짜기 선택 강조 시 직전 스타일을 저장 → 선택 해제·변경·팝업 닫힘 때 원복
|
|
const highlightedLineRef = useRef(null)
|
|
|
|
useEffect(() => {
|
|
/** 배치면 초기설정에서 선택한 지붕재 배열 설정 */
|
|
setCurrentRoofList(roofList)
|
|
}, [])
|
|
|
|
useEffect(() => {
|
|
/** 지붕면 조회 */
|
|
const roofBases = canvas.getObjects().filter((obj) => obj.name === POLYGON_TYPE.ROOF) /** roofPolygon.innerLines */
|
|
|
|
// [UNSELECT-RESTORE] 이전에 강조했던 라인을 선택 해제·변경 시 원래 스타일로 복구
|
|
const prevHighlighted = highlightedLineRef.current
|
|
if (prevHighlighted && prevHighlighted.obj !== currentObject) {
|
|
prevHighlighted.obj.set({ stroke: prevHighlighted.stroke, strokeWidth: prevHighlighted.strokeWidth })
|
|
highlightedLineRef.current = null
|
|
}
|
|
|
|
roofBases.forEach((roof) => {
|
|
roof.innerLines.forEach((line) => {
|
|
/** 실측값이 없는 경우 라인 두께 4로 설정 */
|
|
if (!line.attributes.actualSize || line.attributes?.actualSize === 0) {
|
|
line.set({ strokeWidth: 4, stroke: 'black', selectable: true })
|
|
}
|
|
|
|
/** 현재 선택된 라인인 경우 라인 두께 2로 설정 */
|
|
if (editingLines.includes(line)) {
|
|
line.set({ strokeWidth: 2, stroke: 'black', selectable: true })
|
|
}
|
|
})
|
|
})
|
|
|
|
/** 현재 선택된 객체가 보조라인, 피라미드, 힙인 경우 두께 4로 설정 */
|
|
if (currentObject && currentObject.name && ['auxiliaryLine', 'ridge', 'hip'].includes(currentObject.name)) {
|
|
// [UNSELECT-RESTORE] 강조 직전(innerLines 리셋 반영 후) 스타일을 1회만 저장해 복구 기준으로 사용
|
|
if (highlightedLineRef.current?.obj !== currentObject) {
|
|
highlightedLineRef.current = { obj: currentObject, stroke: currentObject.stroke, strokeWidth: currentObject.strokeWidth }
|
|
}
|
|
currentObject.set({ strokeWidth: 4, stroke: '#EA10AC' })
|
|
}
|
|
|
|
canvas.renderAll()
|
|
}, [currentObject])
|
|
|
|
useEffect(() => {
|
|
/** 현재 선택된 객체가 보조라인, 피라미드, 힙인 경우 두께 4로 설정 */
|
|
const roofBases = canvas.getObjects().filter((obj) => obj.name === POLYGON_TYPE.ROOF)
|
|
if (roofBases.length === 0) {
|
|
swalFire({ text: getMessage('roofAllocation.not.found'), icon: 'warning' })
|
|
closePopup(id)
|
|
}
|
|
|
|
/** 배치면 초기설정 조회 */
|
|
fetchBasicSettings(basicSetting.planNo)
|
|
}, [])
|
|
|
|
// [UNSELECT-RESTORE] 팝업 닫힘(언마운트) 시 마지막으로 강조한 라인을 원래 스타일로 복구
|
|
useEffect(() => {
|
|
return () => {
|
|
const highlighted = highlightedLineRef.current
|
|
if (highlighted) {
|
|
highlighted.obj.set({ stroke: highlighted.stroke, strokeWidth: highlighted.strokeWidth })
|
|
highlightedLineRef.current = null
|
|
canvas?.renderAll()
|
|
}
|
|
}
|
|
}, [])
|
|
|
|
/**
|
|
* 배치면 초기설정 조회
|
|
*/
|
|
const fetchBasicSettings = async (planNo) => {
|
|
try {
|
|
const response = await get({ url: `/api/canvas-management/canvas-basic-settings/by-object/${correntObjectNo}/${planNo}` })
|
|
|
|
let roofsArray = []
|
|
|
|
// API에서 데이터를 성공적으로 가져온 경우
|
|
if (response && response.length > 0) {
|
|
roofsArray = response.map((item, index) => ({
|
|
planNo: item.planNo,
|
|
roofApply: item.roofApply,
|
|
roofSeq: item.roofSeq || index,
|
|
roofMatlCd: item.roofMatlCd,
|
|
roofWidth: item.roofWidth,
|
|
roofHeight: item.roofHeight,
|
|
roofHajebichi: item.roofHajebichi,
|
|
roofGap: item.roofGap,
|
|
roofLayout: item.roofLayout,
|
|
roofPitch: item.roofPitch,
|
|
roofAngle: item.roofAngle,
|
|
selected: index === 0, // 첫 번째 항목을 기본 선택으로 설정
|
|
index: index,
|
|
}))
|
|
}
|
|
// API에서 데이터가 없고 기존 roofList가 있는 경우
|
|
else if (roofList && roofList.length > 0) {
|
|
roofsArray = roofList.map((roof, index) => ({
|
|
...roof,
|
|
selected: index === 0, // 첫 번째 항목을 기본 선택으로 설정
|
|
}))
|
|
}
|
|
// 둘 다 없는 경우 기본값 설정
|
|
else {
|
|
roofsArray = [
|
|
{
|
|
planNo: planNo,
|
|
roofApply: true,
|
|
roofSeq: 0,
|
|
roofMatlCd: 'ROOF_ID_WA_53A',
|
|
roofWidth: 265,
|
|
roofHeight: 235,
|
|
roofHajebichi: 0,
|
|
roofGap: 'HEI_455',
|
|
roofLayout: 'P',
|
|
roofPitch: 4,
|
|
roofAngle: 21.8,
|
|
},
|
|
]
|
|
}
|
|
|
|
/**
|
|
* 데이터 설정
|
|
*/
|
|
const selectRoofs = []
|
|
for (let i = 0; i < roofsArray.length; i++) {
|
|
roofMaterials?.map((material) => {
|
|
if (material.roofMatlCd === roofsArray[i].roofMatlCd) {
|
|
selectRoofs.push({
|
|
...material,
|
|
selected: roofsArray[i].roofApply,
|
|
index: roofsArray[i].roofSeq,
|
|
id: roofsArray[i].roofMatlCd,
|
|
width: roofsArray[i].roofWidth,
|
|
length: roofsArray[i].roofHeight,
|
|
hajebichi: roofsArray[i].roofHajebichi,
|
|
raft: roofsArray[i].roofGap,
|
|
layout: roofsArray[i].roofLayout,
|
|
pitch: roofsArray[i].roofPitch,
|
|
angle: roofsArray[i].roofAngle,
|
|
})
|
|
}
|
|
})
|
|
}
|
|
|
|
const firstRes = Array.isArray(response) && response.length > 0 ? response[0] : null
|
|
|
|
setBasicSetting({
|
|
...basicSetting,
|
|
planNo: firstRes?.planNo ?? planNo,
|
|
roofSizeSet: firstRes?.roofSizeSet ?? 0,
|
|
roofAngleSet: firstRes?.roofAngleSet ?? 0,
|
|
roofsData: roofsArray,
|
|
selectedRoofMaterial: selectRoofs.find((roof) => roof.selected),
|
|
})
|
|
|
|
setBasicInfo({
|
|
planNo: '' + (firstRes?.planNo ?? planNo),
|
|
roofSizeSet: '' + (firstRes?.roofSizeSet ?? 0),
|
|
roofAngleSet: '' + (firstRes?.roofAngleSet ?? 0),
|
|
})
|
|
// 데이터 동기화: 렌더링용 필드 기본값 보정
|
|
const normalizedRoofs = selectRoofs.map((roof) => ({
|
|
...roof,
|
|
width: roof.width ?? '',
|
|
length: roof.length ?? '',
|
|
hajebichi: roof.hajebichi ?? '',
|
|
pitch: roof.pitch ?? '',
|
|
angle: roof.angle ?? '',
|
|
}))
|
|
setCurrentRoofList(normalizedRoofs)
|
|
} catch (error) {
|
|
logger.error('Data fetching error:', error)
|
|
}
|
|
}
|
|
|
|
/**
|
|
* 지붕면 할당 저장
|
|
*/
|
|
const basicSettingSave = async () => {
|
|
try {
|
|
setIsGlobalLoading(true)
|
|
const patternData = {
|
|
objectNo: correntObjectNo,
|
|
planNo: Number(basicSetting.planNo),
|
|
roofSizeSet: Number(basicSetting.roofSizeSet),
|
|
roofAngleSet: basicSetting.roofAngleSet,
|
|
roofAllocationList: currentRoofList.map((item, index) => ({
|
|
planNo: Number(basicSetting.planNo),
|
|
roofApply: item.selected,
|
|
roofSeq: index,
|
|
roofMatlCd: item.roofMatlCd === null || item.roofMatlCd === undefined ? 'ROOF_ID_WA_53A' : item.roofMatlCd,
|
|
roofWidth: item.width === null || item.width === undefined ? 0 : Number(item.width),
|
|
roofHeight: item.length === null || item.length === undefined ? 0 : Number(item.length),
|
|
roofHajebichi: item.hajebichi === null || item.hajebichi === undefined ? 0 : Number(item.hajebichi),
|
|
roofGap: !item.raft ? item.raftBaseCd : item.raft,
|
|
roofLayout: item.layout === null || item.layout === undefined ? 'P' : item.layout,
|
|
roofPitch: item.pitch === null || item.pitch === undefined ? 4 : Number(item.pitch),
|
|
roofAngle: item.angle === null || item.angle === undefined ? 21.8 : Number(item.angle),
|
|
})),
|
|
}
|
|
|
|
await post({ url: `/api/canvas-management/roof-allocation-settings`, data: patternData }).then((res) => {
|
|
setIsGlobalLoading(false)
|
|
})
|
|
|
|
//Recoil 설정
|
|
//setCanvasSetting({ ...basicSetting })
|
|
|
|
/** 배치면 초기설정 조회 */
|
|
fetchBasicSettings(basicSetting.planNo)
|
|
} catch (error) {
|
|
swalFire({ text: error.message, icon: 'error' })
|
|
}
|
|
}
|
|
|
|
/**
|
|
* 지붕재 추가
|
|
*/
|
|
const onAddRoofMaterial = () => {
|
|
if (currentRoofList.length >= 4) {
|
|
swalFire({ type: 'alert', icon: 'error', text: getMessage('roof.exceed.count') })
|
|
return
|
|
}
|
|
|
|
const originCurrentRoofList = currentRoofList.map((roof) => {
|
|
return { ...roof, selected: false }
|
|
})
|
|
originCurrentRoofList.push({
|
|
...currentRoofMaterial,
|
|
selected: true,
|
|
id: currentRoofMaterial.roofMatlCd,
|
|
name: currentRoofMaterial.roofMatlNm,
|
|
index: currentRoofList.length,
|
|
})
|
|
|
|
setCurrentRoofList(originCurrentRoofList)
|
|
}
|
|
|
|
/**
|
|
* 지붕재 삭제
|
|
*/
|
|
const onDeleteRoofMaterial = (idx) => {
|
|
const roofs = canvas.getObjects().filter((obj) => obj.name === POLYGON_TYPE.ROOF)
|
|
|
|
for (let i = 0; i < roofs.length; i++) {
|
|
if (roofs[i].roofMaterial?.index === idx) {
|
|
swalFire({ type: 'alert', icon: 'error', text: getMessage('roof.material.can.not.delete') })
|
|
return
|
|
}
|
|
}
|
|
|
|
const isSelected = currentRoofList[idx].selected
|
|
const newRoofList = JSON.parse(JSON.stringify(currentRoofList)).filter((_, index) => index !== idx)
|
|
if (isSelected) {
|
|
newRoofList[0].selected = true
|
|
}
|
|
setCurrentRoofList(newRoofList)
|
|
setRoofsStore(newRoofList)
|
|
setModuleSelectionData({ ...moduleSelectionData, roofConstructions: newRoofList })
|
|
}
|
|
|
|
/**
|
|
* 선택한 지붕재로 할당
|
|
*/
|
|
const handleSave = async () => {
|
|
// [LOW-PITCH-DIAG 2026-05-06 TEMP] 진단용 — 검증 후 제거
|
|
debugCapture.log('LOW-PITCH-DIAG handleSave', {
|
|
count: currentRoofList?.length,
|
|
list: currentRoofList?.map((r) => ({
|
|
roofMatlCd: r.roofMatlCd,
|
|
pitch: r.pitch,
|
|
angle: r.angle,
|
|
idMatched: LOW_PITCH_RESTRICTED_ROOF_IDS.has(r.roofMatlCd),
|
|
restricted: isLowPitchRestricted(r),
|
|
})),
|
|
})
|
|
|
|
// [LOW-PITCH-WARN 2026-05-06] 등록된 모든 지붕재에 대해 6종+저구배 검사 후 안내 alert. OK 누르면 저장 진행
|
|
await notifyLowPitchRestrictionForRoofs(currentRoofList, swalFire, getMessage)
|
|
|
|
/**
|
|
* 모두 actualSize 있으면 바로 적용 없으면 actualSize 설정
|
|
*/
|
|
if (checkInnerLines()) {
|
|
addPopup(popupId, 1, <ActualSizeSetting id={popupId} />)
|
|
} else {
|
|
apply()
|
|
|
|
resetPoints()
|
|
|
|
basicSettingSave()
|
|
}
|
|
//기존 지붕 선은 남겨둔다.
|
|
drawOriginRoofLine()
|
|
}
|
|
|
|
const drawOriginRoofLine = () => {
|
|
const wallLines = canvas.getObjects().filter((obj) => obj.name === POLYGON_TYPE.WALL)
|
|
/** 벽면 삭제 */
|
|
wallLines.forEach((wallLine) => {
|
|
wallLine.set({
|
|
stroke: 'black',
|
|
strokeDashArray: [5, 2],
|
|
strokeWidth: 1,
|
|
selectable: false,
|
|
name: 'originRoofOuterLine',
|
|
visible: outlineDisplay,
|
|
})
|
|
wallLine.texts.forEach((text) => {
|
|
canvas.remove(text)
|
|
})
|
|
})
|
|
canvas.renderAll()
|
|
}
|
|
|
|
/**
|
|
* 지붕재 오른쪽 마우스 클릭 후 단일로 지붕재 변경 필요한 경우
|
|
*/
|
|
const handleSaveContext = async () => {
|
|
// [LOW-PITCH-DIAG 2026-05-06 TEMP] 진단용 — 검증 후 제거
|
|
debugCapture.log('LOW-PITCH-DIAG handleSaveContext', {
|
|
count: currentRoofList?.length,
|
|
list: currentRoofList?.map((r) => ({
|
|
roofMatlCd: r.roofMatlCd,
|
|
pitch: r.pitch,
|
|
angle: r.angle,
|
|
idMatched: LOW_PITCH_RESTRICTED_ROOF_IDS.has(r.roofMatlCd),
|
|
restricted: isLowPitchRestricted(r),
|
|
})),
|
|
})
|
|
|
|
// [LOW-PITCH-WARN 2026-05-06] 등록된 모든 지붕재에 대해 6종+저구배 검사 후 안내 alert. OK 누르면 저장 진행
|
|
await notifyLowPitchRestrictionForRoofs(currentRoofList, swalFire, getMessage)
|
|
|
|
const newRoofList = currentRoofList.map((roof, idx) => {
|
|
if (roof.index !== idx) {
|
|
// 기존 저장된 지붕재의 index 수정
|
|
const roofs = canvas.getObjects().filter((obj) => obj.name === POLYGON_TYPE.ROOF && obj.roofMaterial?.index === roof.index)
|
|
roofs.forEach((roof) => {
|
|
setSurfaceShapePattern(roof, roofDisplay.column, false, { ...roof, index: idx }, true)
|
|
})
|
|
}
|
|
|
|
return { ...roof, index: idx, raft: roof.raft ? roof.raft : roof.raftBaseCd }
|
|
})
|
|
|
|
setBasicSetting((prev) => {
|
|
return { ...prev, selectedRoofMaterial: newRoofList.find((roof) => roof.selected) }
|
|
})
|
|
const selectedRoofMaterial = newRoofList.find((roof) => roof.selected)
|
|
const roofs = canvas.getObjects().filter((obj) => obj.name === POLYGON_TYPE.ROOF && obj.roofMaterial?.index === selectedRoofMaterial.index)
|
|
|
|
roofs.forEach((roof) => {
|
|
setSurfaceShapePattern(roof, roofDisplay.column, false, { ...selectedRoofMaterial }, true)
|
|
drawDirectionArrow(roof)
|
|
})
|
|
|
|
setRoofList(newRoofList)
|
|
setRoofMaterials(newRoofList)
|
|
setRoofsStore(newRoofList)
|
|
|
|
setSurfaceShapePattern(currentObject, roofDisplay.column, false, selectedRoofMaterial, true)
|
|
drawDirectionArrow(currentObject)
|
|
modifyModuleSelectionData()
|
|
// closeAll()
|
|
closePopup(id)
|
|
basicSettingSave()
|
|
setModuleSelectionData({ ...moduleSelectionData, roofConstructions: newRoofList })
|
|
|
|
// 지붕재 변경 후 undo/redo stack 초기화
|
|
clearStacks()
|
|
}
|
|
|
|
/**
|
|
* 기존 세팅된 지붕에 지붕재 내용을 바뀐 내용으로 수정
|
|
* @param newRoofMaterials
|
|
*/
|
|
const setRoofMaterials = (newRoofMaterials) => {
|
|
const roofs = canvas.getObjects().filter((obj) => obj.name === POLYGON_TYPE.ROOF)
|
|
newRoofMaterials.forEach((roofMaterial) => {
|
|
const index = roofMaterial.index
|
|
const tempRoofs = roofs.filter((roof) => roof.roofMaterial?.index === index)
|
|
tempRoofs.forEach((roof) => {
|
|
setSurfaceShapePattern(roof, roofDisplay.column, false, roofMaterial)
|
|
})
|
|
})
|
|
}
|
|
|
|
/**
|
|
* 지붕면 할당
|
|
*/
|
|
const handleAlloc = () => {
|
|
const roofBases = canvas.getObjects().filter((obj) => obj.name === POLYGON_TYPE.ROOF) // roofPolygon.innerLines
|
|
roofBases.forEach((roof) => {
|
|
if (roof.separatePolygon.length === 0) {
|
|
roof.innerLines.forEach((line) => {
|
|
if ((!line.attributes.actualSize || line.attributes?.actualSize === 0) && line.length > 1) {
|
|
// ─────────────────────────────────────────────────────────────────
|
|
// [hip actualSize fallback 2026-04-30]
|
|
// 기존: actualSize 미설정/0 시 planeSize 로 fallback → diagonal(hip)
|
|
// 까지 actualSize=planeSize 가 되어 伏せ図/配置面 라벨 토글 무효화.
|
|
// 수정: diagonal 은 pitch 로 actualSize 산출(calcLineActualSize2),
|
|
// horizontal/vertical 만 planeSize fallback 유지.
|
|
// pitch 우선순위: line.attributes.pitch → roof.attributes.pitch
|
|
// → roof.roofMaterial.pitch → 0(편평).
|
|
// ─────────────────────────────────────────────────────────────────
|
|
const dx = Math.abs((line.x2 ?? 0) - (line.x1 ?? 0))
|
|
const dy = Math.abs((line.y2 ?? 0) - (line.y1 ?? 0))
|
|
const isDiagonal = dx > 0.5 && dy > 0.5
|
|
let newActualSize
|
|
if (isDiagonal) {
|
|
const pitch = line.attributes?.pitch ?? roof.attributes?.pitch ?? roof.roofMaterial?.pitch ?? 0
|
|
newActualSize = Number(
|
|
calcLineActualSize2(
|
|
{
|
|
x1: line.x1,
|
|
y1: line.y1,
|
|
x2: line.x2,
|
|
y2: line.y2,
|
|
},
|
|
getDegreeByChon(pitch),
|
|
),
|
|
)
|
|
} else {
|
|
newActualSize = line.attributes.planeSize
|
|
}
|
|
line.set({ attributes: { ...line.attributes, actualSize: newActualSize } })
|
|
// lengthText 에 actualSize 즉시 propagate (없으면 모드 토글이 if(obj.actualSize)
|
|
// 가드에 막혀 토글 무효화).
|
|
const lengthText = canvas.getObjects().find((o) => o.name === 'lengthText' && o.parentId === line.id)
|
|
if (lengthText) {
|
|
lengthText.set({ actualSize: newActualSize })
|
|
}
|
|
}
|
|
})
|
|
}
|
|
})
|
|
|
|
apply()
|
|
}
|
|
|
|
/**
|
|
* 실측값 없는 경우 체크
|
|
*/
|
|
const checkInnerLines = () => {
|
|
const roofBases = canvas.getObjects().filter((obj) => obj.name === POLYGON_TYPE.ROOF) // roofPolygon.innerLines
|
|
let result = false
|
|
|
|
roofBases.forEach((roof) => {
|
|
if (roof.separatePolygon.length === 0) {
|
|
roof.innerLines.forEach((line) => {
|
|
if ((!line.attributes.actualSize || line.attributes?.actualSize === 0) && line.length > 1) {
|
|
line.set({ strokeWidth: 4, stroke: 'black', selectable: true })
|
|
result = true
|
|
}
|
|
})
|
|
}
|
|
})
|
|
if (result) canvas?.renderAll()
|
|
|
|
return result
|
|
}
|
|
|
|
/**
|
|
* extensionLine + 동일 직선상 SK(HIP/RIDGE/VALLEY) 1:1 통합.
|
|
* 빨강 보조선(extensionLine)과 baseLine 코너에서 같은 방향으로 뻗어나가는 SK 라인을
|
|
* 한 QLine 으로 합쳐서 roofBase.innerLines 에 둔다.
|
|
* - 대각선 길이가 단일값으로 산출됨
|
|
* - 각도 기반 면적 계산이 한 라인 단위로 가능
|
|
* - splitPolygonWithLines 의 graph 토폴로지가 정상 연결됨
|
|
* 호출 위치: apply() 내 roofBase.lines 병합 직후, split 직전.
|
|
*/
|
|
const integrateExtensionLines = (roofBase) => {
|
|
if (!roofBase?.lines || !Array.isArray(roofBase.innerLines)) return
|
|
|
|
const extLines = roofBase.lines.filter((l) => l.lineName === 'extensionLine')
|
|
if (extLines.length === 0) {
|
|
logger.log(`[INTEGRATE] roofBase.id=${roofBase.id} extensionLine 없음 → skip`)
|
|
return
|
|
}
|
|
|
|
const TOL = 1.0
|
|
const isSamePt = (p1, p2) => Math.hypot(p1.x - p2.x, p1.y - p2.y) < TOL
|
|
const isCollinear = (l1, l2) => {
|
|
const v1x = l1.x2 - l1.x1,
|
|
v1y = l1.y2 - l1.y1
|
|
const v2x = l2.x2 - l2.x1,
|
|
v2y = l2.y2 - l2.y1
|
|
const m1 = Math.hypot(v1x, v1y) || 1
|
|
const m2 = Math.hypot(v2x, v2y) || 1
|
|
const cross = (v1x * v2y - v1y * v2x) / (m1 * m2)
|
|
return Math.abs(cross) < 0.02 // ~1.1°
|
|
}
|
|
|
|
const removedExt = []
|
|
const removedSk = []
|
|
const merged = []
|
|
// [2026-04-30] sk 가 SK 빌드 시점에 이미 ext 끝점까지 연장된 경우(sk.__extended).
|
|
// merge 하면 mergedLine 이 sk 의 옛(축소된) 좌표로 되돌아가므로 스킵.
|
|
// ext 는 roofBase.lines 에서만 제거하고 canvas 에는 invisible 데이터로 그대로 둠.
|
|
const extLinesOnly = []
|
|
|
|
extLines.forEach((ext) => {
|
|
const extP1 = { x: ext.x1, y: ext.y1 }
|
|
const extP2 = { x: ext.x2, y: ext.y2 }
|
|
|
|
const sk = roofBase.innerLines.find((sl) => {
|
|
if (removedSk.includes(sl)) return false
|
|
// [auxiliaryLine 제외 2026-04-30] integrateExtensionLines 는 SK auto-build
|
|
// hip stub + ext 의 1:1 통합용. 사용자가 그린 auxiliaryLine 은 이미 완전한
|
|
// 분할선이므로 ext 와 병합하면 좌표가 어긋남(코너 끝점 손실).
|
|
if (sl.name === 'auxiliaryLine') return false
|
|
const skP1 = { x: sl.x1, y: sl.y1 }
|
|
const skP2 = { x: sl.x2, y: sl.y2 }
|
|
const sharesP1 = isSamePt(extP1, skP1) || isSamePt(extP1, skP2)
|
|
const sharesP2 = isSamePt(extP2, skP1) || isSamePt(extP2, skP2)
|
|
if ((sharesP1 ? 1 : 0) + (sharesP2 ? 1 : 0) !== 1) return false
|
|
return isCollinear(ext, sl)
|
|
})
|
|
|
|
if (!sk) {
|
|
logger.log(`[INTEGRATE] ext 짝없음 ` + `(${extP1.x.toFixed(1)},${extP1.y.toFixed(1)})→(${extP2.x.toFixed(1)},${extP2.y.toFixed(1)})`)
|
|
return
|
|
}
|
|
|
|
// [2026-04-30] sk 가 이미 SK 빌드 단계에서 연장된 경우 → merge 스킵.
|
|
|
|
// [save/load 보존 2026-05-13] runtime __extended 는 저장 시 사라지므로 attributes.extended 도 함께 검사.
|
|
if (sk.__extended || sk.attributes?.extended) {
|
|
logger.log(`[INTEGRATE] sk 이미 연장됨(id=${sk.id}) → merge 스킵, ext 는 lines 에서만 제거(canvas 유지)`)
|
|
extLinesOnly.push(ext)
|
|
return
|
|
}
|
|
|
|
const skP1 = { x: sk.x1, y: sk.y1 }
|
|
const skP2 = { x: sk.x2, y: sk.y2 }
|
|
const sharedPt = isSamePt(extP1, skP1) || isSamePt(extP1, skP2) ? extP1 : extP2
|
|
const extOuter = isSamePt(extP1, sharedPt) ? extP2 : extP1
|
|
const skOuter = isSamePt(skP1, sharedPt) ? skP2 : skP1
|
|
|
|
const extLen = Math.hypot(extP2.x - extP1.x, extP2.y - extP1.y)
|
|
const skLen = Math.hypot(skP2.x - skP1.x, skP2.y - skP1.y)
|
|
const totalLen = extLen + skLen
|
|
|
|
// SK 의 planeSize/actualSize 를 비율로 확장 (SK 길이가 0 이면 그대로)
|
|
// [정밀도 0.01 유지] 저장값 소수점 2자리 (면적 계산 정확도). 표시는 addLengthText 에서 round.
|
|
const skPlane = sk.attributes?.planeSize ?? 0
|
|
const skActual = sk.attributes?.actualSize ?? 0
|
|
const ratio = skLen > 0 ? totalLen / skLen : 1
|
|
const newPlane = Math.round(skPlane * ratio * 100) / 100
|
|
const newActual = Math.round(skActual * ratio * 100) / 100
|
|
|
|
const mergedLine = new QLine([extOuter.x, extOuter.y, skOuter.x, skOuter.y], {
|
|
parentId: sk.parentId,
|
|
parent: sk.parent,
|
|
stroke: sk.stroke,
|
|
strokeWidth: sk.strokeWidth,
|
|
fontSize: sk.fontSize,
|
|
visible: sk.visible,
|
|
selectable: sk.selectable,
|
|
name: sk.name,
|
|
lineName: sk.lineName,
|
|
direction: sk.direction,
|
|
roofId: sk.roofId,
|
|
attributes: {
|
|
...(sk.attributes || {}),
|
|
planeSize: newPlane,
|
|
actualSize: newActual,
|
|
},
|
|
})
|
|
mergedLine.length = totalLen
|
|
|
|
logger.log(
|
|
`[INTEGRATE] merge ` +
|
|
`ext=(${extP1.x.toFixed(1)},${extP1.y.toFixed(1)})→(${extP2.x.toFixed(1)},${extP2.y.toFixed(1)}) len=${extLen.toFixed(1)} ` +
|
|
`sk[${sk.lineName || sk.name}]=(${skP1.x.toFixed(1)},${skP1.y.toFixed(1)})→(${skP2.x.toFixed(1)},${skP2.y.toFixed(1)}) len=${skLen.toFixed(1)} ` +
|
|
`→ total=${totalLen.toFixed(1)} (plane ${skPlane}→${newPlane}, actual ${skActual}→${newActual})`,
|
|
)
|
|
|
|
removedExt.push(ext)
|
|
removedSk.push(sk)
|
|
merged.push(mergedLine)
|
|
})
|
|
|
|
if (merged.length === 0 && extLinesOnly.length === 0) {
|
|
logger.log(`[INTEGRATE] 통합 대상 없음 ext=${extLines.length}`)
|
|
return
|
|
}
|
|
|
|
// roofBase.lines 에서: 기존 merge 로 제거할 ext + __extended 케이스의 ext 둘 다 제외
|
|
// → split 단계에서 ext 가 외곽 처마처럼 처리되는 것 방지.
|
|
roofBase.lines = roofBase.lines.filter((l) => !removedExt.includes(l) && !extLinesOnly.includes(l))
|
|
roofBase.innerLines = roofBase.innerLines.filter((l) => !removedSk.includes(l))
|
|
roofBase.innerLines = [...roofBase.innerLines, ...merged]
|
|
|
|
// canvas 정리:
|
|
// - 기존 merge 케이스: 원본 ext + 원본 sk 둘 다 canvas 제거 (mergedLine 이 대체)
|
|
// - __extended 케이스: ext 는 invisible 데이터로 canvas 에 그대로 유지 (디버그용), sk 는 이미 연장됨
|
|
removedExt.forEach((l) => canvas.remove(l))
|
|
removedSk.forEach((l) => canvas.remove(l))
|
|
|
|
logger.log(
|
|
`[INTEGRATE] 완료 roofBase.id=${roofBase.id} ` +
|
|
`ext제거=${removedExt.length} sk제거=${removedSk.length} merged=${merged.length} ` +
|
|
`extKeptInCanvas=${extLinesOnly.length} ` +
|
|
`→ lines=${roofBase.lines.length} innerLines=${roofBase.innerLines.length}`,
|
|
)
|
|
}
|
|
|
|
/**
|
|
* [KERAB-VALLEY-OVERLAP-MERGE 2026-05-28] 골짜기 케라바 출폭 띠 — 직사각형 sub-roof 를 인접 sub-roof 들에 union 머지.
|
|
*
|
|
* 배경: 케라바 토글 시 두 지붕면이 出幅만큼 서로 물려 겹친다는 표시. useEavesGableEdit.js wallExt 단계에서
|
|
* 평행 사각형 4 라인(kerabValleyOverlapLine) 으로 출폭 띠를 닫고 split 단계로 진입.
|
|
* split 후 이 사각형은 별도 sub-roof X 가 됨. 의도는 X 영역이 인접 두 sub-roof 모두에 속해 겹치는 것.
|
|
* 따라서 X 의 공유 변을 가진 인접 sub-roof 들에 X 의 나머지 변(detour)을 끼워넣어 X 영역을 흡수.
|
|
* 흡수 완료 후 X 자체는 캔버스에서 제거.
|
|
*
|
|
* 식별:
|
|
* - 직사각형 X: sub.lines 의 attributes.type === 'kerabValleyOverlapLine' 가 N-1 개 이상 (4변 중 3~4)
|
|
* - 인접 sub-roof: X 의 변과 같은 두 끝점을 공유하는 다른 sub-roof
|
|
*
|
|
* 머지: 사각형 X 의 공유 변 1개를 detour 변 N-1개로 대체. 방향은 면적 증가로 검증.
|
|
*/
|
|
const mergeValleyOverlapSubRoofs = () => {
|
|
const newSubRoofs = canvas.getObjects().filter((o) => o.name === POLYGON_TYPE.ROOF && !o.isFixed)
|
|
if (newSubRoofs.length === 0) return
|
|
|
|
const eq = (p, q) => Math.abs(p.x - q.x) < 0.5 && Math.abs(p.y - q.y) < 0.5
|
|
|
|
// [KERAB-VALLEY-OVERLAP-BAND-ID 2026-06-04] 밴드 sub-roof 식별.
|
|
// split 후 sub-roof 의 V1(vExt) edge 는 lineName 을 잃어 'L' 로 나오므로(BAND-PROBE 확인)
|
|
// vExt 기반 식별은 불가. 대신 밴드만 overlap-typed(kerabValleyOverlapLine) 변을 보유한다는
|
|
// 사실(인접 실제 면은 split 후 ov:0)을 이용 — overlap 변 2개 이상이면 밴드로 인정.
|
|
// 밴드는 항상 V2/V3/V4 3변이 overlap → ov>=3 이지만, 안전 여유로 2 로 둔다.
|
|
const rects = newSubRoofs.filter((sub) => {
|
|
if (!sub.lines || sub.lines.length < 3) return false
|
|
const ovCnt = sub.lines.filter((l) => l?.attributes?.type === 'kerabValleyOverlapLine').length
|
|
return ovCnt >= 2
|
|
})
|
|
|
|
if (rects.length === 0) return
|
|
|
|
logger.log(`[KERAB-VALLEY-OVERLAP-MERGE] 직사각형 sub-roof 후보=${rects.length}`)
|
|
|
|
const signedArea = (pts) => {
|
|
let s = 0
|
|
for (let i = 0; i < pts.length; i++) {
|
|
const a = pts[i]
|
|
const b = pts[(i + 1) % pts.length]
|
|
s += a.x * b.y - b.x * a.y
|
|
}
|
|
return s / 2
|
|
}
|
|
|
|
rects.forEach((X) => {
|
|
const Xpts = (X.points || []).map((p) => ({ x: p.x, y: p.y }))
|
|
const Xlines = X.lines || []
|
|
if (Xpts.length < 3 || Xlines.length < Xpts.length) {
|
|
logger.log(`[KERAB-VALLEY-OVERLAP-MERGE] X.id=${X.id} skip — pts=${Xpts.length} lines=${Xlines.length}`)
|
|
return
|
|
}
|
|
|
|
const Xedges = []
|
|
for (let i = 0; i < Xpts.length; i++) {
|
|
Xedges.push({ a: Xpts[i], b: Xpts[(i + 1) % Xpts.length], idx: i })
|
|
}
|
|
|
|
// 인접 sub-roof — X 와 공유 변 모두 수집 (다중 공유 검출용)
|
|
const adjacencyMap = new Map() // sub → [{ subEdgeStart, xEdgeIdx }, ...]
|
|
newSubRoofs.forEach((sub) => {
|
|
if (sub === X) return
|
|
const pts = sub.points || []
|
|
if (pts.length < 3) return
|
|
const shares = []
|
|
for (let i = 0; i < pts.length; i++) {
|
|
const p1 = pts[i]
|
|
const p2 = pts[(i + 1) % pts.length]
|
|
for (const e of Xedges) {
|
|
if ((eq(p1, e.a) && eq(p2, e.b)) || (eq(p1, e.b) && eq(p2, e.a))) {
|
|
shares.push({ subEdgeStart: i, xEdgeIdx: e.idx })
|
|
break
|
|
}
|
|
}
|
|
}
|
|
if (shares.length > 0) adjacencyMap.set(sub, shares)
|
|
})
|
|
|
|
logger.log(
|
|
`[KERAB-VALLEY-OVERLAP-MERGE] X.id=${X.id} pts=${Xpts.length} adjacents=${adjacencyMap.size} ` +
|
|
`shares=[${[...adjacencyMap.values()].map((s) => s.length).join(',')}]`,
|
|
)
|
|
|
|
// [KERAB-VALLEY-OVERLAP-FOLD-DIAG 2026-06-04] fold 직전 — 띠(X) + 인접 면 전체 좌표 덤프 (대각선 원인 추적)
|
|
{
|
|
const rnd = (p) => ({ x: Math.round(p.x), y: Math.round(p.y) })
|
|
const adjDump = []
|
|
adjacencyMap.forEach((shares, sub) => {
|
|
adjDump.push({
|
|
subId: String(sub.id).slice(0, 8),
|
|
shares: shares.length,
|
|
shareEdges: shares.map((s) => ({ subEdgeStart: s.subEdgeStart, xEdgeIdx: s.xEdgeIdx })),
|
|
pts: (sub.points || []).map(rnd),
|
|
})
|
|
})
|
|
debugCapture.log('KERAB-VALLEY-OVERLAP-FOLD-DIAG', {
|
|
XId: String(X.id).slice(0, 8),
|
|
Xpts: Xpts.map(rnd),
|
|
XlineTypes: Xlines.map((l) => (l?.attributes?.type === 'kerabValleyOverlapLine' ? 'OV' : l?.lineName || 'L')),
|
|
adjacents: adjDump,
|
|
})
|
|
}
|
|
|
|
if (adjacencyMap.size === 0) {
|
|
canvas.remove(X)
|
|
return
|
|
}
|
|
|
|
// [KERAB-VALLEY-OVERLAP-FOLD 2026-06-04] 밴드를 "변 연장" 으로 한 면에 흡수 (대각선 제거).
|
|
// 할당 = 이어진 라인을 따라 면을 만드는 것. 밴드의 V1(내부 변)을 전부 가진 인접 면이
|
|
// V1 을 밴드 OV 외곽경로(V3→V2→V4, x 를 출폭만큼 바깥)로 연장해 밴드를 흡수한다.
|
|
// 부분만 닿은 면(이전 detour 우회 대상)은 흡수 안 함 — 그 우회가 대각선의 근본원인이었다.
|
|
const N = Xpts.length
|
|
const isOVedge = (i) => Xlines[i]?.attributes?.type === 'kerabValleyOverlapLine'
|
|
|
|
// 밴드의 V1(비 OV) 변 run — 밴드는 V1 이 한 덩어리이므로 연속 가정, 아니면 보존(흡수 skip).
|
|
let lStart = -1
|
|
let lLen = 0
|
|
for (let i = 0; i < N; i++) {
|
|
if (!isOVedge(i)) {
|
|
if (lStart === -1) lStart = i
|
|
lLen++
|
|
}
|
|
}
|
|
let lContig = lStart !== -1 && lLen < N
|
|
for (let k = 0; k < lLen && lContig; k++) {
|
|
if (isOVedge((lStart + k) % N)) lContig = false
|
|
}
|
|
if (!lContig) {
|
|
logger.log(`[KERAB-VALLEY-OVERLAP-FOLD] X.id=${X.id} skip — V1 run 비연속 (lStart=${lStart} lLen=${lLen})`)
|
|
return
|
|
}
|
|
const vStartVtx = Xpts[lStart]
|
|
const vFinVtx = Xpts[(lStart + lLen) % N]
|
|
// OV 외곽 중간점들 — vFin 에서 vStart 로 도는 순서
|
|
const ovFromFin = []
|
|
for (let k = 1; k <= N - (lLen + 1); k++) {
|
|
ovFromFin.push(Xpts[(lStart + lLen + k) % N])
|
|
}
|
|
|
|
// 흡수 면 = V1 을 가장 많이(전부) 공유하는 인접 면
|
|
let absorber = null
|
|
let absorberShares = null
|
|
adjacencyMap.forEach((shares, sub) => {
|
|
// 다중 공유 → bowtie 위험, 머지 skip
|
|
if (shares.length !== 1) {
|
|
logger.log(`[KERAB-VALLEY-OVERLAP-MERGE] sub.id=${sub.id} skip — sharedEdges=${shares.length} (≠1, bowtie 위험)`)
|
|
return
|
|
}
|
|
const { subEdgeStart, xEdgeIdx } = shares[0]
|
|
const pts = (sub.points || []).map((p) => ({ x: p.x, y: p.y }))
|
|
const oldLines = [...(sub.lines || [])]
|
|
const N = Xpts.length
|
|
|
|
// [KERAB-VALLEY-OVERLAP-MERGE 2026-05-28] detour 방향은 sub.pts[subEdgeStart] 가
|
|
// X.xEdge.a (=Xpts[xEdgeIdx]) 인지 X.xEdge.b (=Xpts[xEdgeIdx+1]) 인지로 결정.
|
|
// sub 외곽선 진행 방향이 xEdge 의 a→b 와 같으면 detour 는 X 의 a 쪽 이웃부터 (역방향)
|
|
// 반대면 detour 는 X 의 b 쪽 이웃부터 (정방향).
|
|
const subA = pts[subEdgeStart]
|
|
const xA = Xpts[xEdgeIdx]
|
|
const sameDir = eq(subA, xA)
|
|
|
|
const detour = []
|
|
const detourEdgeIdx = []
|
|
if (sameDir) {
|
|
// a → Xpts[i-1] → Xpts[i-2] → ... → Xpts[i+2] → b
|
|
// detour vertices: N-2 개 (a, b 사이)
|
|
for (let k = 1; k <= N - 2; k++) {
|
|
detour.push(Xpts[(xEdgeIdx - k + N) % N])
|
|
}
|
|
// detour edges: N-1 개. a→Xpts[i-1] 는 X 의 (i-1)%N 변 (반대방향).
|
|
for (let k = 1; k <= N - 1; k++) {
|
|
detourEdgeIdx.push((xEdgeIdx - k + N) % N)
|
|
}
|
|
} else {
|
|
// b → Xpts[i+2] → Xpts[i+3] → ... → Xpts[i+N-1] → a 의 a→b 표기:
|
|
// a → Xpts[i+2] → Xpts[i+3] → ... → Xpts[i+N-1] → b (subA=b 인 관점에서 보면 반대)
|
|
// 정확히는 subA=X.xEdge.b → detour 시작 = Xpts[(i+2)%N] (b 의 X 내 이웃)
|
|
for (let k = 2; k <= N - 1; k++) {
|
|
detour.push(Xpts[(xEdgeIdx + k) % N])
|
|
}
|
|
for (let k = 1; k <= N - 1; k++) {
|
|
detourEdgeIdx.push((xEdgeIdx + k) % N)
|
|
}
|
|
}
|
|
|
|
const buildCand = (verts) => {
|
|
const c = [...pts]
|
|
c.splice(subEdgeStart + 1, 0, ...verts)
|
|
return c
|
|
}
|
|
|
|
const candA = buildCand(detour)
|
|
const oldSigned = signedArea(pts)
|
|
const aSigned = signedArea(candA)
|
|
const sameSign = (s) => oldSigned === 0 || Math.sign(s) === Math.sign(oldSigned)
|
|
const okA = sameSign(aSigned) && Math.abs(aSigned) > Math.abs(oldSigned) + 0.5
|
|
|
|
let finalPts = null
|
|
let finalEdges = null
|
|
if (okA) {
|
|
finalPts = candA
|
|
finalEdges = detourEdgeIdx
|
|
} else {
|
|
// fallback — 반대 방향 시도 (X 가 CW 로 들어온 경우 대비)
|
|
const detourRev = [...detour].reverse()
|
|
const detourEdgeRev = [...detourEdgeIdx].reverse()
|
|
const candB = buildCand(detourRev)
|
|
const bSigned = signedArea(candB)
|
|
const okB = sameSign(bSigned) && Math.abs(bSigned) > Math.abs(oldSigned) + 0.5
|
|
if (okB) {
|
|
finalPts = candB
|
|
finalEdges = detourEdgeRev
|
|
logger.log(
|
|
`[KERAB-VALLEY-OVERLAP-MERGE] sub.id=${sub.id} fallback reverse detour 사용 ` +
|
|
`oldSigned=${oldSigned.toFixed(0)} → ${bSigned.toFixed(0)}`,
|
|
)
|
|
} else {
|
|
logger.log(
|
|
`[KERAB-VALLEY-OVERLAP-MERGE] sub.id=${sub.id} skip — detour 양방향 실패 ` +
|
|
`sameDir=${sameDir} oldSigned=${oldSigned.toFixed(0)} ` +
|
|
`aSigned=${aSigned.toFixed(0)} bSigned=${bSigned.toFixed(0)} ` +
|
|
`subA=(${subA.x.toFixed(1)},${subA.y.toFixed(1)}) ` +
|
|
`xA=(${xA.x.toFixed(1)},${xA.y.toFixed(1)}) ` +
|
|
`xB=(${Xpts[(xEdgeIdx + 1) % N].x.toFixed(1)},${Xpts[(xEdgeIdx + 1) % N].y.toFixed(1)})`,
|
|
)
|
|
return
|
|
}
|
|
}
|
|
|
|
// sub.lines 재구성 — 옛 공유 변 1개 → detour 변 N-1개
|
|
const newSubLines = []
|
|
for (let i = 0; i < oldLines.length; i++) {
|
|
if (i === subEdgeStart) {
|
|
for (let k = 0; k < finalEdges.length; k++) {
|
|
const xLine = Xlines[finalEdges[k]]
|
|
const p1 = finalPts[(subEdgeStart + k) % finalPts.length]
|
|
const p2 = finalPts[(subEdgeStart + k + 1) % finalPts.length]
|
|
const attrs = xLine?.attributes ? { ...xLine.attributes } : { type: 'kerabValleyOverlapLine', offset: 0 }
|
|
const ln = new QLine([p1.x, p1.y, p2.x, p2.y], {
|
|
stroke: sub.stroke,
|
|
strokeWidth: sub.strokeWidth,
|
|
fontSize: sub.fontSize,
|
|
attributes: attrs,
|
|
textVisible: false,
|
|
parent: sub,
|
|
parentId: sub.id,
|
|
idx: newSubLines.length + 1,
|
|
})
|
|
ln.startPoint = p1
|
|
ln.endPoint = p2
|
|
newSubLines.push(ln)
|
|
}
|
|
} else {
|
|
const ln = oldLines[i]
|
|
if (ln) {
|
|
ln.idx = newSubLines.length + 1
|
|
newSubLines.push(ln)
|
|
}
|
|
}
|
|
}
|
|
|
|
// [KERAB-VALLEY-OVERLAP-MERGE 2026-05-28] fabric.Polygon 정식 points 갱신 패턴.
|
|
// src/util/canvas-util.js#anchorWrapper 패턴 그대로 적용 —
|
|
// _setPositionDimensions 가 width/height/pathOffset/left/top 을 재계산하므로
|
|
// 변경 전 앵커(pts[0]) 절대좌표를 캡쳐 → 변경 후 setPositionByOrigin 으로 복원.
|
|
// 그래야 polygon 의 path 가 새 bbox 기준으로 다시 그려지면서 시각 위치도 유지됨.
|
|
const anchorIdx = 0
|
|
const oldLocal = {
|
|
x: sub.points[anchorIdx].x - sub.pathOffset.x,
|
|
y: sub.points[anchorIdx].y - sub.pathOffset.y,
|
|
}
|
|
const absolutePoint = fabric.util.transformPoint(oldLocal, sub.calcTransformMatrix())
|
|
|
|
sub.points = finalPts
|
|
sub.lines = newSubLines
|
|
sub._setPositionDimensions({})
|
|
|
|
const strokeW = sub.strokeUniform ? sub.strokeWidth / sub.scaleX : sub.strokeWidth
|
|
const baseW = sub.width + strokeW
|
|
const baseH = sub.height + (sub.strokeUniform ? sub.strokeWidth / sub.scaleY : sub.strokeWidth)
|
|
const newX = (sub.points[anchorIdx].x - sub.pathOffset.x) / Math.max(baseW, 1e-9)
|
|
const newY = (sub.points[anchorIdx].y - sub.pathOffset.y) / Math.max(baseH, 1e-9)
|
|
sub.setPositionByOrigin(absolutePoint, newX + 0.5, newY + 0.5)
|
|
sub.setCoords?.()
|
|
sub.dirty = true
|
|
|
|
logger.log(
|
|
`[KERAB-VALLEY-OVERLAP-MERGE] sub.id=${sub.id} 머지 완료 ` +
|
|
`oldSigned=${oldSigned.toFixed(0)} → newSigned=${signedArea(finalPts).toFixed(0)} ` +
|
|
`pts=${finalPts.length} lines=${newSubLines.length}`,
|
|
)
|
|
if (!absorber || shares.length > absorberShares.length) {
|
|
absorber = sub
|
|
absorberShares = shares
|
|
}
|
|
})
|
|
if (!absorber || absorberShares.length < lLen) {
|
|
logger.log(
|
|
`[KERAB-VALLEY-OVERLAP-FOLD] X.id=${X.id} skip — V1 전체를 가진 흡수 면 없음 ` + `(maxShares=${absorberShares?.length ?? 0}, lLen=${lLen})`,
|
|
)
|
|
return
|
|
}
|
|
|
|
const aPts = (absorber.points || []).map((p) => ({ x: p.x, y: p.y }))
|
|
const aLines = [...(absorber.lines || [])]
|
|
const aN = aPts.length
|
|
const absShared = absorberShares.map((s) => s.subEdgeStart).sort((a, b) => a - b)
|
|
let aContig = true
|
|
for (let i = 1; i < absShared.length; i++) {
|
|
if (absShared[i] !== absShared[i - 1] + 1) aContig = false
|
|
}
|
|
if (!aContig || absShared[absShared.length - 1] + 1 > aN - 1) {
|
|
logger.log(`[KERAB-VALLEY-OVERLAP-FOLD] X.id=${X.id} skip — 흡수 면 공유 변 비연속/wrap [${absShared.join(',')}]`)
|
|
return
|
|
}
|
|
const runStart = absShared[0]
|
|
const runEnd = absShared[absShared.length - 1] + 1
|
|
// 삽입 방향: 흡수 면 run 시작점이 vStart 면 ovFromFin 역순, vFin 이면 그대로
|
|
const insert = eq(aPts[runStart], vStartVtx) ? [...ovFromFin].reverse() : [...ovFromFin]
|
|
|
|
const newPts = []
|
|
for (let i = 0; i <= runStart; i++) newPts.push(aPts[i])
|
|
for (const p of insert) newPts.push(p)
|
|
for (let i = runEnd; i < aN; i++) newPts.push(aPts[i])
|
|
|
|
// 면적 부호 보존 + 확장(절댓값 증가) 검증 — 실패 시 밴드 보존(흡수 skip)
|
|
const oldSigned = signedArea(aPts)
|
|
const newSigned = signedArea(newPts)
|
|
if ((oldSigned !== 0 && Math.sign(newSigned) !== Math.sign(oldSigned)) || Math.abs(newSigned) <= Math.abs(oldSigned) + 0.5) {
|
|
logger.log(
|
|
`[KERAB-VALLEY-OVERLAP-FOLD] X.id=${X.id} skip — 확장 검증 실패 ` + `oldSigned=${oldSigned.toFixed(0)} newSigned=${newSigned.toFixed(0)}`,
|
|
)
|
|
return
|
|
}
|
|
|
|
// 새 라인 재구성 — 각 변을 기존 흡수 면 라인 또는 밴드 OV 라인에서 매핑, 없으면 생성
|
|
const findLine = (p1, p2) => {
|
|
for (const l of aLines) {
|
|
if (!l) continue
|
|
if ((eq({ x: l.x1, y: l.y1 }, p1) && eq({ x: l.x2, y: l.y2 }, p2)) || (eq({ x: l.x1, y: l.y1 }, p2) && eq({ x: l.x2, y: l.y2 }, p1)))
|
|
return { src: l, reuse: true }
|
|
}
|
|
for (const l of Xlines) {
|
|
if (!l) continue
|
|
if ((eq({ x: l.x1, y: l.y1 }, p1) && eq({ x: l.x2, y: l.y2 }, p2)) || (eq({ x: l.x1, y: l.y1 }, p2) && eq({ x: l.x2, y: l.y2 }, p1)))
|
|
return { src: l, reuse: false }
|
|
}
|
|
return null
|
|
}
|
|
const newLines = []
|
|
for (let i = 0; i < newPts.length; i++) {
|
|
const p1 = newPts[i]
|
|
const p2 = newPts[(i + 1) % newPts.length]
|
|
const found = findLine(p1, p2)
|
|
if (found && found.reuse) {
|
|
found.src.idx = newLines.length + 1
|
|
newLines.push(found.src)
|
|
} else {
|
|
const attrs = found?.src?.attributes
|
|
? { ...found.src.attributes }
|
|
: {
|
|
type: 'kerabValleyOverlapLine',
|
|
offset: 0,
|
|
}
|
|
const ln = new QLine([p1.x, p1.y, p2.x, p2.y], {
|
|
stroke: absorber.stroke,
|
|
strokeWidth: absorber.strokeWidth,
|
|
fontSize: absorber.fontSize,
|
|
attributes: attrs,
|
|
textVisible: false,
|
|
parent: absorber,
|
|
parentId: absorber.id,
|
|
idx: newLines.length + 1,
|
|
})
|
|
ln.startPoint = p1
|
|
ln.endPoint = p2
|
|
newLines.push(ln)
|
|
}
|
|
}
|
|
|
|
// [KERAB-VALLEY-OVERLAP-FOLD 2026-06-04] fabric.Polygon points 갱신 (canvas-util#anchorWrapper 패턴).
|
|
// _setPositionDimensions 가 bbox 재계산하므로 변경 전 앵커(pts[0]) 절대좌표 캡쳐 후 복원.
|
|
const anchorIdx = 0
|
|
const oldLocal = {
|
|
x: absorber.points[anchorIdx].x - absorber.pathOffset.x,
|
|
y: absorber.points[anchorIdx].y - absorber.pathOffset.y,
|
|
}
|
|
const absolutePoint = fabric.util.transformPoint(oldLocal, absorber.calcTransformMatrix())
|
|
|
|
absorber.points = newPts
|
|
absorber.lines = newLines
|
|
absorber._setPositionDimensions({})
|
|
|
|
const strokeW = absorber.strokeUniform ? absorber.strokeWidth / absorber.scaleX : absorber.strokeWidth
|
|
const baseW = absorber.width + strokeW
|
|
const baseH = absorber.height + (absorber.strokeUniform ? absorber.strokeWidth / absorber.scaleY : absorber.strokeWidth)
|
|
const newX = (absorber.points[anchorIdx].x - absorber.pathOffset.x) / Math.max(baseW, 1e-9)
|
|
const newY = (absorber.points[anchorIdx].y - absorber.pathOffset.y) / Math.max(baseH, 1e-9)
|
|
absorber.setPositionByOrigin(absolutePoint, newX + 0.5, newY + 0.5)
|
|
absorber.setCoords?.()
|
|
absorber.dirty = true
|
|
|
|
logger.log(
|
|
`[KERAB-VALLEY-OVERLAP-FOLD] X.id=${X.id} → absorber=${absorber.id} 흡수 완료 ` +
|
|
`pts=${newPts.length} lines=${newLines.length} oldSigned=${oldSigned.toFixed(0)} newSigned=${newSigned.toFixed(0)}`,
|
|
)
|
|
|
|
canvas.remove(X)
|
|
})
|
|
|
|
canvas.renderAll?.()
|
|
}
|
|
|
|
/**
|
|
* 지붕면 할당
|
|
*/
|
|
const apply = () => {
|
|
saveSnapshot()
|
|
const roofBases = canvas.getObjects().filter((obj) => obj.name === POLYGON_TYPE.ROOF && !obj.roofMaterial)
|
|
const wallLines = canvas.getObjects().filter((obj) => obj.name === POLYGON_TYPE.WALL)
|
|
logger.log(`[ALLOC] apply() 진입. roofBases=${roofBases.length}`)
|
|
// [KERAB-VALLEY-OVERLAP-RECALC 2026-05-29] 저장→로드 사이클을 거치면 fabric 객체 좌표/캐시가 정리되어
|
|
// case A 가 잘 동작. 저장 X 시 그 효과가 없음. canvas.toJSON 한 번 호출하여 동일 효과 시뮬레이션.
|
|
try {
|
|
canvas.discardActiveObject()
|
|
canvas.toJSON()
|
|
} catch (e) {
|
|
logger.warn('[ALLOC] toJSON simulate failed', e)
|
|
}
|
|
roofBases.forEach((roofBase) => {
|
|
try {
|
|
// 지붕 할당 로직에 extensionLine 추가
|
|
|
|
// [KERAB-VALLEY-OVERLAP 2026-05-28] 골짜기 케라바 출폭 띠 외곽 라인(kerabValleyOverlapLine) 도 할당 대상.
|
|
// b polygon 의 lines[] 에 추가되어 b 의 면이 출폭 띠 영역까지 확장 → a 의 sub-면과 겹침 표현.
|
|
const roofEaveHelpLines = canvas
|
|
.getObjects()
|
|
.filter(
|
|
(obj) =>
|
|
(obj.lineName === 'eaveHelpLine' || obj.lineName === 'extensionLine' || obj.lineName === 'kerabValleyOverlapLine') &&
|
|
obj.roofId === roofBase.id,
|
|
)
|
|
// logger.log('roofBase.id:', roofBase.id)
|
|
// logger.log('roofEaveHelpLines:', roofEaveHelpLines)
|
|
// logger.log('extensionLines found:', roofEaveHelpLines.filter(l => l.lineName === 'extensionLine'))
|
|
if (roofEaveHelpLines.length > 0) {
|
|
if (roofBase.lines) {
|
|
// Filter out any eaveHelpLines that are already in lines to avoid duplicates
|
|
const existingEaveLineIds = new Set(roofBase.lines.map((line) => line.id))
|
|
const newEaveLines = roofEaveHelpLines.filter((line) => !existingEaveLineIds.has(line.id))
|
|
// Filter out lines from roofBase.lines that share any points with newEaveLines
|
|
|
|
// extensionLine과 일반 eaveHelpLine 분리
|
|
|
|
const extensionLines = newEaveLines.filter((line) => line.lineName === 'extensionLine')
|
|
const normalEaveLines = newEaveLines.filter((line) => line.lineName === 'eaveHelpLine')
|
|
// [KERAB-VALLEY-OVERLAP 2026-05-28] 골짜기 케라바 겹침 라인 — overlap 판단 제외, 단순 결합
|
|
const overlapLines = newEaveLines.filter((line) => line.lineName === 'kerabValleyOverlapLine')
|
|
// logger.log('extensionLines count:', extensionLines.length)
|
|
// logger.log('normalEaveLines count:', normalEaveLines.length)
|
|
|
|
// 일반 eaveHelpLine만 Overlap 판단에 사용
|
|
const linesToKeep = roofBase.lines.filter((roofLine) => {
|
|
const shouldRemove = normalEaveLines.some((eaveLine) => {
|
|
// 1. 기본적인 포인트 일치 확인
|
|
const rX1 = roofLine.x1,
|
|
rY1 = roofLine.y1,
|
|
rX2 = roofLine.x2,
|
|
rY2 = roofLine.y2
|
|
const eX1 = eaveLine.x1,
|
|
eY1 = eaveLine.y1,
|
|
eX2 = eaveLine.x2,
|
|
eY2 = eaveLine.y2
|
|
|
|
const isP1Matched =
|
|
(Math.abs(rX1 - eX1) < 0.1 && Math.abs(rY1 - eY1) < 0.1) || (Math.abs(rX1 - eX2) < 0.1 && Math.abs(rY1 - eY2) < 0.1)
|
|
const isP2Matched =
|
|
(Math.abs(rX2 - eX1) < 0.1 && Math.abs(rY2 - eY1) < 0.1) || (Math.abs(rX2 - eX2) < 0.1 && Math.abs(rY2 - eY2) < 0.1)
|
|
|
|
if (isP1Matched || isP2Matched) {
|
|
// 2. 일직선(평행)인지 확인
|
|
const dx1 = rX2 - rX1
|
|
const dy1 = rY2 - rY1
|
|
const dx2 = eX2 - eX1
|
|
const dy2 = eY2 - eY1
|
|
const crossProduct = Math.abs(dx1 * dy2 - dy1 * dx2)
|
|
const mag1 = Math.sqrt(dx1 * dx1 + dy1 * dy1)
|
|
const mag2 = Math.sqrt(dx2 * dx2 + dy2 * dy2)
|
|
const isStraight = mag1 * mag2 === 0 ? true : crossProduct / (mag1 * mag2) < 0.01
|
|
|
|
if (isStraight) {
|
|
// 3. [핵심] 몸통이 포개지는지(Overlap) 확인
|
|
// 한 선의 끝점이 다른 선의 "내부"에 들어와 있는지 체크
|
|
const isPointInside = (x, y, x1, y1, x2, y2) => {
|
|
const dotProduct = (x - x1) * (x2 - x1) + (y - y1) * (y2 - y1)
|
|
if (dotProduct < 0.1) return false // 시작점 바깥쪽
|
|
const squaredLength = (x2 - x1) ** 2 + (y2 - y1) ** 2
|
|
if (dotProduct > squaredLength - 0.1) return false // 끝점 바깥쪽
|
|
return true // 선의 내부(몸통)에 있음
|
|
}
|
|
|
|
// roofLine의 끝점 중 하나가 eaveLine의 몸통 안에 있거나,
|
|
// eaveLine의 끝점 중 하나가 roofLine의 몸통 안에 있으면 "포개짐"으로 판단
|
|
const isOverlapping =
|
|
isPointInside(rX1, rY1, eX1, eY1, eX2, eY2) ||
|
|
isPointInside(rX2, rY2, eX1, eY1, eX2, eY2) ||
|
|
isPointInside(eX1, eY1, rX1, rY1, rX2, rY2) ||
|
|
isPointInside(eX2, eY2, rX1, rY1, rX2, rY2)
|
|
|
|
if (isOverlapping) {
|
|
logger.log('Removing overlapping line:', roofLine)
|
|
return true // 포개지는 경우에만 삭제
|
|
}
|
|
}
|
|
}
|
|
|
|
return false // 끝점만 닿아 있거나 직각인 경우는 살림
|
|
})
|
|
return !shouldRemove
|
|
})
|
|
// Combine remaining lines with newEaveLines
|
|
roofBase.lines = [...linesToKeep, ...normalEaveLines, ...extensionLines, ...overlapLines]
|
|
} else {
|
|
roofBase.lines = [...roofEaveHelpLines]
|
|
}
|
|
if (!roofBase.innerLines) {
|
|
roofBase.innerLines = []
|
|
}
|
|
}
|
|
|
|
if (roofBase.adjustRoofLines.length > 0) {
|
|
const newRoofLines = []
|
|
let lineIndex = 1
|
|
roofBase.lines.forEach((line, idx) => {
|
|
const adjustLines = roofBase.adjustRoofLines.filter((adjustLine) => adjustLine.roofIdx === line.idx)
|
|
if (adjustLines.length === 0) {
|
|
line.idx = lineIndex
|
|
newRoofLines.push(line)
|
|
lineIndex++
|
|
} else {
|
|
adjustLines.forEach(({ point, roofIdx }) => {
|
|
const newLine = new QLine(point, {
|
|
idx: lineIndex,
|
|
selectable: false,
|
|
parentId: line.parentId,
|
|
parent: line.parent,
|
|
fontSize: line.fontSize,
|
|
stroke: line.stroke,
|
|
strokeWidth: line.strokeWidth,
|
|
attributes: line.attributes,
|
|
})
|
|
newRoofLines.push(newLine)
|
|
lineIndex++
|
|
})
|
|
}
|
|
})
|
|
roofBase.lines = newRoofLines
|
|
}
|
|
// extensionLine + 동일직선 SK 1:1 통합 (대각선 단일길이/각도 면적 산출)
|
|
integrateExtensionLines(roofBase)
|
|
|
|
// [KERAB-PATTERN-DIAG 2026-05-19] 패턴 라인 상태 진단 — 분할 직전 스냅샷
|
|
const __patternLines = roofBase.innerLines.filter((l) => l?.lineName === 'kerabPatternHip' || l?.lineName === 'kerabPatternRidge')
|
|
if (__patternLines.length > 0) {
|
|
logger.log(
|
|
`[KERAB-PATTERN-DIAG] roof=${roofBase.id} patternLines=${__patternLines.length} ` +
|
|
`roof.points=${roofBase.points?.length ?? 0} ` +
|
|
`lines=${roofBase.lines?.length ?? 0} innerLines=${roofBase.innerLines.length}`,
|
|
)
|
|
__patternLines.forEach((l) =>
|
|
logger.log(
|
|
` [PATTERN] ${l.lineName} ${l.name} (${l.x1.toFixed(1)},${l.y1.toFixed(1)})` +
|
|
`→(${l.x2.toFixed(1)},${l.y2.toFixed(1)}) ` +
|
|
`type=${l.attributes?.type ?? 'none'} __extended=${l.__extended} attrExtended=${l.attributes?.extended}`,
|
|
),
|
|
)
|
|
}
|
|
|
|
// [KERAB-VALLEY-OVERLAP 2026-05-29] vExt 기반 wallExt 4변 in-memory 재계산.
|
|
// case A(저장 X→케라바→할당) 에서 canvas 의 kerabValleyOverlapLine 가 silent 사라지는 문제 회피.
|
|
// vExt(kerabPatternValleyExt) 는 roof.innerLines 에 안정적으로 보존 — 거기서 직접 재계산.
|
|
// 조건: vExt 가 polygon 내부에 들어온 케이스 한정 (innerLines 에 있으면 자동 충족).
|
|
const vExtsForOverlap = (roofBase.innerLines || []).filter((l) => l && l.lineName === 'kerabPatternValleyExt' && l.visible !== false)
|
|
// [KERAB-VALLEY-OVERLAP-DEDUP 2026-06-04] buildOverlapLine(useEavesGableEdit)가 케라바 토글 시
|
|
// 생성한 밴드와 이 RECALC 가 이중 생성 → split 가 중복 면 → 머지 모호 → 대각선.
|
|
// 단일 소스로 통일: 재계산 전 기존 kerabValleyOverlapLine 을 roofBase/canvas 에서 제거.
|
|
// vExt 가 있는 roof 만 (= RECALC 가 다시 생성할 roof) 대상 — 아니면 기존 밴드 유실.
|
|
if (vExtsForOverlap.length > 0) {
|
|
const isBandLine = (l) => l && (l.lineName === 'kerabValleyOverlapLine' || l.attributes?.type === 'kerabValleyOverlapLine')
|
|
roofBase.lines = (roofBase.lines || []).filter((l) => !isBandLine(l))
|
|
roofBase.innerLines = (roofBase.innerLines || []).filter((l) => !isBandLine(l))
|
|
canvas
|
|
.getObjects()
|
|
.filter((o) => isBandLine(o) && (o.roofId === roofBase.id || o.parentId === roofBase.id || o.attributes?.roofId === roofBase.id))
|
|
.forEach((o) => canvas.remove(o))
|
|
}
|
|
// [KERAB-VALLEY-EXT-SPLIT 2026-06-04] vExt 가 split 되면 같은 V1 이 여러 collinear 세그먼트로 쪼개진다.
|
|
// overlap 밴드는 V1(벽) 당 한 번만 그려야 한다 — 세그먼트마다 make() 하면 분할점에서 중복 변이 2개 생긴다.
|
|
// 같은 wall(__targetId/wallLine/source)로 묶고, 세그먼트 양 끝의 최외곽 두 점을 vStart/vEnd 로 써서 한 번만 재계산.
|
|
const overlapGroups = new Map()
|
|
for (const seg of vExtsForOverlap) {
|
|
const gkey = seg.__targetId || seg.attributes?.wallLine || seg.__valleyExtSource || seg.parentLine?.id || seg.id
|
|
if (!overlapGroups.has(gkey)) overlapGroups.set(gkey, [])
|
|
overlapGroups.get(gkey).push(seg)
|
|
}
|
|
for (const segs of overlapGroups.values()) {
|
|
// 대표 세그먼트 — 속성/wallLine 해석용
|
|
const vExt = segs[0]
|
|
// 그룹 내 모든 끝점 중 가장 먼 두 점 = 합쳐진 V1 전체 span
|
|
let combStart = { x: vExt.x1, y: vExt.y1 }
|
|
let combEnd = { x: vExt.x2, y: vExt.y2 }
|
|
if (segs.length > 1) {
|
|
const pts = []
|
|
for (const s of segs) {
|
|
pts.push({ x: s.x1, y: s.y1 }, { x: s.x2, y: s.y2 })
|
|
}
|
|
let maxD = -1
|
|
for (let i = 0; i < pts.length; i++) {
|
|
for (let j = i + 1; j < pts.length; j++) {
|
|
const d = Math.hypot(pts[i].x - pts[j].x, pts[i].y - pts[j].y)
|
|
if (d > maxD) {
|
|
maxD = d
|
|
combStart = pts[i]
|
|
combEnd = pts[j]
|
|
}
|
|
}
|
|
}
|
|
}
|
|
// [KERAB-VALLEY-OVERLAP-RECALC 2026-05-29] wallLineId 보장 fallback 체인.
|
|
// 1) vExt.attributes.wallLine (정상 케이스)
|
|
// 2) vExt.__targetId (useEavesGableEdit.js drawValleyExtensions 에서 백업)
|
|
// 3) vExt.parentLine.attributes.wallLine
|
|
// 4) vStart 좌표 매칭하는 roof.lines 변의 wallLine
|
|
let wallLineId = vExt.attributes?.wallLine
|
|
if (!wallLineId) wallLineId = vExt.__targetId
|
|
if (!wallLineId && vExt.parentLine) wallLineId = vExt.parentLine.attributes?.wallLine
|
|
if (!wallLineId) {
|
|
// vStart 가 끝점인 roof.lines 변에서 wallLine 추출
|
|
const vStartP = { x: combStart.x, y: combStart.y }
|
|
for (const rl of roofBase.lines || []) {
|
|
if (!rl?.attributes?.wallLine) continue
|
|
const m1 = Math.hypot(rl.x1 - vStartP.x, rl.y1 - vStartP.y) < 1
|
|
const m2 = Math.hypot(rl.x2 - vStartP.x, rl.y2 - vStartP.y) < 1
|
|
if (m1 || m2) {
|
|
wallLineId = rl.attributes.wallLine
|
|
break
|
|
}
|
|
}
|
|
}
|
|
if (!wallLineId) {
|
|
logger.warn('[KERAB-VALLEY-OVERLAP-RECALC] vExt skip — wallLineId 매칭 실패 (attributes/parent/좌표 모두)')
|
|
continue
|
|
}
|
|
let target = canvas.getObjects().find((o) => o.id === wallLineId && o.name === 'outerLine')
|
|
if (!target) {
|
|
const candidates = canvas.getObjects().filter((o) => o.id === wallLineId)
|
|
target = candidates[0]
|
|
if (!target) {
|
|
logger.warn('[KERAB-VALLEY-OVERLAP-RECALC] outerLine 못찾음 wallLineId=' + wallLineId?.slice(0, 8))
|
|
continue
|
|
}
|
|
}
|
|
const vStart = { x: combStart.x, y: combStart.y }
|
|
const vEnd = { x: combEnd.x, y: combEnd.y }
|
|
const dxT = target.x2 - target.x1
|
|
const dyT = target.y2 - target.y1
|
|
const lenTSq = dxT * dxT + dyT * dyT
|
|
const tFoot = ((vStart.x - target.x1) * dxT + (vStart.y - target.y1) * dyT) / Math.max(lenTSq, 1e-9)
|
|
const newWStart = { x: target.x1 + tFoot * dxT, y: target.y1 + tFoot * dyT }
|
|
const offX = newWStart.x - vStart.x
|
|
const offY = newWStart.y - vStart.y
|
|
const wEndProj = { x: vEnd.x + offX, y: vEnd.y + offY }
|
|
const make = (p1, p2) => {
|
|
const lpts = [p1.x, p1.y, p2.x, p2.y]
|
|
const dx = lpts[2] - lpts[0]
|
|
const dy = lpts[3] - lpts[1]
|
|
const lsz = Math.round(Math.hypot(dx, dy) * 10)
|
|
const ln = new QLine(lpts, {
|
|
parentId: roofBase.id,
|
|
fontSize: roofBase.fontSize,
|
|
stroke: 'rgba(0,0,0,0)',
|
|
strokeWidth: 0,
|
|
name: 'valley',
|
|
visible: true,
|
|
opacity: 0,
|
|
attributes: {
|
|
roofId: roofBase.id,
|
|
type: 'kerabValleyOverlapLine',
|
|
isStart: true,
|
|
planeSize: lsz,
|
|
actualSize: lsz,
|
|
},
|
|
})
|
|
ln.lineName = 'kerabValleyOverlapLine'
|
|
ln.roofId = roofBase.id
|
|
return ln
|
|
}
|
|
// [KERAB-VALLEY-OVERLAP-RECALC 2026-05-29] float drift 회피 — vStart 와 가장 가까운 polygon corner 로 snap.
|
|
// 저장→로드 후엔 좌표 정수 round 로 일치하지만 저장 X 시 drift 가능 → split 알고리즘 외곽 인식 실패.
|
|
const snapToCorner = (p) => {
|
|
for (const rp of roofBase.points || []) {
|
|
const d = Math.hypot(rp.x - p.x, rp.y - p.y)
|
|
if (d < 1) return { x: rp.x, y: rp.y }
|
|
}
|
|
return p
|
|
}
|
|
const vStartSnap = snapToCorner(vStart)
|
|
// [KERAB-VALLEY-OVERLAP-SINGLE-BAND 2026-06-04] 밴드(V1·V2·V3·V4 사각형)는 새 지붕면이 아니라
|
|
// "겹침" 영역 — 단일 면으로 두고 mergeValleyOverlapSubRoofs 가 인접 면(F3)에 접어 넣는다.
|
|
// 따라서 벽/connector 를 분할하지 않고 quad 4변만 만든다.
|
|
// V1(vExt)은 이미 innerLines 에 존재 → 나머지 3변(start connector, wall, end connector)만 추가.
|
|
// V1 의 RG-2 교차 노드(Option A split)는 RG-2 를 그래프에 연결하되 밴드는 한 면으로 유지.
|
|
const made = [make(vStartSnap, newWStart), make(newWStart, wEndProj), make(wEndProj, vEnd)]
|
|
roofBase.lines.push(...made)
|
|
roofBase.innerLines.push(...made)
|
|
}
|
|
|
|
// [KERAB-TYPE-EAVES-SK-HELPER-PURGE 2026-06-12] kerabPatternHip 이 있을 때
|
|
// name='hip', lineName 미설정 인 SK 격자 반-엣지를 innerLines 에서 제거한다.
|
|
// 이 선들은 외곽 gable 변과 동일직선상에서 더 짧은 경로를 제공하여 Dijkstra 가
|
|
// degenerate collinear face 를 먼저 선택 → 진짜 top/bottom 사다리꼴 면의 start 가
|
|
// 소진돼 미생성된다. kerab 케이스에서만 purge — 일반 SK hip 에서는 건드리지 않음.
|
|
// [GABLE-ROOFLINE-LABEL 2026-06-24] purge 대상은 lineName 없는 SK 격자 반-엣지 hip 뿐.
|
|
// 박공 처마/골짜기 roofLine 은 drawRoofLine 에서 lineName='roofLine' 을 받으므로 이 필터에 안 걸린다.
|
|
if (roofBase.innerLines.some((l) => l?.lineName === 'kerabPatternHip')) {
|
|
const skHelpers = roofBase.innerLines.filter((l) => !l?.lineName && l?.name === 'hip')
|
|
skHelpers.forEach((l) => canvas.remove(l))
|
|
roofBase.innerLines = roofBase.innerLines.filter((l) => !(!l?.lineName && l?.name === 'hip'))
|
|
}
|
|
|
|
if (roofBase.separatePolygon.length > 0) {
|
|
splitPolygonWithSeparate(roofBase.separatePolygon)
|
|
} else {
|
|
splitPolygonWithLines(roofBase)
|
|
}
|
|
|
|
// [KERAB-PATTERN-DIAG 2026-05-19] 분할 후 결과 — 새 sub-roof 개수
|
|
if (__patternLines.length > 0) {
|
|
const __newRoofs = canvas.getObjects().filter((o) => o.name === 'roof' && !o.isFixed)
|
|
logger.log(`[KERAB-PATTERN-DIAG] 분할 후 새 sub-roof=${__newRoofs.length}`)
|
|
}
|
|
|
|
// [KERAB-VALLEY-OVERLAP-MERGE 2026-05-28] 직사각형 sub-roof 를 인접 두 sub-roof 에 union 머지.
|
|
// 골짜기 출폭 띠 영역이 두 면 모두에 속하도록 (겹침 표현).
|
|
// 직사각형 식별: 외곽 라인의 attributes.type === 'kerabValleyOverlapLine' 가 다수인 sub-roof.
|
|
// 인접 두 sub-roof = 직사각형의 변 좌표를 공유하는 다른 sub-roof.
|
|
mergeValleyOverlapSubRoofs()
|
|
} catch (e) {
|
|
logger.log(e)
|
|
canvas.discardActiveObject()
|
|
return
|
|
}
|
|
|
|
/** 라인 삭제 */
|
|
roofBase.innerLines.forEach((line) => {
|
|
canvas.remove(line)
|
|
})
|
|
|
|
// [KERAB-VALLEY-OVERLAP 2026-05-28] roofBase.lines 로 추가된 보조 라인(kerabValleyOverlapLine) 및
|
|
// wallBase 변형 kerabPatternValleyExt(innerLines 미포함) 는 split 이후 더 이상 필요 없으므로 정리.
|
|
// 미정리 시 canvas 잔류 → sub-roof 위에 솔리드/점선으로 남는다.
|
|
const overlapLeftovers = canvas
|
|
.getObjects()
|
|
.filter(
|
|
(obj) =>
|
|
(obj.lineName === 'kerabValleyOverlapLine' || obj.lineName === 'kerabPatternValleyExt') &&
|
|
(obj.roofId === roofBase.id || obj?.attributes?.roofId === roofBase.id),
|
|
)
|
|
overlapLeftovers.forEach((line) => canvas.remove(line))
|
|
|
|
canvas.remove(roofBase)
|
|
})
|
|
|
|
/** 데이터 설정 */
|
|
const newRoofList = currentRoofList.map((roof, idx) => {
|
|
return { ...roof, index: idx, ...basicInfo, raft: roof.raft ? roof.raft : roof.raftBaseCd }
|
|
})
|
|
|
|
setBasicSetting((prev) => {
|
|
return { ...prev, selectedRoofMaterial: newRoofList.find((roof) => roof.selected) }
|
|
})
|
|
setRoofList(newRoofList)
|
|
|
|
const roofs = canvas.getObjects().filter((obj) => obj.name === 'roof')
|
|
|
|
roofs.forEach((roof) => {
|
|
if (roof.isFixed) return
|
|
roof.set({ isFixed: true })
|
|
|
|
/** 모양 패턴 설정 */
|
|
setSurfaceShapePattern(
|
|
roof,
|
|
roofDisplay.column,
|
|
false,
|
|
currentRoofList.find((roof) => roof.selected),
|
|
)
|
|
drawDirectionArrow(roof)
|
|
})
|
|
|
|
// [ROOF-FACE-DIAG 2026-06-04] 할당된 지붕면별 면적/좌표 디버그 라벨+로그 (로컬 전용).
|
|
reattachRoofFaceDebugLabels(canvas)
|
|
|
|
setRoofMaterials(newRoofList)
|
|
setRoofsStore(newRoofList)
|
|
/** 외곽선 삭제 */
|
|
const removeTargets = canvas.getObjects().filter((obj) => obj.name === 'outerLinePoint' || obj.name === 'outerLine' || obj.name === 'pitchText')
|
|
removeTargets.forEach((obj) => {
|
|
canvas.remove(obj)
|
|
})
|
|
setEditingLines([])
|
|
closeAll()
|
|
setSelectedMenu('surface')
|
|
//지붕면 완성 후 실측치 로 보이도록 수정
|
|
setCorridorDimension(1)
|
|
|
|
/** 모듈 선택 데이터 초기화 */
|
|
// modifyModuleSelectionData()
|
|
setModuleSelectionData({ ...moduleSelectionData, roofConstructions: newRoofList })
|
|
|
|
setTimeout(() => {
|
|
changeCorridorDimensionText('realDimension')
|
|
}, 500)
|
|
}
|
|
|
|
/**
|
|
* 라인 사이즈 설정
|
|
*/
|
|
const setLineSize = (id, size) => {
|
|
const roofBases = canvas.getObjects().filter((obj) => obj.name === POLYGON_TYPE.ROOF)
|
|
roofBases.forEach((roof) => {
|
|
roof.innerLines.forEach((line) => {
|
|
if (id === line.id) {
|
|
setEditingLines([...editingLines.filter((editLine) => editLine.id !== line.id), line])
|
|
line.attributes.actualSize = size
|
|
line.set({ strokeWidth: 2, stroke: 'black' })
|
|
}
|
|
})
|
|
})
|
|
|
|
canvas?.renderAll()
|
|
}
|
|
|
|
/**
|
|
* 지붕재 변경
|
|
*/
|
|
const handleChangeRoofMaterial = (value, index) => {
|
|
const selectedRoofMaterial = roofMaterials.find((roof) => roof.roofMatlCd === value.id)
|
|
const newRoofList = currentRoofList.map((roof, idx) => {
|
|
if (idx === index) {
|
|
return { ...selectedRoofMaterial, selected: roof.selected, index }
|
|
}
|
|
return roof
|
|
})
|
|
|
|
setCurrentRoofList(newRoofList)
|
|
}
|
|
|
|
/**
|
|
* 기본 지붕재 radio값 변경
|
|
*/
|
|
const handleDefaultRoofMaterial = (index) => {
|
|
const newRoofList = currentRoofList.map((roof, idx) => {
|
|
return { ...roof, selected: idx === index }
|
|
})
|
|
|
|
setCurrentRoofList(newRoofList)
|
|
}
|
|
|
|
/**
|
|
* 서까래 변경
|
|
*/
|
|
const handleChangeRaft = (e, index) => {
|
|
const raftValue = e.clCode
|
|
|
|
const newRoofList = currentRoofList.map((roof, idx) => {
|
|
if (idx === index) {
|
|
return { ...roof, raft: raftValue }
|
|
}
|
|
return roof
|
|
})
|
|
|
|
setCurrentRoofList(newRoofList)
|
|
}
|
|
|
|
/**
|
|
* 레이아웃 변경
|
|
*/
|
|
const handleChangeLayout = (layoutValue, index) => {
|
|
const newRoofList = currentRoofList.map((roof, idx) => {
|
|
if (idx === index) {
|
|
return { ...roof, layout: layoutValue }
|
|
}
|
|
return roof
|
|
})
|
|
|
|
setCurrentRoofList(newRoofList)
|
|
}
|
|
|
|
/**
|
|
* 치수 입력방법(복시도입력/실측값입력/육지붕)
|
|
*/
|
|
const handleChangeInput = (e, type = '', index) => {
|
|
const value = e.target.value
|
|
const newRoofList = currentRoofList.map((roof, idx) => {
|
|
if (idx === index) {
|
|
return { ...roof, [type]: value }
|
|
}
|
|
return roof
|
|
})
|
|
|
|
setCurrentRoofList(newRoofList)
|
|
}
|
|
|
|
/**
|
|
* 피치 변경
|
|
*/
|
|
const handleChangePitch = (e, index) => {
|
|
let value = e //e.target.value
|
|
|
|
const reg = /^[0-9]+(\.[0-9]{0,1})?$/
|
|
if (!reg.test(value)) {
|
|
value = value.substring(0, value.length - 1)
|
|
}
|
|
const newRoofList = currentRoofList.map((roof, idx) => {
|
|
if (idx === index) {
|
|
const result =
|
|
currentAngleType === 'slope'
|
|
? {
|
|
pitch: value,
|
|
angle: getDegreeByChon(value),
|
|
}
|
|
: { pitch: getChonByDegree(value), angle: value }
|
|
return { ...roof, ...result }
|
|
}
|
|
return roof
|
|
})
|
|
|
|
setCurrentRoofList(newRoofList)
|
|
}
|
|
|
|
/**
|
|
* 모듈 선택에서 선택한 데이터 초기화
|
|
*/
|
|
const modifyModuleSelectionData = () => {
|
|
if (moduleSelectionData.roofConstructions?.length > 0) {
|
|
setModuleSelectionData({ ...moduleSelectionData, roofConstructions: [] })
|
|
moduleSelectedDataTrigger({ ...moduleSelectionData, roofConstructions: [] })
|
|
}
|
|
}
|
|
|
|
/**
|
|
* 모듈 선택 데이터 트리거
|
|
*/
|
|
const { trigger: moduleSelectedDataTrigger } = useCanvasPopupStatusController(2)
|
|
|
|
return {
|
|
handleSave,
|
|
onAddRoofMaterial,
|
|
onDeleteRoofMaterial,
|
|
handleAlloc,
|
|
setLineSize,
|
|
roofMaterials,
|
|
selectedRoofMaterial,
|
|
basicSetting,
|
|
setBasicSetting,
|
|
currentRoofMaterial,
|
|
setCurrentRoofMaterial,
|
|
handleDefaultRoofMaterial,
|
|
handleChangeRoofMaterial,
|
|
handleChangeRaft,
|
|
handleChangeLayout,
|
|
handleSaveContext,
|
|
currentRoofList,
|
|
handleChangeInput,
|
|
handleChangePitch,
|
|
}
|
|
}
|