import { useRecoilState, useRecoilValue, useResetRecoilState } from 'recoil' import { canvasState, currentAngleTypeSelector, currentObjectState } from '@/store/canvasAtom' import { useContext, useEffect, 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, LINE_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 { reattachRoofFaceDebugLabels } from '@/components/fabric/QPolygon' import { calcLineActualSize2 } from '@/util/qpolygon-utils' // [LOW-PITCH-WARN 2026-05-06] 저구배 + 특정 기와 사용 시 시공 매뉴얼 안내 alert import { notifyLowPitchRestrictionForRoofs, isLowPitchRestricted, LOW_PITCH_RESTRICTED_ROOF_IDS } 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 outlineDisplay = useRecoilValue(outlineDisplaySelector) useEffect(() => { /** 배치면 초기설정에서 선택한 지붕재 배열 설정 */ setCurrentRoofList(roofList) }, []) useEffect(() => { /** 지붕면 조회 */ const roofBases = canvas.getObjects().filter((obj) => obj.name === POLYGON_TYPE.ROOF) /** roofPolygon.innerLines */ 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)) { currentObject.set({ strokeWidth: 4, stroke: '#EA10AC' }) } }, [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) }, []) /** * 배치면 초기설정 조회 */ 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, ) } 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 }) } /** * 기존 세팅된 지붕에 지붕재 내용을 바뀐 내용으로 수정 * @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) => { 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 = () => { 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) } 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, } }