From 3811f224d6952d28d4d4074e555190ba2612ba6c Mon Sep 17 00:00:00 2001 From: "hyojun.choi" Date: Wed, 10 Sep 2025 17:51:07 +0900 Subject: [PATCH 01/20] =?UTF-8?q?plan=20=EC=A0=80=EC=9E=A5=20=EC=A0=84=20?= =?UTF-8?q?=EC=84=A0=ED=83=9D=ED=95=9C=20setupsurface=20=EC=84=A0=ED=83=9D?= =?UTF-8?q?=EB=90=9C=20=EA=B2=BD=EC=9A=B0=20=EC=B4=88=EA=B8=B0=ED=99=94?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/hooks/usePlan.js | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/src/hooks/usePlan.js b/src/hooks/usePlan.js index ba8776d4..cdb02fef 100644 --- a/src/hooks/usePlan.js +++ b/src/hooks/usePlan.js @@ -173,6 +173,13 @@ export function usePlan(params = {}) { * @param {boolean} saveAlert - 저장 완료 알림 표시 여부 */ const saveCanvas = async (saveAlert = true) => { + // 저장 전 선택되어 있는 object 제거 + const setupSurfaces = canvas.getObjects().filter((obj) => obj.name === POLYGON_TYPE.MODULE_SETUP_SURFACE) + + setupSurfaces.forEach((surface) => { + surface.set({ fill: 'rgba(255,255,255,0.1)', strokeDashArray: [10, 4], strokeWidth: 1 }) + }) + const canvasStatus = currentCanvasData('save') const result = await putCanvasStatus(canvasStatus, saveAlert) //캔버스 저장 완료 후 From 0666612c0a14d496e9107eecf6583eda00866ad8 Mon Sep 17 00:00:00 2001 From: ysCha Date: Wed, 10 Sep 2025 18:21:30 +0900 Subject: [PATCH 02/20] =?UTF-8?q?[1216]=20=EB=AA=A8=EB=93=88=EC=97=90=20?= =?UTF-8?q?=EC=8B=9C=EB=A6=AC=EC=A6=88=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../modal/basic/step/Orientation.jsx | 131 ++++++++++++++++-- src/locales/ja.json | 2 + src/locales/ko.json | 4 +- 3 files changed, 127 insertions(+), 10 deletions(-) diff --git a/src/components/floor-plan/modal/basic/step/Orientation.jsx b/src/components/floor-plan/modal/basic/step/Orientation.jsx index 14618a20..c5427d3b 100644 --- a/src/components/floor-plan/modal/basic/step/Orientation.jsx +++ b/src/components/floor-plan/modal/basic/step/Orientation.jsx @@ -7,16 +7,21 @@ import { useRecoilState, useRecoilValue, useSetRecoilState } from 'recoil' import QSelectBox from '@/components/common/select/QSelectBox' import { roofsState } from '@/store/roofAtom' import { useModuleBasicSetting } from '@/hooks/module/useModuleBasicSetting' +import { useCommonCode } from '@/hooks/common/useCommonCode' import Swal from 'sweetalert2' import { normalizeDecimal} from '@/util/input-utils' export const Orientation = forwardRef((props, ref) => { const { getMessage } = useMessage() + const { findCommonCode } = useCommonCode() const [hasAnglePassivity, setHasAnglePassivity] = useState(false) const basicSetting = useRecoilValue(basicSettingState) const [addedRoofs, setAddedRoofs] = useRecoilState(addedRoofsState) //지붕재 선택 const [roofsStore, setRoofsStore] = useRecoilState(roofsState) const [roofTab, setRoofTab] = useState(0) //지붕재 탭 + const [selectedModuleSeries, setSelectedModuleSeries] = useState(null) + const [moduleSeriesList, setModuleSeriesList] = useState([]) + const [filteredModuleList, setFilteredModuleList] = useState([]) const { roofs, setRoofs, @@ -80,9 +85,20 @@ export const Orientation = forwardRef((props, ref) => { useEffect(() => { if (selectedModules) { - setSelectedModules(moduleList.find((module) => module.itemId === selectedModules.itemId)) + const foundModule = moduleList.find((module) => module.itemId === selectedModules.itemId) + if (foundModule) { + setSelectedModules(foundModule) + + // 선택된 모듈의 시리즈로 업데이트 (시리즈 목록이 있는 경우에만) + if (moduleSeriesList.length > 0 && foundModule.moduleSerCd) { + const currentSeries = moduleSeriesList.find(series => series.moduleSerCd === foundModule.moduleSerCd) + if (currentSeries && (!selectedModuleSeries || selectedModuleSeries.moduleSerCd !== currentSeries.moduleSerCd)) { + setSelectedModuleSeries(currentSeries) + } + } + } } - }, [selectedModules]) + }, [selectedModules, moduleList, moduleSeriesList]) useEffect(() => { if (selectedSurfaceType) { @@ -206,6 +222,31 @@ export const Orientation = forwardRef((props, ref) => { return true } + const handleChangeModuleSeries = (e) => { + resetRoofs() + setSelectedModuleSeries(e) + + // 선택된 시리즈에 맞는 모듈 목록 필터링 및 첫 번째 모듈 선택 + if (e && moduleList.length > 0) { + let filtered + + if (e.moduleSerCd === 'ALL') { + // "전체" 선택 시 모든 모듈 표시 + filtered = moduleList + } else { + // 특정 시리즈 선택 시 해당 시리즈 모듈만 표시 + filtered = moduleList.filter(module => module.moduleSerCd === e.moduleSerCd) + } + + setFilteredModuleList(filtered) + + // 필터링된 목록의 첫 번째 모듈을 자동 선택 + if (filtered.length > 0) { + setSelectedModules(filtered[0]) + } + } + } + const handleChangeModule = (e) => { resetRoofs() setSelectedModules(e) @@ -264,12 +305,69 @@ export const Orientation = forwardRef((props, ref) => { setRoofsStore(newRoofs) } + // 모듈시리즈 목록 생성 및 commonCode와 매핑 useEffect(() => { - // 컴포넌트가 마운트될 때 selectedModules가 없으면 handleChangeModule 호출 - if (moduleList.length > 0 && (!selectedModules || !selectedModules.itemId)) { - handleChangeModule(moduleList[0]); + if (moduleList.length > 0 && moduleSeriesList.length === 0) { + const moduleSeriesCodes = findCommonCode(207100) || [] + + // moduleList에서 고유한 moduleSerCd 추출 + const uniqueSeriesCd = [...new Set(moduleList.map(module => module.moduleSerCd).filter(Boolean))] + + if (uniqueSeriesCd.length > 0) { + // moduleSerCd와 commonCode를 매핑하여 기본 moduleSeriesList 생성 + const mappedSeries = uniqueSeriesCd.map(serCd => { + const matchedCode = moduleSeriesCodes.find(code => code.clCode === serCd) + return { + moduleSerCd: serCd, + moduleSerNm: matchedCode ? matchedCode.clCodeNm : serCd + } + }) + + // "전체" 옵션을 맨 앞에 추가 + const allOption = { + moduleSerCd: 'ALL', + moduleSerNm: getMessage("board.sub.total") || 'ALL' + } + + const seriesList = [allOption, ...mappedSeries] + setModuleSeriesList(seriesList) + + // 현재 선택된 모듈이 있으면 해당 모듈의 시리즈를 찾아서 선택 + if (selectedModules && selectedModules.moduleSerCd) { + const currentSeries = seriesList.find(series => series.moduleSerCd === selectedModules.moduleSerCd) + if (currentSeries) { + setSelectedModuleSeries(currentSeries) + } else { + setSelectedModuleSeries(allOption) + } + } else { + // 선택된 모듈이 없으면 "전체"를 기본 선택 + setSelectedModuleSeries(allOption) + } + } } - }, [moduleList]); // 빈 의존성 배열로 마운트 시 한 번만 실행 + }, [moduleList, selectedModules]) + + // 초기 로딩 시에만 필터링된 모듈 목록 설정 + useEffect(() => { + if (moduleList.length > 0 && filteredModuleList.length === 0 && selectedModuleSeries) { + let filtered + + if (selectedModuleSeries.moduleSerCd === 'ALL') { + // "전체" 선택 시 모든 모듈 표시 + filtered = moduleList + } else { + // 특정 시리즈 선택 시 해당 시리즈 모듈만 표시 + filtered = moduleList.filter(module => module.moduleSerCd === selectedModuleSeries.moduleSerCd) + } + + setFilteredModuleList(filtered) + + if (filtered.length > 0 && !selectedModules) { + setSelectedModules(filtered[0]) + } + } + }, [moduleList, selectedModuleSeries]); return ( <>
@@ -336,11 +434,26 @@ export const Orientation = forwardRef((props, ref) => {
- {getMessage('modal.module.basic.setting.module.setting')} + {getMessage('modal.module.basic.setting.module.series.setting')}
- {moduleList && ( + {moduleSeriesList.length > 0 && ( handleChangeModuleSeries(e)} + /> + )} +
+
+
+ {getMessage('modal.module.basic.setting.module.setting2')} +
+ {filteredModuleList && ( + Date: Thu, 11 Sep 2025 09:50:25 +0900 Subject: [PATCH 03/20] =?UTF-8?q?module=20=EC=9D=B4=EB=8F=99=20=ED=9B=84?= =?UTF-8?q?=EC=97=90=EB=8F=84=20=ED=9D=A1=EC=B0=A9=EC=A0=90=20=EC=9E=91?= =?UTF-8?q?=EB=8F=99=ED=95=98=EB=8F=84=EB=A1=9D=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/hooks/useEvent.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/hooks/useEvent.js b/src/hooks/useEvent.js index dfbe492c..8caa90ea 100644 --- a/src/hooks/useEvent.js +++ b/src/hooks/useEvent.js @@ -213,7 +213,7 @@ export function useEvent() { const modulePoints = [] const modules = canvas.getObjects().filter((obj) => obj.name === POLYGON_TYPE.MODULE) modules.forEach((module) => { - module.points.forEach((point) => { + module.getCurrentPoints().forEach((point) => { modulePoints.push({ x: point.x, y: point.y }) }) }) From fb20a1676b3e5e92c99fcba27dd12f15b64f39ba Mon Sep 17 00:00:00 2001 From: ysCha Date: Thu, 11 Sep 2025 09:50:42 +0900 Subject: [PATCH 04/20] =?UTF-8?q?=EA=B3=84=EC=82=B0=EA=B8=B0=EC=9D=98=20?= =?UTF-8?q?=EB=A7=88=EC=A7=80=EB=A7=89=20=EC=9E=85=EB=A0=A5=EC=88=AB?= =?UTF-8?q?=EC=9E=90=EA=B0=80=20=EB=B3=B4=EC=9D=B4=EA=B2=8C..?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/components/common/input/CalcInput.jsx | 798 +++++++++++----------- src/styles/calc.scss | 37 +- 2 files changed, 424 insertions(+), 411 deletions(-) diff --git a/src/components/common/input/CalcInput.jsx b/src/components/common/input/CalcInput.jsx index 311e71c3..3da3a7c5 100644 --- a/src/components/common/input/CalcInput.jsx +++ b/src/components/common/input/CalcInput.jsx @@ -2,433 +2,439 @@ import React, { useState, useRef, useEffect, forwardRef } from 'react' import { createCalculator } from '@/util/calc-utils' import '@/styles/calc.scss' -export const CalculatorInput = forwardRef(({ value, onChange, label, options = {}, id, className = 'calculator-input', readOnly = false, placeholder}, ref) => { - const [showKeypad, setShowKeypad] = useState(false) - const [displayValue, setDisplayValue] = useState(value || '0') - const [hasOperation, setHasOperation] = useState(false) - const calculatorRef = useRef(createCalculator(options)) - const containerRef = useRef(null) - const inputRef = useRef(null) +export const CalculatorInput = forwardRef( + ({ value, onChange, label, options = {}, id, className = 'calculator-input', readOnly = false, placeholder }, ref) => { + const [showKeypad, setShowKeypad] = useState(false) + const [displayValue, setDisplayValue] = useState(value || '0') + const [hasOperation, setHasOperation] = useState(false) + const calculatorRef = useRef(createCalculator(options)) + const containerRef = useRef(null) + const inputRef = useRef(null) - // 외부 ref와 내부 ref를 동기화 - useEffect(() => { - if (ref) { - if (typeof ref === 'function') { - ref(inputRef.current) - } else { - ref.current = inputRef.current - } - } - }, [ref]) - - // Sync displayValue with value prop - useEffect(() => { - setDisplayValue(value || '0') - }, [value]) - - // 클릭 외부 감지 - useEffect(() => { - const handleClickOutside = (event) => { - if (containerRef.current && !containerRef.current.contains(event.target)) { - setShowKeypad(false) - if (hasOperation) { - // If there's an operation in progress, compute the result when losing focus - handleCompute() + // 외부 ref와 내부 ref를 동기화 + useEffect(() => { + if (ref) { + if (typeof ref === 'function') { + ref(inputRef.current) + } else { + ref.current = inputRef.current } } - } + }, [ref]) - document.addEventListener('mousedown', handleClickOutside) - return () => document.removeEventListener('mousedown', handleClickOutside) - }, [value, onChange, hasOperation]) + // Sync displayValue with value prop + useEffect(() => { + setDisplayValue(value || '0') + }, [value]) - // 숫자 입력 처리 함수 수정 - const handleNumber = (num) => { - const calculator = calculatorRef.current - let newDisplayValue = '' - - if (hasOperation) { - // 연산자 이후 숫자 입력 시 - if (calculator.currentOperand === '0' || calculator.shouldResetDisplay) { - calculator.currentOperand = num.toString() - calculator.shouldResetDisplay = false - } else { - calculator.currentOperand = (calculator.currentOperand || '') + num + // 클릭 외부 감지 + useEffect(() => { + const handleClickOutside = (event) => { + if (containerRef.current && !containerRef.current.contains(event.target)) { + setShowKeypad(false) + if (hasOperation) { + // If there's an operation in progress, compute the result when losing focus + handleCompute() + } + } } - newDisplayValue = calculator.previousOperand + calculator.operation + calculator.currentOperand - setDisplayValue(newDisplayValue) - } else { - // 첫 번째 숫자 입력 시 - if (displayValue === '0' || calculator.shouldResetDisplay) { - calculator.currentOperand = num.toString() - calculator.shouldResetDisplay = false - newDisplayValue = calculator.currentOperand + + document.addEventListener('mousedown', handleClickOutside) + return () => document.removeEventListener('mousedown', handleClickOutside) + }, [value, onChange, hasOperation]) + + // 숫자 입력 처리 함수 수정 + const handleNumber = (num) => { + const calculator = calculatorRef.current + let newDisplayValue = '' + + if (hasOperation) { + // 연산자 이후 숫자 입력 시 + if (calculator.currentOperand === '0' || calculator.shouldResetDisplay) { + calculator.currentOperand = num.toString() + calculator.shouldResetDisplay = false + } else { + calculator.currentOperand = (calculator.currentOperand || '') + num + } + newDisplayValue = calculator.previousOperand + calculator.operation + calculator.currentOperand setDisplayValue(newDisplayValue) - if (!hasOperation) { - onChange(calculator.currentOperand) + } else { + // 첫 번째 숫자 입력 시 + if (displayValue === '0' || calculator.shouldResetDisplay) { + calculator.currentOperand = num.toString() + calculator.shouldResetDisplay = false + newDisplayValue = calculator.currentOperand + setDisplayValue(newDisplayValue) + if (!hasOperation) { + onChange(calculator.currentOperand) + } + } else { + calculator.currentOperand = (calculator.currentOperand || '') + num + newDisplayValue = calculator.currentOperand + setDisplayValue(newDisplayValue) + if (!hasOperation) { + onChange(newDisplayValue) + } + } + } + + // 커서를 텍스트 끝으로 이동하고 스크롤 처리 + requestAnimationFrame(() => { + if (inputRef.current) { + inputRef.current.focus() + const len = newDisplayValue.length + inputRef.current.setSelectionRange(len, len) + // 텍스트 끝으로 스크롤 + inputRef.current.scrollLeft = inputRef.current.scrollWidth + } + }) + } + + // 연산자 처리 함수 수정 + const handleOperation = (operation) => { + const calculator = calculatorRef.current + let newDisplayValue = '' + + // 현재 입력된 값이 없으면 이전 값 사용 (연속 연산 시) + if (!calculator.currentOperand && calculator.previousOperand) { + calculator.operation = operation + newDisplayValue = calculator.previousOperand + operation + setDisplayValue(newDisplayValue) + setHasOperation(true) + } else if (hasOperation) { + // 이미 연산자가 있는 경우, 계산 실행 + const result = calculator.compute() + if (result !== undefined) { + calculator.previousOperand = result.toString() + calculator.operation = operation + calculator.currentOperand = '' + newDisplayValue = calculator.previousOperand + operation + setDisplayValue(newDisplayValue) } } else { - calculator.currentOperand = (calculator.currentOperand || '') + num - newDisplayValue = calculator.currentOperand - setDisplayValue(newDisplayValue) - if (!hasOperation) { - onChange(newDisplayValue) - } - } - } - - // 포커스와 커서 위치 설정 (새로운 값의 길이로 설정) - requestAnimationFrame(() => { - if (inputRef.current) { - inputRef.current.focus() - const len = newDisplayValue.length - inputRef.current.setSelectionRange(len, len) - } - }) - } - - // 연산자 처리 함수 수정 - const handleOperation = (operation) => { - const calculator = calculatorRef.current - let newDisplayValue = '' - - // 현재 입력된 값이 없으면 이전 값 사용 (연속 연산 시) - if (!calculator.currentOperand && calculator.previousOperand) { - calculator.operation = operation - newDisplayValue = calculator.previousOperand + operation - setDisplayValue(newDisplayValue) - setHasOperation(true) - } else if (hasOperation) { - // 이미 연산자가 있는 경우, 계산 실행 - const result = calculator.compute() - if (result !== undefined) { - calculator.previousOperand = result.toString() + // 첫 번째 연산자 입력 시 + calculator.previousOperand = calculator.currentOperand || '0' calculator.operation = operation calculator.currentOperand = '' + setHasOperation(true) newDisplayValue = calculator.previousOperand + operation setDisplayValue(newDisplayValue) } - } else { - // 첫 번째 연산자 입력 시 - calculator.previousOperand = calculator.currentOperand || '0' - calculator.operation = operation - calculator.currentOperand = '' - setHasOperation(true) - newDisplayValue = calculator.previousOperand + operation - setDisplayValue(newDisplayValue) + + // 커서를 텍스트 끝으로 이동하고 스크롤 처리 + requestAnimationFrame(() => { + if (inputRef.current) { + inputRef.current.focus() + const len = newDisplayValue.length + inputRef.current.setSelectionRange(len, len) + // 텍스트 끝으로 스크롤 + inputRef.current.scrollLeft = inputRef.current.scrollWidth + } + }) } - - // 포커스와 커서 위치 설정 (새로운 값의 길이로 설정) - requestAnimationFrame(() => { - if (inputRef.current) { - inputRef.current.focus() - const len = newDisplayValue.length - inputRef.current.setSelectionRange(len, len) - } - }) - } - // AC 버튼 클릭 핸들러 - const handleClear = () => { - const calculator = calculatorRef.current - const newValue = calculator.clear() - const displayValue = newValue || '0' - setDisplayValue(displayValue) - setHasOperation(false) - onChange(displayValue) - // 포커스와 커서 위치 설정 - requestAnimationFrame(() => { - if (inputRef.current) { - inputRef.current.focus() - const len = displayValue.length - inputRef.current.setSelectionRange(len, len) - // Ensure focus is maintained - inputRef.current.focus() - } - }) - } - - // 계산 실행 함수 수정 - const handleCompute = (fromEnterKey = false) => { - const calculator = calculatorRef.current - if (!hasOperation || !calculator.currentOperand) return - - const result = calculator.compute() - if (result !== undefined) { - const resultStr = result.toString() - setDisplayValue(resultStr) - setHasOperation(false) - // Only call onChange with the final result - onChange(resultStr) - - // 엔터키로 호출된 경우 포커스 설정하지 않음 - if (!fromEnterKey) { - requestAnimationFrame(() => { - if (inputRef.current) { - inputRef.current.focus() - const len = resultStr.length - inputRef.current.setSelectionRange(len, len) - } - }) - } - } - } - - // DEL 버튼 클릭 핸들러 - const handleDelete = () => { - const calculator = calculatorRef.current - const newValue = calculator.deleteNumber() - const displayValue = newValue || '0' - setDisplayValue(displayValue) - setHasOperation(!!calculator.operation) - onChange(displayValue) - // 포커스와 커서 위치 설정 - requestAnimationFrame(() => { - if (inputRef.current) { - inputRef.current.focus() - const len = displayValue.length - inputRef.current.setSelectionRange(len, len) - // Ensure focus is maintained - inputRef.current.focus() - } - }) - } - - // input의 onChange 이벤트 처리 - 허용된 계산기 입력만 처리 - const handleInputChange = (e) => { - if (readOnly) return - - const inputValue = e.target.value - - // 허용된 문자만 필터링 (숫자, 연산자, 소수점) - const filteredValue = inputValue.replace(/[^0-9+\-×÷.]/g, '') - - // 연산자 연속 입력 방지 - const lastChar = filteredValue[filteredValue.length - 1] - const prevChar = filteredValue[filteredValue.length - 2] - - if (['+', '×', '÷'].includes(lastChar) && ['+', '-', '×', '÷', '.'].includes(prevChar)) { - // 연산자나 소수점이 연속으로 입력된 경우 이전 문자 유지 - return - } - - // 소수점 중복 입력 방지 - const parts = filteredValue.split(/[+\-×÷]/) - if (parts[parts.length - 1].split('.').length > 2) { - // 한 숫자에 소수점이 2개 이상인 경우 - return - } - - setDisplayValue(filteredValue) - - // 계산기 상태 업데이트 - if (filteredValue !== displayValue) { + // AC 버튼 클릭 핸들러 + const handleClear = () => { const calculator = calculatorRef.current - const hasOperation = /[+\-×÷]/.test(filteredValue) - - if (hasOperation) { - const [operand1, operator, operand2] = filteredValue.split(/([+\-×÷])/) - calculator.previousOperand = operand1 || '' - calculator.operation = operator || '' - calculator.currentOperand = operand2 || '' - setHasOperation(true) - } else { - calculator.currentOperand = filteredValue + const newValue = calculator.clear() + const displayValue = newValue || '0' + setDisplayValue(displayValue) + setHasOperation(false) + onChange(displayValue) + // 커서를 텍스트 끝으로 이동하고 스크롤 처리 + requestAnimationFrame(() => { + if (inputRef.current) { + inputRef.current.focus() + const len = displayValue.length + inputRef.current.setSelectionRange(len, len) + // 텍스트 끝으로 스크롤 + inputRef.current.scrollLeft = inputRef.current.scrollWidth + } + }) + } + + // 계산 실행 함수 수정 + const handleCompute = (fromEnterKey = false) => { + const calculator = calculatorRef.current + if (!hasOperation || !calculator.currentOperand) return + + const result = calculator.compute() + if (result !== undefined) { + const resultStr = result.toString() + setDisplayValue(resultStr) setHasOperation(false) - } - - onChange(filteredValue) - } - } + // Only call onChange with the final result + onChange(resultStr) + + // 엔터키로 호출된 경우 포커스 설정하지 않음 + if (!fromEnterKey) { + requestAnimationFrame(() => { + if (inputRef.current) { + inputRef.current.focus() + const len = resultStr.length + inputRef.current.setSelectionRange(len, len) + } + }) + } + } + } + + // DEL 버튼 클릭 핸들러 + const handleDelete = () => { + const calculator = calculatorRef.current + const newValue = calculator.deleteNumber() + const displayValue = newValue || '0' + setDisplayValue(displayValue) + setHasOperation(!!calculator.operation) + onChange(displayValue) + // 커서를 텍스트 끝으로 이동하고 스크롤 처리 + requestAnimationFrame(() => { + if (inputRef.current) { + inputRef.current.focus() + const len = displayValue.length + inputRef.current.setSelectionRange(len, len) + // 텍스트 끝으로 스크롤 + inputRef.current.scrollLeft = inputRef.current.scrollWidth + } + }) + } + + // input의 onChange 이벤트 처리 - 허용된 계산기 입력만 처리 + const handleInputChange = (e) => { + if (readOnly) return + + const inputValue = e.target.value + + // 허용된 문자만 필터링 (숫자, 연산자, 소수점) + const filteredValue = inputValue.replace(/[^0-9+\-×÷.]/g, '') + + // 연산자 연속 입력 방지 + const lastChar = filteredValue[filteredValue.length - 1] + const prevChar = filteredValue[filteredValue.length - 2] + + if (['+', '×', '÷'].includes(lastChar) && ['+', '-', '×', '÷', '.'].includes(prevChar)) { + // 연산자나 소수점이 연속으로 입력된 경우 이전 문자 유지 + return + } + + // 소수점 중복 입력 방지 + const parts = filteredValue.split(/[+\-×÷]/) + if (parts[parts.length - 1].split('.').length > 2) { + // 한 숫자에 소수점이 2개 이상인 경우 + return + } + + setDisplayValue(filteredValue) + + // 계산기 상태 업데이트 + if (filteredValue !== displayValue) { + const calculator = calculatorRef.current + const hasOperation = /[+\-×÷]/.test(filteredValue) + + if (hasOperation) { + const [operand1, operator, operand2] = filteredValue.split(/([+\-×÷])/) + calculator.previousOperand = operand1 || '' + calculator.operation = operator || '' + calculator.currentOperand = operand2 || '' + setHasOperation(true) + } else { + calculator.currentOperand = filteredValue + setHasOperation(false) + } + + onChange(filteredValue) + } + } + + // 키패드 토글 함수 + const toggleKeypad = (e) => { + if (e) { + e.preventDefault() + e.stopPropagation() + } + const newShowKeypad = !showKeypad + setShowKeypad(newShowKeypad) + + // Show keypad 시에만 포커스 유지 + if (newShowKeypad) { + setTimeout(() => { + inputRef.current?.focus() + }, 0) + } + } + + // 키보드 이벤트 처리 수정 + const handleKeyDown = (e) => { + if (readOnly) return + + // Tab 키는 계산기 숨기고 기본 동작 허용 + if (e.key === 'Tab') { + setShowKeypad(false) + return + } + + // 모든 방향키는 기본 동작 허용 + if (e.key === 'ArrowLeft' || e.key === 'ArrowRight' || e.key === 'ArrowUp' || e.key === 'ArrowDown') { + setShowKeypad(true) + return + } + + // 계산기 허용 키들이 입력되면 키패드 표시 (엔터키 제외) + if (/^[0-9+\-×÷.=]$/.test(e.key) || e.key === 'Backspace' || e.key === 'Delete' || e.key === '*' || e.key === '/') { + setShowKeypad(true) + } + + // 키패드가 숨겨진 상태에서 엔터키는 페이지로 전달 + if (!showKeypad && e.key === 'Enter') { + return + } - // 키패드 토글 함수 - const toggleKeypad = (e) => { - if (e) { e.preventDefault() - e.stopPropagation() - } - const newShowKeypad = !showKeypad - setShowKeypad(newShowKeypad) - - // Show keypad 시에만 포커스 유지 - if (newShowKeypad) { - setTimeout(() => { - inputRef.current?.focus() - }, 0) - } - } + const calculator = calculatorRef.current + const { allowDecimal } = options - // 키보드 이벤트 처리 수정 - const handleKeyDown = (e) => { - if (readOnly) return + if (e.key === '.') { + // allowDecimal이 false이면 소수점 입력 무시 + if (!allowDecimal) return - // Tab 키는 계산기 숨기고 기본 동작 허용 - if (e.key === 'Tab') { - setShowKeypad(false) - return - } - - // 모든 방향키는 기본 동작 허용 - if (e.key === 'ArrowLeft' || e.key === 'ArrowRight' || e.key === 'ArrowUp' || e.key === 'ArrowDown') { - setShowKeypad(true) - return - } + // 소수점 입력 처리 + const currentValue = displayValue.toString() + const parts = currentValue.split(/[+\-×÷]/) + const lastPart = parts[parts.length - 1] - // 계산기 허용 키들이 입력되면 키패드 표시 (엔터키 제외) - if (/^[0-9+\-×÷.=]$/.test(e.key) || e.key === 'Backspace' || e.key === 'Delete' || e.key === '*' || e.key === '/') { - setShowKeypad(true) - } - - // 키패드가 숨겨진 상태에서 엔터키는 페이지로 전달 - if (!showKeypad && e.key === 'Enter') { - return - } - - e.preventDefault() - const calculator = calculatorRef.current - const { allowDecimal } = options - - if (e.key === '.') { - // allowDecimal이 false이면 소수점 입력 무시 - if (!allowDecimal) return - - // 소수점 입력 처리 - const currentValue = displayValue.toString() - const parts = currentValue.split(/[+\-×÷]/) - const lastPart = parts[parts.length - 1] - - // 이미 소수점이 있으면 무시 - if (!lastPart.includes('.')) { + // 이미 소수점이 있으면 무시 + if (!lastPart.includes('.')) { + handleNumber(e.key) + } + } else if (/^[0-9]$/.test(e.key)) { handleNumber(e.key) - } - } else if (/^[0-9]$/.test(e.key)) { - handleNumber(e.key) - } else { - switch (e.key) { - case '+': - case '-': - case '*': - case '/': - const opMap = { '*': '×', '/': '÷' } - handleOperation(opMap[e.key] || e.key) - break - case 'Enter': - case '=': - if (showKeypad) { - // 키패드가 보이는 상태에서 엔터키: 계산 후 키패드만 숨김 - handleCompute(true) // 엔터키로 호출됨을 표시 + } else { + switch (e.key) { + case '+': + case '-': + case '*': + case '/': + const opMap = { '*': '×', '/': '÷' } + handleOperation(opMap[e.key] || e.key) + break + case 'Enter': + case '=': + if (showKeypad) { + // 키패드가 보이는 상태에서 엔터키: 계산 후 키패드만 숨김 + handleCompute(true) // 엔터키로 호출됨을 표시 + setShowKeypad(false) + } + // 키패드가 숨겨진 상태에서 엔터키: 페이지로 전달 (preventDefault 하지 않음) + break + case 'Backspace': + case 'Delete': + handleDelete() + break + + case 'Escape': + handleClear() setShowKeypad(false) - } - // 키패드가 숨겨진 상태에서 엔터키: 페이지로 전달 (preventDefault 하지 않음) - break - case 'Backspace': - case 'Delete': - handleDelete() - break + break - case 'Escape': - handleClear() - setShowKeypad(false) - break - - default: - break + default: + break + } } } - } - return ( -
- {label && ( - - )} - !readOnly && setShowKeypad(true)} - onFocus={() => !readOnly && setShowKeypad(true)} - onChange={handleInputChange} - onKeyDown={handleKeyDown} - tabIndex={readOnly ? -1 : 0} - placeholder={placeholder} - autoComplete={'off'} - /> + return ( +
+ {label && ( + + )} + !readOnly && setShowKeypad(true)} + onFocus={() => !readOnly && setShowKeypad(true)} + onChange={handleInputChange} + onKeyDown={handleKeyDown} + tabIndex={readOnly ? -1 : 0} + placeholder={placeholder} + autoComplete={'off'} + /> - {showKeypad && !readOnly && ( -
-
- - - - - - - - - {/* 숫자 버튼 */} - {[1,2,3,4,5,6,7,8,9].map((num) => ( - - ))} - {/* 0 버튼 */} - - - {/* = 버튼 */} - + + + + + + + + {/* 숫자 버튼 */} + {[1, 2, 3, 4, 5, 6, 7, 8, 9].map((num) => ( + + ))} + {/* 0 버튼 */} + + + {/* = 버튼 */} + +
-
- )} -
- ) -}) + )} +
+ ) + }, +) CalculatorInput.displayName = 'CalculatorInput' diff --git a/src/styles/calc.scss b/src/styles/calc.scss index f3977c65..c80355d0 100644 --- a/src/styles/calc.scss +++ b/src/styles/calc.scss @@ -1,17 +1,17 @@ // Variables $colors: ( - 'dark-700': #1f2937, - 'dark-600': #334155, - 'dark-500': #4b5563, - 'dark-400': #6b7280, - 'dark-300': #374151, - 'primary': #10b981, - 'primary-dark': #059669, - 'danger': #ef4444, - 'danger-dark': #dc2626, - 'warning': #f59e0b, - 'warning-dark': #d97706, - 'border': #475569 + 'dark-700': #1f2937, + 'dark-600': #334155, + 'dark-500': #4b5563, + 'dark-400': #6b7280, + 'dark-300': #374151, + 'primary': #10b981, + 'primary-dark': #059669, + 'danger': #ef4444, + 'danger-dark': #dc2626, + 'warning': #f59e0b, + 'warning-dark': #d97706, + 'border': #475569, ); // Mixins @@ -46,10 +46,15 @@ $colors: ( color: white; font-weight: bold; border-radius: 0.5rem; - text-align: right; + text-align: left; // 왼쪽 정렬 cursor: pointer; font-size: 0.625rem; box-sizing: border-box; + direction: ltr; // 왼쪽에서 오른쪽으로 입력 + white-space: nowrap; // 줄바꿈 방지 + // input 요소에서는 overflow 대신 다른 방법 사용 + text-overflow: clip; // 텍스트 잘림 처리 + unicode-bidi: bidi-override; // 텍스트 방향 강제 &:focus { outline: none; @@ -67,7 +72,9 @@ $colors: ( width: 120px; background-color: map-get($colors, 'dark-700'); border-radius: 0.5rem; - box-shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.1), 0 2px 4px -1px rgba(0, 0, 0, 0.06); + box-shadow: + 0 4px 6px -1px rgba(0, 0, 0, 0.1), + 0 2px 4px -1px rgba(0, 0, 0, 0.06); padding: 0.5rem; z-index: 1000; animation: fadeIn 0.15s ease-out; @@ -153,4 +160,4 @@ $colors: ( padding: 0.5rem; font-size: 0.875rem; } -} \ No newline at end of file +} From d37b1911397c49f1d4ffa9b73a518f74aa424223 Mon Sep 17 00:00:00 2001 From: ysCha Date: Fri, 12 Sep 2025 10:43:13 +0900 Subject: [PATCH 05/20] =?UTF-8?q?[1216]=20=EB=AA=A8=EB=93=88=EC=97=90=20?= =?UTF-8?q?=EC=8B=9C=EB=A6=AC=EC=A6=88=20=EC=B6=94=EA=B0=80=20-=20?= =?UTF-8?q?=EC=8B=9C=EB=A6=AC=EC=A6=88=EA=B0=80=20=EC=A1=B4=EC=9E=AC?= =?UTF-8?q?=ED=95=98=EC=A7=80=20=EC=95=8A=EB=8A=94=20=EB=AA=A8=EB=93=88?= =?UTF-8?q?=EC=A1=B4=EC=9E=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../modal/basic/step/Orientation.jsx | 21 +++++++++++-------- 1 file changed, 12 insertions(+), 9 deletions(-) diff --git a/src/components/floor-plan/modal/basic/step/Orientation.jsx b/src/components/floor-plan/modal/basic/step/Orientation.jsx index c5427d3b..976e2cd1 100644 --- a/src/components/floor-plan/modal/basic/step/Orientation.jsx +++ b/src/components/floor-plan/modal/basic/step/Orientation.jsx @@ -71,6 +71,11 @@ export const Orientation = forwardRef((props, ref) => { ], } + const allOption = { + moduleSerCd: 'ALL', + moduleSerNm: getMessage("board.sub.total") || 'ALL' + }; + useEffect(() => { if (basicSetting.roofSizeSet == '3') { restoreModuleInstArea() @@ -95,6 +100,8 @@ export const Orientation = forwardRef((props, ref) => { if (currentSeries && (!selectedModuleSeries || selectedModuleSeries.moduleSerCd !== currentSeries.moduleSerCd)) { setSelectedModuleSeries(currentSeries) } + }else{ + setSelectedModuleSeries(allOption) } } } @@ -235,7 +242,8 @@ export const Orientation = forwardRef((props, ref) => { filtered = moduleList } else { // 특정 시리즈 선택 시 해당 시리즈 모듈만 표시 - filtered = moduleList.filter(module => module.moduleSerCd === e.moduleSerCd) + //filtered = moduleList.filter(module => module.moduleSerCd === e.moduleSerCd) + filtered = moduleList.filter(module => module && module.moduleSerCd && module.moduleSerCd === e.moduleSerCd) } setFilteredModuleList(filtered) @@ -324,11 +332,6 @@ export const Orientation = forwardRef((props, ref) => { }) // "전체" 옵션을 맨 앞에 추가 - const allOption = { - moduleSerCd: 'ALL', - moduleSerNm: getMessage("board.sub.total") || 'ALL' - } - const seriesList = [allOption, ...mappedSeries] setModuleSeriesList(seriesList) @@ -436,16 +439,16 @@ export const Orientation = forwardRef((props, ref) => {
{getMessage('modal.module.basic.setting.module.series.setting')}
- {moduleSeriesList.length > 0 && ( +
0 ? moduleSeriesList : [allOption]} value={selectedModuleSeries} targetKey={'moduleSerCd'} sourceKey={'moduleSerCd'} showKey={'moduleSerNm'} onChange={(e) => handleChangeModuleSeries(e)} /> - )} +
From f912a8474edc3e31f80a9a16be17b8159b1c79f5 Mon Sep 17 00:00:00 2001 From: ysCha Date: Fri, 12 Sep 2025 16:45:53 +0900 Subject: [PATCH 06/20] fetching error: TypeError: Cannot read properties of undefined (reading 'planNo') at eval (useRoofAllocationSetting.js:179:26) at async fetchBasicSettings (useRoofAllocationSetting.js:112:7) --- .../ContextRoofAllocationSetting.jsx | 7 +++-- .../roofAllocation/RoofAllocationSetting.jsx | 6 ++--- .../roofcover/useRoofAllocationSetting.js | 26 +++++++++++++------ 3 files changed, 24 insertions(+), 15 deletions(-) diff --git a/src/components/floor-plan/modal/roofAllocation/ContextRoofAllocationSetting.jsx b/src/components/floor-plan/modal/roofAllocation/ContextRoofAllocationSetting.jsx index 2ce64be7..78597844 100644 --- a/src/components/floor-plan/modal/roofAllocation/ContextRoofAllocationSetting.jsx +++ b/src/components/floor-plan/modal/roofAllocation/ContextRoofAllocationSetting.jsx @@ -86,7 +86,7 @@ export default function ContextRoofAllocationSetting(props) { return (
- +
{pitchText} diff --git a/src/components/floor-plan/modal/roofAllocation/RoofAllocationSetting.jsx b/src/components/floor-plan/modal/roofAllocation/RoofAllocationSetting.jsx index 32364844..0e0e09ee 100644 --- a/src/components/floor-plan/modal/roofAllocation/RoofAllocationSetting.jsx +++ b/src/components/floor-plan/modal/roofAllocation/RoofAllocationSetting.jsx @@ -86,7 +86,7 @@ export default function RoofAllocationSetting(props) { return (
- +
@@ -212,7 +212,7 @@ export default function RoofAllocationSetting(props) { e.target.value = normalizeDecimalLimit(e.target.value, 2) handleChangePitch(e, index) }} - value={currentAngleType === 'slope' ? roof.pitch : roof.angle} + value={currentAngleType === 'slope' ? (roof.pitch ?? '') : (roof.angle ?? '')} />
{pitchText} diff --git a/src/hooks/roofcover/useRoofAllocationSetting.js b/src/hooks/roofcover/useRoofAllocationSetting.js index 3fef0ee9..e6d7a825 100644 --- a/src/hooks/roofcover/useRoofAllocationSetting.js +++ b/src/hooks/roofcover/useRoofAllocationSetting.js @@ -174,22 +174,32 @@ export function useRoofAllocationSetting(id) { }) } + const firstRes = Array.isArray(res) && res.length > 0 ? res[0] : null + setBasicSetting({ ...basicSetting, - planNo: res[0].planNo, - roofSizeSet: res[0].roofSizeSet, - roofAngleSet: res[0].roofAngleSet, + planNo: firstRes?.planNo ?? planNo, + roofSizeSet: firstRes?.roofSizeSet ?? 0, + roofAngleSet: firstRes?.roofAngleSet ?? 0, roofsData: roofsArray, selectedRoofMaterial: selectRoofs.find((roof) => roof.selected), }) setBasicInfo({ - planNo: '' + res[0].planNo, - roofSizeSet: '' + res[0].roofSizeSet, - roofAngleSet: '' + res[0].roofAngleSet, + planNo: '' + (firstRes?.planNo ?? planNo), + roofSizeSet: '' + (firstRes?.roofSizeSet ?? 0), + roofAngleSet: '' + (firstRes?.roofAngleSet ?? 0), }) - //데이터 동기화 - setCurrentRoofList(selectRoofs) + // 데이터 동기화: 렌더링용 필드 기본값 보정 + 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) { console.error('Data fetching error:', error) From 36d16069e08ee24d72c8b2e29f669439deefccec Mon Sep 17 00:00:00 2001 From: Cha Date: Sun, 14 Sep 2025 01:09:03 +0900 Subject: [PATCH 07/20] skeleton-utils --- src/util/skeleton-utils.js | 229 +++++++++++++++++++++++++++++++++++++ 1 file changed, 229 insertions(+) create mode 100644 src/util/skeleton-utils.js diff --git a/src/util/skeleton-utils.js b/src/util/skeleton-utils.js new file mode 100644 index 00000000..6ec643a4 --- /dev/null +++ b/src/util/skeleton-utils.js @@ -0,0 +1,229 @@ +/** + * @file skeleton-utils.js + * @description 지붕 폴리곤의 스켈레톤(중심선)을 생성하고 Fabric.js 캔버스에 그리는 유틸리티 함수들을 포함합니다. + */ + +import SkeletonBuilder from '@/lib/skeletons/SkeletonBuilder'; +import { fabric } from 'fabric'; +import { LINE_TYPE } from '@/common/common'; +import { QLine } from '@/components/fabric/QLine'; +import { calcLinePlaneSize } from '@/util/qpolygon-utils'; + +/** + * 지붕 폴리곤의 좌표를 스켈레톤 생성에 적합한 형태로 전처리합니다. + * - 연속된 중복 좌표를 제거합니다. + * - 폴리곤이 닫힌 형태가 되도록 마지막 좌표를 확인하고 필요시 제거합니다. + * - 좌표를 시계 방향으로 정렬합니다. + * @param {Array} initialPoints - 초기 폴리곤 좌표 배열 (e.g., [{x: 10, y: 10}, ...]) + * @returns {Array>} 전처리된 좌표 배열 (e.g., [[10, 10], ...]) + */ +const preprocessPolygonCoordinates = (initialPoints) => { + // fabric.Point 객체를 [x, y] 배열로 변환 + let coordinates = initialPoints.map(point => [point.x, point.y]); + + // 연속된 중복 좌표 제거 + coordinates = coordinates.filter((coord, index) => { + if (index === 0) return true; + const prev = coordinates[index - 1]; + return !(coord[0] === prev[0] && coord[1] === prev[1]); + }); + + // 폴리곤의 첫 점과 마지막 점이 동일하면 마지막 점을 제거하여 닫힌 구조 보장 + if (coordinates.length > 1 && + coordinates[0][0] === coordinates[coordinates.length - 1][0] && + coordinates[0][1] === coordinates[coordinates.length - 1][1]) { + coordinates.pop(); + } + + // SkeletonBuilder 라이브러리는 시계 방향 순서의 좌표를 요구하므로 배열을 뒤집습니다. + coordinates.reverse(); + + return coordinates; +}; + +/** + * 생성된 스켈레톤 엣지들로부터 중복되지 않는 고유한 선분 목록을 추출합니다. + * 각 엣지는 작은 폴리곤으로 구성되며, 인접한 엣지들은 선분을 공유하므로 중복 제거가 필요합니다. + * @param {Array} skeletonEdges - SkeletonBuilder로부터 반환된 엣지 배열 + * @returns {Array} 고유한 선분 객체의 배열 (e.g., [{x1, y1, x2, y2, edgeIndex}, ...]) + */ +const extractUniqueLinesFromEdges = (skeletonEdges) => { + const uniqueLines = new Set(); + const linesToDraw = []; + + skeletonEdges.forEach((edge, edgeIndex) => { + // 엣지 데이터가 유효한 폴리곤인지 확인 + if (!edge || !edge.Polygon || edge.Polygon.length < 2) { + console.warn(`Edge ${edgeIndex} has invalid polygon data:`, edge.Polygon); + return; + } + + // 폴리곤의 각 변을 선분으로 변환 + for (let i = 0; i < edge.Polygon.length; i++) { + const p1 = edge.Polygon[i]; + const p2 = edge.Polygon[(i + 1) % edge.Polygon.length]; // 다음 점 (마지막 점은 첫 점과 연결) + + // 선분의 시작점과 끝점을 일관된 순서로 정렬하여 정규화된 문자열 키 생성 + // 이를 통해 동일한 선분(방향만 다른 경우 포함)을 식별하고 중복을 방지 + const normalizedLineKey = p1.X < p2.X || (p1.X === p2.X && p1.Y < p2.Y) + ? `${p1.X},${p1.Y}-${p2.X},${p2.Y}` + : `${p2.X},${p2.Y}-${p1.X},${p1.Y}`; + + // Set에 정규화된 키가 없으면 새로운 선분으로 간주하고 추가 + if (!uniqueLines.has(normalizedLineKey)) { + uniqueLines.add(normalizedLineKey); + linesToDraw.push({ + x1: p1.X, y1: p1.Y, + x2: p2.X, y2: p2.Y, + edgeIndex + }); + } + } + }); + + return linesToDraw; +}; + +/** + * 지붕 폴리곤의 스켈레톤(중심선)을 생성하고 캔버스에 그립니다. + * @param {string} roofId - 대상 지붕 객체의 ID + * @param {fabric.Canvas} canvas - Fabric.js 캔버스 객체 + * @param {string} textMode - 텍스트 표시 모드 + */ +export const drawSkeletonRidgeRoof = (roofId, canvas, textMode) => { + try { + const roof = canvas?.getObjects().find((object) => object.id === roofId); + if (!roof) { + console.error(`Roof with id "${roofId}" not found.`); + return; + } + + // 1. 기존 스켈레톤 라인 제거 + const existingSkeletonLines = canvas.getObjects().filter(obj => + obj.parentId === roofId && obj.attributes?.type === 'skeleton' + ); + existingSkeletonLines.forEach(line => canvas.remove(line)); + + // 2. 지붕 폴리곤 좌표 전처리 + const coordinates = preprocessPolygonCoordinates(roof.points); + if (coordinates.length < 3) { + console.warn("Polygon has less than 3 unique points. Cannot generate skeleton."); + return; + } + + // 3. 스켈레톤 생성 + const multiPolygon = [[coordinates]]; // GeoJSON MultiPolygon 형식 + const skeleton = SkeletonBuilder.BuildFromGeoJSON(multiPolygon); + + if (!skeleton || !skeleton.Edges || skeleton.Edges.length === 0) { + console.log('No valid skeleton edges found for this roof.'); + return; + } + + // 4. 스켈레톤 엣지에서 고유 선분 추출 + const linesToDraw = extractUniqueLinesFromEdges(skeleton.Edges); + + // 5. 캔버스에 스켈레톤 라인 렌더링 + const skeletonLines = []; + const outerLines = pointsToLines(coordinates); + + linesToDraw.forEach((line, index) => { + // 외곽선과 겹치는 스켈레톤 라인은 그리지 않음 + const isOverlapping = outerLines.some(outerLine => linesOverlap(line, outerLine)); + if (isOverlapping) { + console.log(`Skeleton line (edge ${line.edgeIndex}) is overlapping with a baseLine. It will not be drawn.`); + return; + } + + const isDiagonal = Math.abs(line.x1 - line.x2) > 1e-6 && Math.abs(line.y1 - line.y2) > 1e-6; + + const skeletonLine = new QLine([line.x1, line.y1, line.x2, line.y2], { + parentId: roofId, + stroke: '#ff0000', // 스켈레톤은 빨간색으로 표시 + strokeWidth: 2, + strokeDashArray: [3, 3], // 점선으로 표시 + name: isDiagonal ? LINE_TYPE.SUBLINE.HIP : LINE_TYPE.SUBLINE.RIDGE, + fontSize: roof.fontSize || 12, + textMode: textMode, + attributes: { + roofId: roofId, + type: 'skeleton', // 스켈레톤 타입 식별자 + skeletonIndex: line.edgeIndex, + lineIndex: index, + planeSize: calcLinePlaneSize(line), + actualSize: calcLinePlaneSize(line), // 실제 크기는 필요시 별도 계산 로직 추가 + }, + }); + + skeletonLine.startPoint = { x: line.x1, y: line.y1 }; + skeletonLine.endPoint = { x: line.x2, y: line.y2 }; + + skeletonLines.push(skeletonLine); + canvas.add(skeletonLine); + }); + + // 6. roof 객체에 스켈레톤 라인 정보 업데이트 + roof.innerLines = [...(roof.innerLines || []), ...skeletonLines]; + skeletonLines.forEach(line => line.bringToFront()); + + canvas.renderAll(); + console.log(`Successfully drew ${linesToDraw.length} unique skeleton lines from ${skeleton.Edges.length} polygons.`); + + } catch (error) { + console.error('An error occurred while generating the skeleton:', error); + } +}; + +/** + * 두 선분이 동일 선상에 있으면서 서로 겹치는지 확인합니다. + * @param {object} line1 - 첫 번째 선분 객체 { x1, y1, x2, y2 } + * @param {object} line2 - 두 번째 선분 객체 { x1, y1, x2, y2 } + * @param {number} [epsilon=1e-6] - 부동 소수점 계산 오차 허용 범위 + * @returns {boolean} 동일 선상에서 겹치면 true, 아니면 false + */ +function linesOverlap(line1, line2, epsilon = 1e-6) { + // 1. 세 점의 면적 계산을 통해 동일 선상에 있는지 확인 (면적이 0에 가까우면 동일 선상) + const area1 = (line1.x2 - line1.x1) * (line2.y1 - line1.y1) - (line2.x1 - line1.x1) * (line1.y2 - line1.y1); + const area2 = (line1.x2 - line1.x1) * (line2.y2 - line1.y1) - (line2.x2 - line1.x1) * (line1.y2 - line1.y1); + + if (Math.abs(area1) > epsilon || Math.abs(area2) > epsilon) { + return false; // 동일 선상에 없음 + } + + // 2. 동일 선상에 있을 경우, 두 선분의 범위(x, y)가 겹치는지 확인 + const xOverlap = Math.max(line1.x1, line1.x2) >= Math.min(line2.x1, line2.x2) && + Math.max(line2.x1, line2.x2) >= Math.min(line1.x1, line1.x2); + + const yOverlap = Math.max(line1.y1, line1.y2) >= Math.min(line2.y1, line2.y2) && + Math.max(line2.y1, line2.y2) >= Math.min(line1.y1, line1.y2); + + return xOverlap && yOverlap; +} + +/** + * 점들의 배열을 닫힌 폴리곤을 구성하는 선분 객체의 배열로 변환합니다. + * @param {Array>} points - [x, y] 형태의 점 좌표 배열 + * @returns {Array<{x1: number, y1: number, x2: number, y2: number}>} 선분 객체 배열 + */ +function pointsToLines(points) { + if (!points || points.length < 2) { + return []; + } + + const lines = []; + const numPoints = points.length; + + for (let i = 0; i < numPoints; i++) { + const startPoint = points[i]; + const endPoint = points[(i + 1) % numPoints]; // 마지막 점은 첫 번째 점과 연결 + + lines.push({ + x1: startPoint[0], + y1: startPoint[1], + x2: endPoint[0], + y2: endPoint[1], + }); + } + + return lines; +} From 7d9b6d5225ed3f297cb5ce5d91ae700c4d278593 Mon Sep 17 00:00:00 2001 From: "hyojun.choi" Date: Mon, 15 Sep 2025 14:55:04 +0900 Subject: [PATCH 08/20] =?UTF-8?q?object=20=ED=9A=8C=EC=A0=84=20=EA=B8=B0?= =?UTF-8?q?=EB=8A=A5=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/common/common.js | 2 + src/components/fabric/QPolygon.js | 6 + .../floor-plan/modal/object/SizeSetting.jsx | 10 +- src/hooks/surface/useSurfaceShapeBatch.js | 236 +++++++++++++----- src/hooks/useContextMenu.js | 17 +- src/locales/ja.json | 1 + src/locales/ko.json | 1 + 7 files changed, 203 insertions(+), 70 deletions(-) diff --git a/src/common/common.js b/src/common/common.js index 9d15e988..757dc0e8 100644 --- a/src/common/common.js +++ b/src/common/common.js @@ -216,6 +216,8 @@ export const SAVE_KEY = [ 'isMultipleOf45', 'from', 'originColor', + 'originWidth', + 'originHeight', ] export const OBJECT_PROTOTYPE = [fabric.Line.prototype, fabric.Polygon.prototype, fabric.Triangle.prototype, fabric.Group.prototype] diff --git a/src/components/fabric/QPolygon.js b/src/components/fabric/QPolygon.js index b50cdaa3..73bd6fd9 100644 --- a/src/components/fabric/QPolygon.js +++ b/src/components/fabric/QPolygon.js @@ -87,6 +87,10 @@ export const QPolygon = fabric.util.createClass(fabric.Polygon, { this.initLines() this.init() this.setShape() + const originWidth = this.originWidth ?? this.width + const originHeight = this.originHeight ?? this.height + this.originWidth = this.angle === 90 || this.angle === 270 ? originHeight : originWidth + this.originHeight = this.angle === 90 || this.angle === 270 ? originWidth : originHeight }, setShape() { @@ -126,11 +130,13 @@ export const QPolygon = fabric.util.createClass(fabric.Polygon, { this.on('moving', () => { this.initLines() this.addLengthText() + this.setCoords() }) this.on('modified', (e) => { this.initLines() this.addLengthText() + this.setCoords() }) this.on('selected', () => { diff --git a/src/components/floor-plan/modal/object/SizeSetting.jsx b/src/components/floor-plan/modal/object/SizeSetting.jsx index c5873006..b4a7d3e7 100644 --- a/src/components/floor-plan/modal/object/SizeSetting.jsx +++ b/src/components/floor-plan/modal/object/SizeSetting.jsx @@ -12,7 +12,7 @@ import { useSurfaceShapeBatch } from '@/hooks/surface/useSurfaceShapeBatch' export default function SizeSetting(props) { const contextPopupPosition = useRecoilValue(contextPopupPositionState) - const [settingTarget, setSettingTarget] = useState(1) + const [settingTarget, setSettingTarget] = useState(props.side || 1) const { id, pos = contextPopupPosition, target } = props const { getMessage } = useMessage() const { closePopup } = usePopup() @@ -47,11 +47,11 @@ export default function SizeSetting(props) {
- + mm
- + mm
@@ -60,11 +60,11 @@ export default function SizeSetting(props) {
- + mm
- + mm
diff --git a/src/hooks/surface/useSurfaceShapeBatch.js b/src/hooks/surface/useSurfaceShapeBatch.js index 874526f8..1d0445d8 100644 --- a/src/hooks/surface/useSurfaceShapeBatch.js +++ b/src/hooks/surface/useSurfaceShapeBatch.js @@ -1,9 +1,9 @@ 'use client' import { useRecoilValue, useResetRecoilState } from 'recoil' -import { canvasSettingState, canvasState, currentCanvasPlanState, globalPitchState } from '@/store/canvasAtom' +import { canvasSettingState, canvasState, currentCanvasPlanState, currentObjectState, globalPitchState } from '@/store/canvasAtom' import { LINE_TYPE, MENU, POLYGON_TYPE } from '@/common/common' -import { getIntersectionPoint, toFixedWithoutRounding } from '@/util/canvas-util' +import { getIntersectionPoint } from '@/util/canvas-util' import { degreesToRadians } from '@turf/turf' import { QPolygon } from '@/components/fabric/QPolygon' import { useSwal } from '@/hooks/useSwal' @@ -21,10 +21,13 @@ import { placementShapeDrawingPointsState } from '@/store/placementShapeDrawingA import { getBackGroundImage } from '@/lib/imageActions' import { useCanvasSetting } from '@/hooks/option/useCanvasSetting' import { useText } from '@/hooks/useText' +import SizeSetting from '@/components/floor-plan/modal/object/SizeSetting' +import { v4 as uuidv4 } from 'uuid' +import { useState } from 'react' export function useSurfaceShapeBatch({ isHidden, setIsHidden }) { const { getMessage } = useMessage() - const { drawDirectionArrow, addPolygon, addLengthText } = usePolygon() + const { drawDirectionArrow, addPolygon, addLengthText, setPolygonLinesActualSize } = usePolygon() const lengthTextFont = useRecoilValue(fontSelector('lengthText')) const resetOuterLinePoints = useResetRecoilState(outerLinePointsState) const resetPlacementShapeDrawingPoints = useResetRecoilState(placementShapeDrawingPointsState) @@ -36,11 +39,13 @@ export function useSurfaceShapeBatch({ isHidden, setIsHidden }) { const { swalFire } = useSwal() const { addCanvasMouseEventListener, initEvent } = useEvent() // const { addCanvasMouseEventListener, initEvent } = useContext(EventContext) - const { addPopup, closePopup } = usePopup() + const { addPopup, closePopup, closeAll } = usePopup() const { setSurfaceShapePattern } = useRoofFn() const { changeCorridorDimensionText } = useText() const currentCanvasPlan = useRecoilValue(currentCanvasPlanState) const { fetchSettings } = useCanvasSetting(false) + const currentObject = useRecoilValue(currentObjectState) + const [popupId, setPopupId] = useState(uuidv4()) const applySurfaceShape = (surfaceRefs, selectedType, id) => { let length1, length2, length3, length4, length5 @@ -879,6 +884,7 @@ export function useSurfaceShapeBatch({ isHidden, setIsHidden }) { drawDirectionArrow(roof) changeCorridorDimensionText() addLengthText(roof) + roof.setCoords() initEvent() canvas.renderAll() }) @@ -916,71 +922,138 @@ export function useSurfaceShapeBatch({ isHidden, setIsHidden }) { } const resizeSurfaceShapeBatch = (side, target, width, height) => { - const originTarget = { ...target } + if (!target || target.type !== 'QPolygon') return - const objectWidth = target.width - const objectHeight = target.height - const changeWidth = width / 10 / objectWidth - const changeHeight = height / 10 / objectHeight - let sideX = 'left' - let sideY = 'top' + width = width / 10 + height = height / 10 - //그룹 중심점 변경 - if (side === 2) { - sideX = 'right' - sideY = 'top' - } else if (side === 3) { - sideX = 'left' - sideY = 'bottom' - } else if (side === 4) { - sideX = 'right' - sideY = 'bottom' + // 현재 QPolygon의 점들 가져오기 (변형 적용된 실제 좌표) + const currentPoints = target.getCurrentPoints() || [] + const angle = target.angle % 360 + if (currentPoints.length === 0) return + + // 현재 바운딩 박스 계산 + let minX = Math.min(...currentPoints.map((p) => p.x)) + let maxX = Math.max(...currentPoints.map((p) => p.x)) + let minY = Math.min(...currentPoints.map((p) => p.y)) + let maxY = Math.max(...currentPoints.map((p) => p.y)) + + let currentWidth = maxX - minX + let currentHeight = maxY - minY + + // 회전에 관계없이 단순한 앵커 포인트 계산 + let anchorX, anchorY + switch (side) { + case 1: // left-top + anchorX = minX + anchorY = minY + break + case 2: // right-top + anchorX = maxX + anchorY = minY + break + case 3: // left-bottom + anchorX = minX + anchorY = maxY + break + case 4: // right-bottom + anchorX = maxX + anchorY = maxY + break + default: + return } - //변경 전 좌표 - const newCoords = target.getPointByOrigin(sideX, sideY) + // 목표 크기 (회전에 관계없이 동일하게 적용) + let targetWidth = width + let targetHeight = height - target.set({ - originX: sideX, - originY: sideY, - left: newCoords.x, - top: newCoords.y, + // 새로운 점들 계산 - 앵커 포인트는 고정, 나머지는 비례적으로 확장 + // 각도와 side에 따라 확장 방향 결정 + const newPoints = currentPoints.map((point) => { + // 앵커 포인트 기준으로 새로운 위치 계산 + // side와 각도에 관계없이 일관된 방식으로 처리 + + // 앵커 포인트에서 각 점까지의 절대 거리 + const deltaX = point.x - anchorX + const deltaY = point.y - anchorY + + // 새로운 크기에 맞춰 비례적으로 확장 + const newDeltaX = (deltaX / currentWidth) * targetWidth + const newDeltaY = (deltaY / currentHeight) * targetHeight + + const newX = anchorX + newDeltaX + const newY = anchorY + newDeltaY + + return { + x: newX, // 소수점 1자리로 반올림 + y: newY, + } }) - target.scaleX = changeWidth - target.scaleY = changeHeight - - const currentPoints = target.getCurrentPoints() - - target.set({ + // 기존 객체의 속성들을 복사 (scale은 1로 고정) + const originalOptions = { + stroke: target.stroke, + strokeWidth: target.strokeWidth, + fill: target.fill, + opacity: target.opacity, + visible: target.visible, + selectable: target.selectable, + evented: target.evented, + hoverCursor: target.hoverCursor, + moveCursor: target.moveCursor, + lockMovementX: target.lockMovementX, + lockMovementY: target.lockMovementY, + lockRotation: target.lockRotation, + lockScalingX: target.lockScalingX, + lockScalingY: target.lockScalingY, + lockUniScaling: target.lockUniScaling, + name: target.name, + uuid: target.uuid, + roofType: target.roofType, + roofMaterial: target.roofMaterial, + azimuth: target.azimuth, + // tilt: target.tilt, + // angle: target.angle, + // scale은 항상 1로 고정 scaleX: 1, scaleY: 1, - width: toFixedWithoutRounding(width / 10, 1), - height: toFixedWithoutRounding(height / 10, 1), - }) - //크기 변경후 좌표를 재 적용 - const changedCoords = target.getPointByOrigin(originTarget.originX, originTarget.originY) - - target.set({ - originX: originTarget.originX, - originY: originTarget.originY, - left: changedCoords.x, - top: changedCoords.y, - }) - canvas.renderAll() - - //면형상 리사이즈시에만 - target.fire('polygonMoved') - target.points = currentPoints - target.fire('modified') - - setSurfaceShapePattern(target, roofDisplay.column, false, target.roofMaterial, true) - - if (target.direction) { - drawDirectionArrow(target) + lines: target.lines, + // 기타 모든 사용자 정의 속성들 + ...Object.fromEntries( + Object.entries(target).filter( + ([key, value]) => + !['type', 'left', 'top', 'width', 'height', 'scaleX', 'scaleY', 'points', 'lines', 'texts', 'canvas', 'angle', 'tilt'].includes(key) && + typeof value !== 'function', + ), + ), } - target.setCoords() - canvas.renderAll() + + // 기존 QPolygon 제거 + canvas.remove(target) + + // 새로운 QPolygon 생성 (scale은 1로 고정됨) + const newPolygon = new QPolygon(newPoints, originalOptions, canvas) + + newPolygon.set({ + originWidth: width, + originHeight: height, + }) + + // 캔버스에 추가 + canvas.add(newPolygon) + + // 선택 상태 유지 + canvas.setActiveObject(newPolygon) + + newPolygon.fire('modified') + setSurfaceShapePattern(newPolygon, null, null, newPolygon.roofMaterial) + drawDirectionArrow(newPolygon) + newPolygon.setCoords() + changeSurfaceLineType(newPolygon) + canvas?.renderAll() + closeAll() + addPopup(popupId, 1, ) } const changeSurfaceLinePropertyEvent = () => { @@ -1364,6 +1437,50 @@ export function useSurfaceShapeBatch({ isHidden, setIsHidden }) { return orderedPoints } + const rotateSurfaceShapeBatch = () => { + if (currentObject) { + // 기존 관련 객체들 제거 + const relatedObjects = canvas + .getObjects() + .filter( + (obj) => + obj.parentId === currentObject.id || + (obj.name === 'lengthText' && obj.parentId === currentObject.id) || + (obj.name === 'arrow' && obj.parentId === currentObject.id), + ) + relatedObjects.forEach((obj) => canvas.remove(obj)) + + // 현재 회전값에 90도 추가 + const currentAngle = currentObject.angle || 0 + const newAngle = (currentAngle + 90) % 360 + const originWidth = currentObject.originWidth + const originHeight = currentObject.originHeight + // 회전 적용 (width/height 교체 제거로 도형 깨짐 방지) + currentObject.rotate(newAngle) + + // QPolygon 내부 구조 재구성 (선이 깨지는 문제 해결) + if (currentObject.type === 'QPolygon' && currentObject.lines) { + currentObject.initLines() + } + + currentObject.set({ + originWidth: originHeight, + originHeight: originWidth, + }) + + currentObject.setCoords() + currentObject.fire('modified') + + // 화살표와 선 다시 그리기 + drawDirectionArrow(currentObject) + setTimeout(() => { + setPolygonLinesActualSize(currentObject) + changeSurfaceLineType(currentObject) + }, 200) + canvas.renderAll() + } + } + return { applySurfaceShape, deleteAllSurfacesAndObjects, @@ -1373,5 +1490,6 @@ export function useSurfaceShapeBatch({ isHidden, setIsHidden }) { changeSurfaceLineProperty, changeSurfaceLinePropertyReset, changeSurfaceLineType, + rotateSurfaceShapeBatch, } } diff --git a/src/hooks/useContextMenu.js b/src/hooks/useContextMenu.js index fe6d800e..4580f3ee 100644 --- a/src/hooks/useContextMenu.js +++ b/src/hooks/useContextMenu.js @@ -1,7 +1,6 @@ import { useRecoilState, useRecoilValue, useSetRecoilState } from 'recoil' import { canvasSettingState, canvasState, currentMenuState, currentObjectState } from '@/store/canvasAtom' import { useEffect, useState } from 'react' -import { MENU, POLYGON_TYPE } from '@/common/common' import AuxiliarySize from '@/components/floor-plan/modal/auxiliary/AuxiliarySize' import { usePopup } from '@/hooks/usePopup' import { v4 as uuidv4 } from 'uuid' @@ -12,11 +11,9 @@ import { gridColorState } from '@/store/gridAtom' import { contextPopupPositionState, contextPopupState } from '@/store/popupAtom' import AuxiliaryEdit from '@/components/floor-plan/modal/auxiliary/AuxiliaryEdit' import SizeSetting from '@/components/floor-plan/modal/object/SizeSetting' -import RoofMaterialSetting from '@/components/floor-plan/modal/object/RoofMaterialSetting' import DormerOffset from '@/components/floor-plan/modal/object/DormerOffset' import FontSetting from '@/components/common/font/FontSetting' import RoofAllocationSetting from '@/components/floor-plan/modal/roofAllocation/RoofAllocationSetting' -import LinePropertySetting from '@/components/floor-plan/modal/lineProperty/LinePropertySetting' import FlowDirectionSetting from '@/components/floor-plan/modal/flowDirection/FlowDirectionSetting' import { useCommonUtils } from './common/useCommonUtils' @@ -29,7 +26,6 @@ import ColumnRemove from '@/components/floor-plan/modal/module/column/ColumnRemo import ColumnInsert from '@/components/floor-plan/modal/module/column/ColumnInsert' import RowRemove from '@/components/floor-plan/modal/module/row/RowRemove' import RowInsert from '@/components/floor-plan/modal/module/row/RowInsert' -import CircuitNumberEdit from '@/components/floor-plan/modal/module/CircuitNumberEdit' import { useObjectBatch } from '@/hooks/object/useObjectBatch' import { useSurfaceShapeBatch } from '@/hooks/surface/useSurfaceShapeBatch' import { fontSelector, globalFontAtom } from '@/store/fontAtom' @@ -45,6 +41,8 @@ import PlacementSurfaceLineProperty from '@/components/floor-plan/modal/placemen import { selectedMenuState } from '@/store/menuAtom' import { useTrestle } from './module/useTrestle' import { useCircuitTrestle } from './useCirCuitTrestle' +import { usePolygon } from '@/hooks/usePolygon' +import { useText } from '@/hooks/useText' export function useContextMenu() { const canvas = useRecoilValue(canvasState) @@ -64,7 +62,7 @@ export function useContextMenu() { const [column, setColumn] = useState(null) const { handleZoomClear } = useCanvasEvent() const { moveObjectBatch, copyObjectBatch } = useObjectBatch({}) - const { moveSurfaceShapeBatch } = useSurfaceShapeBatch({}) + const { moveSurfaceShapeBatch, rotateSurfaceShapeBatch } = useSurfaceShapeBatch({}) const [globalFont, setGlobalFont] = useRecoilState(globalFontAtom) const { addLine, removeLine } = useLine() const { removeGrid } = useGrid() @@ -73,10 +71,12 @@ export function useContextMenu() { const { settingsData, setSettingsDataSave } = useCanvasSetting(false) const { swalFire } = useSwal() const { alignModule, modulesRemove, moduleRoofRemove } = useModule() - const { removeRoofMaterial, removeAllRoofMaterial, moveRoofMaterial, removeOuterLines } = useRoofFn() + const { removeRoofMaterial, removeAllRoofMaterial, moveRoofMaterial, removeOuterLines, setSurfaceShapePattern } = useRoofFn() const selectedMenu = useRecoilValue(selectedMenuState) const { isAllComplete, clear: resetModule } = useTrestle() const { isExistCircuit } = useCircuitTrestle() + const { changeCorridorDimensionText } = useText() + const { setPolygonLinesActualSize, drawDirectionArrow } = usePolygon() const currentMenuSetting = () => { switch (selectedMenu) { case 'outline': @@ -170,6 +170,11 @@ export function useContextMenu() { name: getMessage('contextmenu.size.edit'), component: , }, + { + id: 'rotate', + name: `${getMessage('contextmenu.rotate')}`, + fn: () => rotateSurfaceShapeBatch(), + }, { id: 'roofMaterialRemove', shortcut: ['d', 'D'], diff --git a/src/locales/ja.json b/src/locales/ja.json index ac682266..2999728f 100644 --- a/src/locales/ja.json +++ b/src/locales/ja.json @@ -446,6 +446,7 @@ "contextmenu.remove": "削除", "contextmenu.remove.all": "完全削除", "contextmenu.move": "移動", + "contextmenu.rotate": "回転", "contextmenu.copy": "コピー", "contextmenu.edit": "編集", "contextmenu.module.vertical.align": "モジュールの垂直方向の中央揃え", diff --git a/src/locales/ko.json b/src/locales/ko.json index 0559b28e..4f6601cd 100644 --- a/src/locales/ko.json +++ b/src/locales/ko.json @@ -446,6 +446,7 @@ "contextmenu.remove": "삭제", "contextmenu.remove.all": "전체 삭제", "contextmenu.move": "이동", + "contextmenu.rotate": "회전", "contextmenu.copy": "복사", "contextmenu.edit": "편집", "contextmenu.module.vertical.align": "모듈 세로 가운데 정렬", From 99c7759e00c9597098786769606c49404046476d Mon Sep 17 00:00:00 2001 From: Cha Date: Tue, 16 Sep 2025 00:14:03 +0900 Subject: [PATCH 09/20] skeleton-utils --- src/util/skeleton-utils.js | 89 ++++++++++++++++++++++++++++++++++---- 1 file changed, 81 insertions(+), 8 deletions(-) diff --git a/src/util/skeleton-utils.js b/src/util/skeleton-utils.js index 6ec643a4..77ce3412 100644 --- a/src/util/skeleton-utils.js +++ b/src/util/skeleton-utils.js @@ -4,8 +4,8 @@ */ import SkeletonBuilder from '@/lib/skeletons/SkeletonBuilder'; -import { fabric } from 'fabric'; -import { LINE_TYPE } from '@/common/common'; +import { fabric } from 'fabric' +import { LINE_TYPE, POLYGON_TYPE } from '@/common/common' import { QLine } from '@/components/fabric/QLine'; import { calcLinePlaneSize } from '@/util/qpolygon-utils'; @@ -93,11 +93,14 @@ const extractUniqueLinesFromEdges = (skeletonEdges) => { export const drawSkeletonRidgeRoof = (roofId, canvas, textMode) => { try { const roof = canvas?.getObjects().find((object) => object.id === roofId); + const wall = canvas.getObjects().find((object) => object.name === POLYGON_TYPE.WALL && object.attributes.roofId === roofId) + if (!roof) { console.error(`Roof with id "${roofId}" not found.`); return; } + // 1. 기존 스켈레톤 라인 제거 const existingSkeletonLines = canvas.getObjects().filter(obj => obj.parentId === roofId && obj.attributes?.type === 'skeleton' @@ -111,6 +114,11 @@ export const drawSkeletonRidgeRoof = (roofId, canvas, textMode) => { return; } + /** 외벽선 */ + const baseLines = wall.baseLines.filter((line) => line.attributes.planeSize > 0) + const baseLinePoints = baseLines.map((line) => ({ x: line.x1, y: line.y1 })) + + // 3. 스켈레톤 생성 const multiPolygon = [[coordinates]]; // GeoJSON MultiPolygon 형식 const skeleton = SkeletonBuilder.BuildFromGeoJSON(multiPolygon); @@ -127,15 +135,65 @@ export const drawSkeletonRidgeRoof = (roofId, canvas, textMode) => { const skeletonLines = []; const outerLines = pointsToLines(coordinates); + for (const baseLine of baseLines) { + const { type } = baseLine.get("attributes"); + + if(type === LINE_TYPE.WALLLINE.EAVES) { + + + + }else if(type === LINE_TYPE.WALLLINE.RIDGE) { + + } + } + linesToDraw.forEach((line, index) => { // 외곽선과 겹치는 스켈레톤 라인은 그리지 않음 const isOverlapping = outerLines.some(outerLine => linesOverlap(line, outerLine)); if (isOverlapping) { + // Array.find()를 사용하여 baseLines 배열에서 일치하는 라인을 찾습니다. + const foundBaseLine = baseLines.filter(baseLine => { + // baseLine (fabric.QLine)에서 좌표를 추출합니다. + const { p1: baseP1, p2: baseP2 } = getPointsFromQLine(baseLine); + + const attributes = baseLine.get('attributes'); + + // 2. 속성 객체에서 type 값을 추출합니다. + const type = attributes.type; + + // 이제 'type' 변수를 조건문 등에서 사용할 수 있습니다. + console.log('라인 타입:', type); + + // lineToDraw의 좌표 (p1, p2)와 baseLine의 좌표를 비교합니다. + // 라인 방향이 다를 수 있으므로 정방향과 역방향 모두 확인합니다. + + // 정방향 일치: (p1 -> p2) == (baseP1 -> baseP2) + const forwardMatch = + line.x1 === baseP1.x && line.y1 === baseP1.y && + line.x2 === baseP2.x && line.y2 === baseP2.y; + + // 역방향 일치: (p1 -> p2) == (baseP2 -> baseP1) + const reverseMatch = + line.x1 === baseP2.x && line.y1 === baseP2.y && + line.x2 === baseP1.x && line.y2 === baseP1.y; + + return forwardMatch || reverseMatch; + }); + + // 일치하는 라인을 찾았는지 확인 + if (foundBaseLine) { + console.log(`linesToDraw[${index}]와 일치하는 라인을 찾았습니다:`, foundBaseLine); + // 여기서 foundBaseLine을 사용하여 필요한 작업을 수행할 수 있습니다. + } else { + + console.log(`linesToDraw[${index}]에 대한 일치하는 라인을 찾지 못했습니다.`); + } console.log(`Skeleton line (edge ${line.edgeIndex}) is overlapping with a baseLine. It will not be drawn.`); return; } const isDiagonal = Math.abs(line.x1 - line.x2) > 1e-6 && Math.abs(line.y1 - line.y2) > 1e-6; + const isEaves = baseLinePoints.some(point => linesOverlap(line, point)); const skeletonLine = new QLine([line.x1, line.y1, line.x2, line.y2], { parentId: roofId, @@ -147,7 +205,7 @@ export const drawSkeletonRidgeRoof = (roofId, canvas, textMode) => { textMode: textMode, attributes: { roofId: roofId, - type: 'skeleton', // 스켈레톤 타입 식별자 + type: LINE_TYPE.WALLLINE.EAVES, // 스켈레톤 타입 식별자 skeletonIndex: line.edgeIndex, lineIndex: index, planeSize: calcLinePlaneSize(line), @@ -160,14 +218,19 @@ export const drawSkeletonRidgeRoof = (roofId, canvas, textMode) => { skeletonLines.push(skeletonLine); canvas.add(skeletonLine); + + // 6. roof 객체에 스켈레톤 라인 정보 업데이트 + roof.innerLines = [...(roof.innerLines || []), ...skeletonLines]; + skeletonLines.forEach(line => line.bringToFront()); + + canvas.renderAll(); + console.log(`Successfully drew ${linesToDraw.length} unique skeleton lines from ${skeleton.Edges.length} polygons.`); + + + }); - // 6. roof 객체에 스켈레톤 라인 정보 업데이트 - roof.innerLines = [...(roof.innerLines || []), ...skeletonLines]; - skeletonLines.forEach(line => line.bringToFront()); - canvas.renderAll(); - console.log(`Successfully drew ${linesToDraw.length} unique skeleton lines from ${skeleton.Edges.length} polygons.`); } catch (error) { console.error('An error occurred while generating the skeleton:', error); @@ -227,3 +290,13 @@ function pointsToLines(points) { return lines; } + + +/** + * fabric.QLine에서 시작점과 끝점을 가져옵니다. + * @param {fabric.QLine} line + * @returns {{p1: {x: number, y: number}, p2: {x: number, y: number}}} + */ +export const getPointsFromQLine = (line) => { + return { p1: { x: line.x1, y: line.y1 }, p2: { x: line.x2, y: line.y2 } }; +}; \ No newline at end of file From 21943536c9a9ef078a77cdbe3fddddbd61d61eea Mon Sep 17 00:00:00 2001 From: ysCha Date: Tue, 16 Sep 2025 18:19:13 +0900 Subject: [PATCH 10/20] skeleton 20% --- src/util/skeleton-utils.js | 1844 +++++++++++++++++++++++++++++++----- 1 file changed, 1581 insertions(+), 263 deletions(-) diff --git a/src/util/skeleton-utils.js b/src/util/skeleton-utils.js index 77ce3412..bbf87407 100644 --- a/src/util/skeleton-utils.js +++ b/src/util/skeleton-utils.js @@ -1,13 +1,1571 @@ +import { LINE_TYPE, POLYGON_TYPE } from '@/common/common' +import Big from 'big.js' +import { SkeletonBuilder } from '@/lib/skeletons' +import { arePointsEqual, calcLineActualSize, calcLinePlaneSize, calculateAngle, toGeoJSON } from '@/util/qpolygon-utils' +import { QLine } from '@/components/fabric/QLine' +import { getDegreeByChon, isPointOnLine } from '@/util/canvas-util' + /** - * @file skeleton-utils.js - * @description 지붕 폴리곤의 스켈레톤(중심선)을 생성하고 Fabric.js 캔버스에 그리는 유틸리티 함수들을 포함합니다. + * 지붕 폴리곤의 스켈레톤(중심선)을 생성하고 캔버스에 그립니다. + * @param {string} roofId - 대상 지붕 객체의 ID + * @param {fabric.Canvas} canvas - Fabric.js 캔버스 객체 + * @param {string} textMode - 텍스트 표시 모드 + */ +export const drawSkeletonRidgeRoof = (roofId, canvas, textMode) => { + const roof = canvas?.getObjects().find((object) => object.id === roofId) + if (!roof) { + console.error(`Roof with id "${roofId}" not found.`); + return; + } + + // 1. 기존 스켈레톤 라인 제거 + const existingSkeletonLines = canvas.getObjects().filter(obj => + obj.parentId === roofId && obj.attributes?.type === 'skeleton' + ); + existingSkeletonLines.forEach(line => canvas.remove(line)); + + // 2. 지붕 폴리곤 좌표 전처리 + const coordinates = preprocessPolygonCoordinates(roof.points); + if (coordinates.length < 3) { + console.warn("Polygon has less than 3 unique points. Cannot generate skeleton."); + return; + } + + + const wall = canvas.getObjects().find((object) => object.name === POLYGON_TYPE.WALL && object.attributes.roofId === roofId) + + //평행선 여부 + const hasNonParallelLines = roof.lines.filter((line) => Big(line.x1).minus(Big(line.x2)).gt(1) && Big(line.y1).minus(Big(line.y2)).gt(1)) + if (hasNonParallelLines.length > 0) { + return + } + + + /** 외벽선 */ + const baseLines = wall.baseLines.filter((line) => line.attributes.planeSize > 0) + const baseLinePoints = baseLines.map((line) => ({ x: line.x1, y: line.y1 })) + + skeletonBuilder(roofId, canvas, textMode, roof) +} + +/** + * 스켈레톤의 edge를 각도가 있는 구간으로 변형합니다. + * @param {Object} skeleton - 스켈레톤 객체 + * @param {number} edgeIndex - 변형할 edge의 인덱스 + * @param {number} angleOffset - 추가할 각도 (도 단위) + * @param {number} splitRatio - 분할 비율 (0-1 사이, 0.5면 중간점) + * @returns {Object} 변형된 스켈레톤 객체 + */ +export const transformEdgeWithAngle = (skeleton, edgeIndex, angleOffset = 45, splitRatio = 0.5) => { + if (!skeleton || !skeleton.Edges || edgeIndex >= skeleton.Edges.length || edgeIndex < 0) { + console.warn('유효하지 않은 스켈레톤 또는 edge 인덱스입니다.') + return skeleton + } + + const edgeResult = skeleton.Edges[edgeIndex] + if (!edgeResult || !edgeResult.Polygon || !Array.isArray(edgeResult.Polygon)) { + console.warn('유효하지 않은 edge 또는 Polygon 데이터입니다.') + return skeleton + } + + const polygonPoints = edgeResult.Polygon.map((p) => ({ x: p.X, y: p.Y })) + + // 변형할 edge 찾기 (가장 긴 내부 선분을 대상으로 함) + let longestEdge = null + let longestLength = 0 + let longestEdgeIndex = -1 + + for (let i = 0; i < polygonPoints.length; i++) { + const p1 = polygonPoints[i] + const p2 = polygonPoints[(i + 1) % polygonPoints.length] + + const length = Math.sqrt(Math.pow(p2.x - p1.x, 2) + Math.pow(p2.y - p1.y, 2)) + + if (length > longestLength) { + longestLength = length + longestEdge = { p1, p2, index: i } + longestEdgeIndex = i + } + } + + if (!longestEdge) return skeleton + + // 중간점 계산 + const midPoint = { + x: longestEdge.p1.x + (longestEdge.p2.x - longestEdge.p1.x) * splitRatio, + y: longestEdge.p1.y + (longestEdge.p2.y - longestEdge.p1.y) * splitRatio, + } + + // 원래 선분의 방향 벡터 + const originalVector = { + x: longestEdge.p2.x - longestEdge.p1.x, + y: longestEdge.p2.y - longestEdge.p1.y, + } + + // 각도 변형을 위한 새로운 점 계산 + const angleRad = (angleOffset * Math.PI) / 180 + const perpVector = { + x: -originalVector.y, + y: originalVector.x, + } + + // 정규화 + const perpLength = Math.sqrt(perpVector.x * perpVector.x + perpVector.y * perpVector.y) + const normalizedPerp = { + x: perpVector.x / perpLength, + y: perpVector.y / perpLength, + } + + // 각도 변형을 위한 오프셋 거리 (선분 길이의 10%) + const offsetDistance = longestLength * 0.1 + + // 새로운 각도 점 + const anglePoint = { + x: midPoint.x + normalizedPerp.x * offsetDistance * Math.sin(angleRad), + y: midPoint.y + normalizedPerp.y * offsetDistance * Math.sin(angleRad), + } + + // 새로운 폴리곤 점들 생성 + const newPolygonPoints = [...polygonPoints] + + // 기존 점을 제거하고 새로운 세 점으로 교체 + newPolygonPoints.splice(longestEdgeIndex + 1, 0, anglePoint) + + // 스켈레톤 객체 업데이트 - 순환 참조 문제를 방지하기 위해 안전한 복사 방식 사용 + const newSkeleton = { + ...skeleton, + Edges: skeleton.Edges.map((edge, idx) => { + if (idx === edgeIndex) { + return { + ...edge, + Polygon: newPolygonPoints.map((p) => ({ X: p.x, Y: p.y })), + } + } + return edge + }), + } + + return newSkeleton +} + +/** + * 여러 edge를 한 번에 변형합니다. + * @param {Object} skeleton - 스켈레톤 객체 + * @param {Array} edgeConfigs - 변형 설정 배열 [{edgeIndex, angleOffset, splitRatio}] + * @returns {Object} 변형된 스켈레톤 객체 + */ +export const transformMultipleEdges = (skeleton, edgeConfigs) => { + let transformedSkeleton = skeleton + + // 인덱스 역순으로 정렬하여 변형 시 인덱스 변화를 방지 + edgeConfigs.sort((a, b) => b.edgeIndex - a.edgeIndex) + + edgeConfigs.forEach((config) => { + transformedSkeleton = transformEdgeWithAngle(transformedSkeleton, config.edgeIndex, config.angleOffset || 45, config.splitRatio || 0.5) + }) + + return transformedSkeleton +} + +/** + * 마루가 있는 지붕을 그린다. + * @param roofId + * @param canvas + * @param textMode + * @param roof + * @param edgeProperties + */ +export const skeletonBuilder = (roofId, canvas, textMode, roof) => { + // 1. 다각형 좌표를 GeoJSON 형식으로 변환합니다. + const geoJSONPolygon = toGeoJSON(roof.points) + + try { + // 2. SkeletonBuilder를 사용하여 스켈레톤을 생성합니다. + geoJSONPolygon.pop() + let skeleton = SkeletonBuilder.BuildFromGeoJSON([[geoJSONPolygon]]) + + console.log(`지붕 형태: ${skeleton.roof_type}`) // "complex" + console.log('Edge 분석:', skeleton.edge_analysis) + + // 3. 라인을 그림 + const innerLines = createInnerLinesFromSkeleton(skeleton, roof.lines, roof, canvas, textMode) + + console.log("innerLines::", innerLines) + // 4. 생성된 선들을 캔버스에 추가하고 지붕 객체에 저장 + // innerLines.forEach((line) => { + // canvas.add(line) + // line.bringToFront() + // canvas.renderAll() + // }) + + roof.innerLines = innerLines + + // canvas에 skeleton 상태 저장 + if (!canvas.skeletonStates) { + canvas.skeletonStates = {} + } + canvas.skeletonStates[roofId] = true + + canvas.renderAll() + } catch (e) { + console.error('지붕 생성 중 오류 발생:', e) + // 오류 발생 시 기존 로직으로 대체하거나 사용자에게 알림 + if (canvas.skeletonStates) { + canvas.skeletonStates[roofId] = false + } + } +} + +/** + * 스켈레톤 결과와 원본 외벽선 정보를 바탕으로 내부선(마루, 추녀)들을 생성합니다. + * @param {object} skeleton - SkeletonBuilder로부터 반환된 스켈레톤 객체 + * @param {Array} baseLines - 원본 외벽선 QLine 객체 배열 + * @param {QPolygon} roof - 대상 지붕 QPolygon 객체 + * @param {fabric.Canvas} canvas - Fabric.js 캔버스 객체 + * @param {string} textMode - 텍스트 표시 모드 ('plane', 'actual', 'none') + * @returns {Array} 생성된 내부선(QLine) 배열 + */ +// 두 선분이 같은 직선상에 있고 겹치는지 확인하는 함수 +const areLinesCollinearAndOverlapping = (line1, line2) => { + // 두 선분이 같은 직선상에 있는지 확인 + const areCollinear = (p1, p2, p3, p4) => { + const area1 = (p2.x - p1.x) * (p3.y - p1.y) - (p2.y - p1.y) * (p3.x - p1.x) + const area2 = (p2.x - p1.x) * (p4.y - p1.y) - (p2.y - p1.y) * (p4.x - p1.x) + return Math.abs(area1) < 1 && Math.abs(area2) < 1 + } + + // 두 선분이 겹치는지 확인 + const isOverlapping = (a1, a2, b1, b2) => { + // x축에 평행한 경우 + if (Math.abs(a1.y - a2.y) < 1 && Math.abs(b1.y - b2.y) < 1) { + if (Math.abs(a1.y - b1.y) > 1) return false + return !(Math.max(a1.x, a2.x) < Math.min(b1.x, b2.x) || Math.min(a1.x, a2.x) > Math.max(b1.x, b2.x)) + } + // y축에 평행한 경우 + if (Math.abs(a1.x - a2.x) < 1 && Math.abs(b1.x - b2.x) < 1) { + if (Math.abs(a1.x - b1.x) > 1) return false + return !(Math.max(a1.y, a2.y) < Math.min(b1.y, b2.y) || Math.min(a1.y, a2.y) > Math.max(b1.y, b2.y)) + } + return false + } + + return areCollinear(line1.p1, line1.p2, line2.p1, line2.p2) && isOverlapping(line1.p1, line1.p2, line2.p1, line2.p2) +} + +// 겹치는 선분을 하나로 합치는 함수 +const mergeCollinearLines = (lines) => { + if (lines.length <= 1) return lines + + const merged = [] + const processed = new Set() + + for (let i = 0; i < lines.length; i++) { + if (processed.has(i)) continue + + let currentLine = lines[i] + let mergedLine = { ...currentLine } + let wasMerged = false + + for (let j = i + 1; j < lines.length; j++) { + if (processed.has(j)) continue + + const otherLine = lines[j] + + if (areLinesCollinearAndOverlapping(mergedLine, otherLine)) { + // 겹치는 선분을 하나로 합침 + const allPoints = [ + { x: mergedLine.p1.x, y: mergedLine.p1.y }, + { x: mergedLine.p2.x, y: mergedLine.p2.y }, + { x: otherLine.p1.x, y: otherLine.p1.y }, + { x: otherLine.p2.x, y: otherLine.p2.y }, + ] + + // x축에 평행한 경우 x 좌표로 정렬 + if (Math.abs(mergedLine.p1.y - mergedLine.p2.y) < 1) { + allPoints.sort((a, b) => a.x - b.x) + mergedLine = { + p1: allPoints[0], + p2: allPoints[allPoints.length - 1], + attributes: mergedLine.attributes, + } + } + // y축에 평행한 경우 y 좌표로 정렬 + else { + allPoints.sort((a, b) => a.y - b.y) + mergedLine = { + p1: allPoints[0], + p2: allPoints[allPoints.length - 1], + attributes: mergedLine.attributes, + } + } + + wasMerged = true + processed.add(j) + } + } + + merged.push(mergedLine) + processed.add(i) + } + + return merged +} + +//조건에 따른 스켈레톤을 그린다. +const createInnerLinesFromSkeleton = (skeleton, baseLines, roof, canvas, textMode) => { + console.log('=== Edge Properties 기반 후처리 시작 ===') + + if (!skeleton || !skeleton.Edges) return [] + + const innerLines = [] + const processedInnerEdges = new Set() + const rawLines = [] + + // 1. 기본 skeleton에서 모든 내부 선분 수집 + //edge 순서와 baseLines 순서가 같을수가 없다. + for (let edgeIndex = 0; edgeIndex < skeleton.Edges.length; edgeIndex++) { + console.log('edgeIndex:::', edgeIndex) + let changeEdgeIndex = edgeIndex + //입력 폴리곤이 왼쪽 상단에서 시작하면 시계 방향으로 진행합니다. + //오른쪽 하단에서 시작하면 그 지점에서부터 시계 방향으로 진행합니다. + //edgeIndex 대신에 실제 baseLines 선택라인을 찾아야 한다. + const edgeResult = skeleton.Edges[edgeIndex] + + // 방향을 고려하지 않고 같은 라인인지 확인하는 함수 + + let edgeType = 'eaves' + let baseLineIndex = 0 + + processEavesEdge(edgeResult, baseLines, rawLines, processedInnerEdges, edgeIndex, baseLineIndex) + // // ✅ Edge 타입별 처리 분기 + // switch (edgeType) { + // case 'eaves': + // processEavesEdge(edgeResult, baseLines, rawLines, processedInnerEdges, edgeIndex, baseLineIndex) + // break + // + // case 'wall': + // processWallEdge(edgeResult, baseLines, rawLines, processedInnerEdges, edgeIndex) + // break + // + // case 'gable': + // processGableEdge(edgeResult, baseLines, rawLines, processedInnerEdges, edgeIndex, baseLineIndex) + // break + // + // default: + // console.warn(`알 수 없는 edge 타입: ${edgeType}`) + // processEavesEdge(edgeResult, baseLines, rawLines, processedInnerEdges, edgeIndex, baseLineIndex) + // } + } + + for (let baseLineIndex = 0; baseLineIndex < baseLines.length; baseLineIndex++) { + + if (baseLines[baseLineIndex].attributes.type === 'gable') { + // 일다 그려서 rawLines를 만들어 + for (let edgeIndex = 0; edgeIndex < skeleton.Edges.length; edgeIndex++) { + + const edgeResult = skeleton.Edges[edgeIndex] + const startX = edgeResult.Edge.Begin.X + const startY = edgeResult.Edge.Begin.Y + const endX = edgeResult.Edge.End.X + const endY = edgeResult.Edge.End.Y + + //외벽선 동일 라인이면 + if (isSameLine(startX, startY, endX, endY, baseLines[baseLineIndex])) { + processGableEdge(edgeResult, baseLines, rawLines, processedInnerEdges, edgeIndex, baseLineIndex) // + break // 매칭되는 라인을 찾았으므로 루프 종료 + } + + } + } + + } + + console.log(`처리된 rawLines: ${rawLines.length}개`) + + // 2. 겹치는 선분 병합 + // const mergedLines = mergeCollinearLines(rawLines) + // console.log('mergedLines', mergedLines) + // 3. QLine 객체로 변환 + for (const line of rawLines) { + const { p1, p2, attributes, lineStyle } = line + const innerLine = new QLine([p1.x, p1.y, p2.x, p2.y], { + parentId: roof.id, + fontSize: roof.fontSize, + stroke: lineStyle.color, + strokeWidth: lineStyle?.width, + name: attributes.type, + textMode: textMode, + attributes: attributes, + }) + + canvas.add(innerLine) + innerLine.bringToFront() + canvas.renderAll() + + innerLines.push(innerLine) + } + + return innerLines +} + +// ✅ EAVES (처마) 처리 - 기본 skeleton 모두 사용 +function processEavesEdge(edgeResult, baseLines, rawLines, processedInnerEdges) { + console.log(`processEavesEdge::`, rawLines) + + // 내부 선분 수집 (스케레톤은 다각형) + const polygonPoints = edgeResult.Polygon.map((p) => ({ x: p.X, y: p.Y })) + + for (let i = 0; i < polygonPoints.length; i++) { + //시계방향 + const p1 = polygonPoints[i] + const p2 = polygonPoints[(i + 1) % polygonPoints.length] + + // 외벽선 제외 후 추가 + if (!isOuterEdge(p1, p2, baseLines)) { + addRawLine(rawLines, processedInnerEdges, p1, p2, 'RIDGE', '#FF0000', 3) + } + } +} + +// ✅ WALL (벽) 처리 - 선분 개수 최소화 +function processWallEdge(edgeResult, baseLines, rawLines, processedInnerEdges, edgeIndex) { + console.log(`WALL Edge ${edgeIndex}: 내부 선분 최소화`) + + const polygonPoints = edgeResult.Polygon.map((p) => ({ x: p.X, y: p.Y })) + + // 벽면은 내부 구조를 단순화 - 주요 선분만 선택 + for (let i = 0; i < polygonPoints.length; i++) { + const p1 = polygonPoints[i] + const p2 = polygonPoints[(i + 1) % polygonPoints.length] + + if (!isOuterEdge(p1, p2, baseLines)) { + // 선분 길이 확인 - 긴 선분만 사용 (짧은 선분 제거) + const lineLength = Math.sqrt((p2.x - p1.x) ** 2 + (p2.y - p1.y) ** 2) + + if (lineLength > 10) { + // 최소 길이 조건 + addRawLine(rawLines, processedInnerEdges, p1, p2, 'HIP', '#000000', 2) + } else { + console.log(`WALL: 짧은 선분 제거 (길이: ${lineLength.toFixed(1)})`) + } + } + } +} + +// ✅ GABLE (케라바) 처리 - 직선 생성, 다른 선분 제거 +function processGableEdge(edgeResult, baseLines, rawLines, processedInnerEdges, edgeIndex, baseLineIndex) { + console.log(`GABLE Edge ${edgeResult}: 직선 skeleton 생성`) + const diagonalLine = []; //대각선 라인 + + const polygonPoints = edgeResult.Polygon.map((p) => ({ x: p.X, y: p.Y })) + console.log('polygonPoints::', polygonPoints) + // ✅ 케라바는 직선 패턴으로 변경 + + // 1. 기존 복잡한 skeleton 선분들 무시 + // 2. GABLE edge에 수직인 직선 생성 + + const sourceEdge = edgeResult.Edge + const gableStart = { x: sourceEdge.Begin.X, y: sourceEdge.Begin.Y } + const gableEnd = { x: sourceEdge.End.X, y: sourceEdge.End.Y } + + // GABLE edge 중점 + const gableMidpoint = { + x: (gableStart.x + gableEnd.x) / 2, + y: (gableStart.y + gableEnd.y) / 2, + } + // + // // polygonPoints와 gableMidpoint 비교: x 또는 y가 같은 점 찾기 (허용 오차 적용) + // const axisTolerance = 0.1 + // const sameXPoints = polygonPoints.filter((p) => Math.abs(p.x - gableMidpoint.x) < axisTolerance) + // const sameYPoints = polygonPoints.filter((p) => Math.abs(p.y - gableMidpoint.y) < axisTolerance) + // if (sameXPoints.length || sameYPoints.length) { + // console.log('GABLE: gableMidpoint와 같은 축의 폴리곤 점', { + // gableMidpoint, + // sameXPoints, + // sameYPoints, + // }) + // } + // + // // 폴리곤 중심점 (대략적) + // const centerX = polygonPoints.reduce((sum, p) => sum + p.x, 0) / polygonPoints.length + // const centerY = polygonPoints.reduce((sum, p) => sum + p.y, 0) / polygonPoints.length + // const polygonCenter = { x: centerX, y: centerY } + // + // // 허용 오차 + // const colinearityTolerance = 0.1 + // + // // 폴리곤 선분 생성 (연속 점 쌍) + // const segments = [] + // for (let i = 0; i < polygonPoints.length; i++) { + // const p1 = polygonPoints[i] + // const p2 = polygonPoints[(i + 1) % polygonPoints.length] + // segments.push({ p1, p2 }) + // } + // + // // gableMidpoint와 같은 축(Y 또는 X)에 있는 수직/수평 선분만 추출 + // const sameAxisSegments = segments.filter(({ p1, p2 }) => { + // const isVertical = Math.abs(p1.x - p2.x) < colinearityTolerance + // const isHorizontal = Math.abs(p1.y - p2.y) < colinearityTolerance + // const sameXAxis = isVertical && Math.abs(p1.x - gableMidpoint.x) < axisTolerance + // const sameYAxis = isHorizontal && Math.abs(p1.y - gableMidpoint.y) < axisTolerance + // return sameXAxis || sameYAxis + // }) + // + // // 가장 가까운(또는 가장 긴) 용마루 후보 선택 + // let ridgeCandidate = null + // if (sameAxisSegments.length) { + // // 1) 중점과의 최단거리 기준 + // ridgeCandidate = sameAxisSegments.reduce((best, seg) => { + // const mid = { x: (seg.p1.x + seg.p2.x) / 2, y: (seg.p1.y + seg.p2.y) / 2 } + // const dist2 = (mid.x - gableMidpoint.x) ** 2 + (mid.y - gableMidpoint.y) ** 2 + // if (!best) return { seg, score: dist2 } + // return dist2 < best.score ? { seg, score: dist2 } : best + // }, null)?.seg + // + // + // } + + // + + + const selectBaseLine = baseLines[baseLineIndex]; + console.log('selectBaseLine:', selectBaseLine); + console.log('rawLines:', rawLines) + //selectBaseLine 과 같은 edgeResult.ed + + // 대각선 보정(fallback) 제거: 항상 수평/수직 내부 용마루만 생성 + + for (let i = rawLines.length - 1; i >= 0; i--) { + const line = rawLines[i]; + console.log('line:', line) + console.log('line.attributes.type:', line.attributes.type) + if (line.attributes.type === LINE_TYPE.SUBLINE.HIP || line.attributes.type === 'HIP') { + + // 선택한 기준선 을 중심으로 대각선 삭제 + // Get line and edge points + const edgeStart = { x: edgeResult.Edge.Begin.X, y: edgeResult.Edge.Begin.Y }; + const edgeEnd = { x: edgeResult.Edge.End.X, y: edgeResult.Edge.End.Y }; + const lineStart = { x: line.p1.x, y: line.p1.y }; + const lineEnd = { x: line.p2.x, y: line.p2.y }; + + const pointsEqual = (p1, p2) => { + return p1.x === p2.x && p1.y === p2.y; + } + // Check if line shares an endpoint with the edge + const sharesStartPoint = pointsEqual(edgeStart, lineStart) || pointsEqual(edgeStart, lineEnd); + const sharesEndPoint = pointsEqual(edgeEnd, lineStart) || pointsEqual(edgeEnd, lineEnd); + + if (sharesStartPoint || sharesEndPoint) { + rawLines.splice(i, 1); + // ridge extension logic can go here + } + + + + + }else if (line.attributes.type === LINE_TYPE.SUBLINE.RIDGE || line.attributes.type === 'RIDGE') { + //마루일때 + + if(edgeResult.Polygon.length > 3){ + const lineP1 = { x: line.p1.x, y: line.p1.y }; + const lineP2 = { x: line.p2.x, y: line.p2.y }; + let hasP1 = false; + let hasP2 = false; + + for (const polyPoint of edgeResult.Polygon) { + if (!hasP1 && polyPoint.X === lineP1.x && polyPoint.Y === lineP1.y) { + hasP1 = true; + } + if (!hasP2 && polyPoint.X === lineP2.x && polyPoint.Y === lineP2.y) { + hasP2 = true; + } + + // Early exit if both points are found + if (hasP1 && hasP2) break; + } + + if (hasP1 && hasP2) { + rawLines.splice(i, 1); + } + + } + } + + console.log('result rawLines:', rawLines) + } + + + // addRawLine( + // rawLines, + // processedInnerEdges, + // gableMidpoint, + // polygonCenter, + // 'RIDGE', + // '#0000FF', // 파란색으로 구분 + // 3, // 두껍게 + // ) +} + +// ✅ 헬퍼 함수들 +function isOuterEdge(p1, p2, baseLines) { + const tolerance = 0.1 + return baseLines.some((line) => { + const lineStart = line.startPoint || { x: line.x1, y: line.y1 } + const lineEnd = line.endPoint || { x: line.x2, y: line.y2 } + + return ( + (Math.abs(lineStart.x - p1.x) < tolerance && + Math.abs(lineStart.y - p1.y) < tolerance && + Math.abs(lineEnd.x - p2.x) < tolerance && + Math.abs(lineEnd.y - p2.y) < tolerance) || + (Math.abs(lineStart.x - p2.x) < tolerance && + Math.abs(lineStart.y - p2.y) < tolerance && + Math.abs(lineEnd.x - p1.x) < tolerance && + Math.abs(lineEnd.y - p1.y) < tolerance) + ) + }) +} + +function addRawLine(rawLines, processedInnerEdges, p1, p2, lineType, color, width) { + const edgeKey = [`${p1.x.toFixed(1)},${p1.y.toFixed(1)}`, `${p2.x.toFixed(1)},${p2.y.toFixed(1)}`].sort().join('|') + + if (processedInnerEdges.has(edgeKey)) return + processedInnerEdges.add(edgeKey) + + // 라인 타입을 상수로 정규화 + const inputNormalizedType = + lineType === LINE_TYPE.SUBLINE.RIDGE || lineType === 'RIDGE' + ? LINE_TYPE.SUBLINE.RIDGE + : lineType === LINE_TYPE.SUBLINE.HIP || lineType === 'HIP' + ? LINE_TYPE.SUBLINE.HIP + : lineType + + // 대각선 여부 판단 (수평/수직이 아닌 경우) + const dx = Math.abs(p2.x - p1.x) + const dy = Math.abs(p2.y - p1.y) + const tolerance = 0.1 + const isHorizontal = dy < tolerance + const isVertical = dx < tolerance + const isDiagonal = !isHorizontal && !isVertical + + // 대각선일 때 lineType을 HIP로 지정 + const normalizedType = isDiagonal ? LINE_TYPE.SUBLINE.HIP : inputNormalizedType + + rawLines.push({ + p1: p1, + p2: p2, + attributes: { + type: normalizedType, + planeSize: calcLinePlaneSize({ x1: p1.x, y1: p1.y, x2: p2.x, y2: p2.y }), + isRidge: normalizedType === LINE_TYPE.SUBLINE.RIDGE, + }, + lineStyle: { + color: color, + width: width, + }, + }) + +} + +/** + * 특정 roof의 edge를 캐라바로 설정하여 다시 그립니다. + * @param {string} roofId - 지붕 ID + * @param {fabric.Canvas} canvas - 캔버스 객체 + * @param {string} textMode - 텍스트 모드 + * @param {number} selectedEdgeIndex - 선택된 외곽선의 인덱스 */ -import SkeletonBuilder from '@/lib/skeletons/SkeletonBuilder'; -import { fabric } from 'fabric' -import { LINE_TYPE, POLYGON_TYPE } from '@/common/common' -import { QLine } from '@/components/fabric/QLine'; -import { calcLinePlaneSize } from '@/util/qpolygon-utils'; +export const drawSkeletonWithTransformedEdges = (roofId, canvas, textMode, selectedEdgeIndex) => { + let roof = canvas?.getObjects().find((object) => object.id === roofId) + if (!roof) { + console.warn('Roof object not found') + return + } + + // Clear existing inner lines if any + if (roof.innerLines) { + roof.innerLines.forEach((line) => canvas.remove(line)) + roof.innerLines = [] + } + + // Transform the selected wall into a roof + transformWallToRoof(roof, canvas, selectedEdgeIndex) + + canvas.renderAll() +} + +/** + * 삼각형에 대한 캐라바 처리 + * @param {QPolygon} roof - 지붕 객체 + * @param {fabric.Canvas} canvas - 캔버스 객체 + * @param {string} textMode - 텍스트 모드 + * @param {number} selectedEdgeIndex - 선택된 외곽선의 인덱스 + */ +const drawCarabaForTriangle = (roof, canvas, textMode, edgeIndex) => { + const points = roof.getCurrentPoints() + + if (!points || points.length !== 3) { + console.warn('삼각형이 아니거나 유효하지 않은 점 데이터입니다.') + return + } + + if (edgeIndex < 0 || edgeIndex >= 3) { + console.warn('유효하지 않은 edge 인덱스입니다.') + return + } + + // 선택된 edge의 두 꼭짓점을 제외한 나머지 점 찾기 + const oppositeVertexIndex = (edgeIndex + 2) % 3 + const oppositeVertex = points[oppositeVertexIndex] + + // 선택된 edge의 시작점과 끝점 + const edgeStartIndex = edgeIndex + const edgeEndIndex = (edgeIndex + 1) % 3 + const edgeStart = points[edgeStartIndex] + const edgeEnd = points[edgeEndIndex] + + // 선택된 edge의 중점 계산 + const edgeMidPoint = { + x: (edgeStart.x + edgeEnd.x) / 2, + y: (edgeStart.y + edgeEnd.y) / 2, + } + + // 맞은편 꼭짓점에서 선택된 edge의 중점으로 가는 직선 생성 + const carabaLine = new QLine([oppositeVertex.x, oppositeVertex.y, edgeMidPoint.x, edgeMidPoint.y], { + parentId: roof.id, + fontSize: roof.fontSize, + stroke: '#FF0000', + strokeWidth: 2, + name: LINE_TYPE.SUBLINE.RIDGE, + textMode: textMode, + attributes: { + type: LINE_TYPE.SUBLINE.RIDGE, + planeSize: calcLinePlaneSize({ + x1: oppositeVertex.x, + y1: oppositeVertex.y, + x2: edgeMidPoint.x, + y2: edgeMidPoint.y, + }), + actualSize: calcLineActualSize( + { + x1: oppositeVertex.x, + y1: oppositeVertex.y, + x2: edgeMidPoint.x, + y2: edgeMidPoint.y, + }, + getDegreeByChon(roof.lines[edgeIndex]?.attributes?.pitch || 30), + ), + roofId: roof.id, + isRidge: true, + }, + }) + + // 캔버스에 추가 + canvas.add(carabaLine) + carabaLine.bringToFront() + + // 지붕 객체에 저장 + roof.innerLines = [carabaLine] + + // canvas에 skeleton 상태 저장 + if (!canvas.skeletonStates) { + canvas.skeletonStates = {} + } + canvas.skeletonStates[roof.id] = true + + canvas.renderAll() +} + +/** + * 다각형에 대한 캐라바 처리 + * @param {QPolygon} roof - 지붕 객체 + * @param {fabric.Canvas} canvas - 캔버스 객체 + * @param {string} textMode - 텍스트 모드 + * @param {number} selectedEdgeIndex - 선택된 외곽선의 인덱스 + */ +const drawCarabaForPolygon = (roof, canvas, textMode, selectedEdgeIndex) => { + const points = roof.getCurrentPoints() + + if (!points || points.length < 3) { + console.warn('유효하지 않은 다각형 점 데이터입니다.') + return + } + + if (selectedEdgeIndex < 0 || selectedEdgeIndex >= points.length) { + console.warn('유효하지 않은 edge 인덱스입니다.') + return + } + + // 삼각형인 경우 기존 로직 사용 + if (points.length === 3) { + drawCarabaForTriangle(roof, canvas, textMode, selectedEdgeIndex) + return + } + + // 먼저 스켈레톤을 생성하여 내부 구조를 파악 + const geoJSONPolygon = toGeoJSON(points) + geoJSONPolygon.pop() // 마지막 좌표 제거 + + let skeleton + try { + skeleton = SkeletonBuilder.BuildFromGeoJSON([[geoJSONPolygon]]) + } catch (e) { + console.error('스켈레톤 생성 중 오류:', e) + return + } + + if (!skeleton || !skeleton.Edges) { + console.warn('스켈레톤 생성에 실패했습니다.') + return + } + + // 선택된 외곽선의 시작점과 끝점 + const selectedStart = points[selectedEdgeIndex] + const selectedEnd = points[(selectedEdgeIndex + 1) % points.length] + + const innerLines = [] + const processedInnerEdges = new Set() + + // 스켈레톤의 모든 내부선을 수집 + for (const edgeResult of skeleton.Edges) { + const polygonPoints = edgeResult.Polygon.map((p) => ({ x: p.X, y: p.Y })) + + for (let i = 0; i < polygonPoints.length; i++) { + const p1 = polygonPoints[i] + const p2 = polygonPoints[(i + 1) % polygonPoints.length] + + // 선택된 외곽선은 제외 + const isSelectedEdge = + (arePointsEqual(selectedStart, p1) && arePointsEqual(selectedEnd, p2)) || + (arePointsEqual(selectedStart, p2) && arePointsEqual(selectedEnd, p1)) + + if (isSelectedEdge) continue + + // 다른 외곽선들은 제외 + const isOtherOuterEdge = roof.lines.some((line, idx) => { + if (idx === selectedEdgeIndex) return false + return ( + (arePointsEqual(line.startPoint, p1) && arePointsEqual(line.endPoint, p2)) || + (arePointsEqual(line.startPoint, p2) && arePointsEqual(line.endPoint, p1)) + ) + }) + + if (isOtherOuterEdge) continue + + const edgeKey = [`${p1.x.toFixed(1)},${p1.y.toFixed(1)}`, `${p2.x.toFixed(1)},${p2.y.toFixed(1)}`].sort().join('|') + + if (processedInnerEdges.has(edgeKey)) continue + processedInnerEdges.add(edgeKey) + + // 선택된 외곽선에 수직으로 연장되는 선들만 처리 + const selectedLineAngle = calculateAngle(selectedStart, selectedEnd) + const innerLineAngle = calculateAngle(p1, p2) + const angleDiff = Math.abs(selectedLineAngle - innerLineAngle) + const isPerpendicular = Math.abs(angleDiff - 90) < 5 || Math.abs(angleDiff - 270) < 5 + + if (isPerpendicular) { + // 선택된 외곽선 방향으로 연장 + const extendedLine = extendLineToOppositeEdge(p1, p2, points, selectedEdgeIndex) + + if (extendedLine) { + const carabaLine = new QLine([extendedLine.start.x, extendedLine.start.y, extendedLine.end.x, extendedLine.end.y], { + parentId: roof.id, + fontSize: roof.fontSize, + stroke: '#FF0000', + strokeWidth: 2, + name: LINE_TYPE.SUBLINE.RIDGE, + textMode: textMode, + attributes: { + type: LINE_TYPE.SUBLINE.RIDGE, + planeSize: calcLinePlaneSize({ + x1: extendedLine.start.x, + y1: extendedLine.start.y, + x2: extendedLine.end.x, + y2: extendedLine.end.y, + }), + actualSize: calcLineActualSize( + { + x1: extendedLine.start.x, + y1: extendedLine.start.y, + x2: extendedLine.end.x, + y2: extendedLine.end.y, + }, + getDegreeByChon(roof.lines[selectedEdgeIndex]?.attributes?.pitch || 30), + ), + roofId: roof.id, + isRidge: true, + }, + }) + + innerLines.push(carabaLine) + } + } + } + } + + // 캔버스에 추가 + innerLines.forEach((line) => { + canvas.add(line) + line.bringToFront() + }) + + // 지붕 객체에 저장 + roof.innerLines = innerLines + + // canvas에 skeleton 상태 저장 + if (!canvas.skeletonStates) { + canvas.skeletonStates = {} + } + canvas.skeletonStates[roof.id] = true + + canvas.renderAll() +} + +/** + * 선분을 맞은편 외곽선까지 연장하는 함수 + */ +const extendLineToOppositeEdge = (p1, p2, polygonPoints, selectedEdgeIndex) => { + // 선분의 방향 벡터 계산 + const direction = { + x: p2.x - p1.x, + y: p2.y - p1.y, + } + + // 방향 벡터 정규화 + const length = Math.sqrt(direction.x * direction.x + direction.y * direction.y) + if (length === 0) return null + + const normalizedDir = { + x: direction.x / length, + y: direction.y / length, + } + + // 선택된 외곽선의 반대편 찾기 + const oppositeEdgeIndex = (selectedEdgeIndex + Math.floor(polygonPoints.length / 2)) % polygonPoints.length + const oppositeStart = polygonPoints[oppositeEdgeIndex] + const oppositeEnd = polygonPoints[(oppositeEdgeIndex + 1) % polygonPoints.length] + + // p1에서 시작해서 반대편까지 연장 + const extendedStart = { x: p1.x, y: p1.y } + const extendedEnd = findIntersectionWithEdge(p1, normalizedDir, oppositeStart, oppositeEnd) || { x: p2.x, y: p2.y } + + return { + start: extendedStart, + end: extendedEnd, + } +} + +/** + * 선분과 외곽선의 교점을 찾는 함수 + */ +const findIntersectionWithEdge = (lineStart, lineDir, edgeStart, edgeEnd) => { + const edgeDir = { + x: edgeEnd.x - edgeStart.x, + y: edgeEnd.y - edgeStart.y, + } + + const denominator = lineDir.x * edgeDir.y - lineDir.y * edgeDir.x + if (Math.abs(denominator) < 1e-10) return null // 평행선 + + const t = ((edgeStart.x - lineStart.x) * edgeDir.y - (edgeStart.y - lineStart.y) * edgeDir.x) / denominator + const u = ((edgeStart.x - lineStart.x) * lineDir.y - (edgeStart.y - lineStart.y) * lineDir.x) / denominator + + if (t >= 0 && u >= 0 && u <= 1) { + return { + x: lineStart.x + t * lineDir.x, + y: lineStart.y + t * lineDir.y, + } + } + + return null +} + +/** + * Transforms the selected wall line into a roof structure + * @param {QPolygon} roof - The roof object + * @param {fabric.Canvas} canvas - The canvas object + * @param {number} edgeIndex - Index of the selected edge + */ +const transformWallToRoof = (roof, canvas, edgeIndex) => { + // Get the current points + const points = roof.getCurrentPoints() + + if (!points || points.length < 3) { + console.warn('Invalid polygon points') + return + } + + // Get the selected edge points + const p1 = points[edgeIndex] + const p2 = points[(edgeIndex + 1) % points.length] + + // Calculate mid point of the selected edge + const midX = (p1.x + p2.x) / 2 + const midY = (p1.y + p2.y) / 2 + + // Calculate the perpendicular vector (for the roof ridge) + const dx = p2.x - p1.x + const dy = p2.y - p1.y + const length = Math.sqrt(dx * dx + dy * dy) + + // Normal vector (perpendicular to the edge) + const nx = -dy / length + const ny = dx / length + + // Calculate the ridge point (extending inward from the middle of the edge) + const ridgeLength = length * 0.4 // Adjust this factor as needed + const ridgeX = midX + nx * ridgeLength + const ridgeY = midY + ny * ridgeLength + + // Create the new points for the roof + const newPoints = [...points] + newPoints.splice(edgeIndex + 1, 0, { x: ridgeX, y: ridgeY }) + + // Update the roof with new points + roof.set({ + points: newPoints, + // Ensure the polygon is re-rendered + dirty: true, + }) + + // Update the polygon's path + roof.setCoords() + + // Force a re-render of the canvas + canvas.renderAll() + + return roof +} +/** + * 사용 예제: 첫 번째 edge를 45도 각도로 변형 + * drawSkeletonWithTransformedEdges(roofId, canvas, textMode, [ + * { edgeIndex: 0, angleOffset: 45, splitRatio: 0.5 } + * ]); + */ + +class Advanced2DRoofBuilder extends SkeletonBuilder { + static Build2DRoofFromAdvancedProperties(geoJsonPolygon, edgeProperties) { + // 입력 데이터 검증 + if (!geoJsonPolygon || !Array.isArray(geoJsonPolygon) || geoJsonPolygon.length === 0) { + throw new Error('geoJsonPolygon이 유효하지 않습니다') + } + + if (!edgeProperties || !Array.isArray(edgeProperties)) { + throw new Error('edgeProperties가 유효하지 않습니다') + } + + console.log('입력 검증 통과') + console.log('geoJsonPolygon:', geoJsonPolygon) + console.log('edgeProperties:', edgeProperties) + + // 1. 입력 폴리곤을 edgeProperties에 따라 수정 + const modifiedPolygon = this.preprocessPolygonByEdgeTypes(geoJsonPolygon, edgeProperties) + + // 2. 수정된 폴리곤으로 skeleton 생성 + const skeleton = SkeletonBuilder.BuildFromGeoJSON([[modifiedPolygon]]) + + if (!skeleton || !skeleton.Edges) { + throw new Error('Skeleton 생성 실패') + } + + // 3. Edge 분석 + const edgeAnalysis = this.analyzeAdvancedEdgeTypes(edgeProperties) + + return { + skeleton: skeleton, + original_polygon: geoJsonPolygon, + modified_polygon: modifiedPolygon, + roof_type: edgeAnalysis.roof_type, + edge_analysis: edgeAnalysis, + } + } + + /** + * ✅ 안전한 폴리곤 전처리 + */ + static preprocessPolygonByEdgeTypes(geoJsonPolygon, edgeProperties) { + try { + const originalRing = geoJsonPolygon + + if (!Array.isArray(originalRing) || originalRing.length < 4) { + throw new Error('외곽선이 유효하지 않습니다') + } + + const modifiedRing = originalRing.map((point) => { + if (!Array.isArray(point) || point.length < 2) { + throw new Error('좌표점 형식이 잘못되었습니다') + } + return [point[0], point[1]] + }) + + const isClosedPolygon = this.isPolygonClosed(modifiedRing) + if (isClosedPolygon) { + modifiedRing.pop() + } + + const actualEdgeCount = modifiedRing.length + const edgeCountToProcess = Math.min(edgeProperties.length, actualEdgeCount) + + for (let i = 0; i < edgeCountToProcess; i++) { + const edgeProp = edgeProperties[i] + const edgeType = edgeProp?.edge_type + + console.log(`Processing edge ${i}: ${edgeType}`) + + try { + switch (edgeType) { + case 'EAVES': + // ✅ 수정: 처마는 기본 상태이므로 수정하지 않음 + console.log(`Edge ${i}: EAVES - 기본 처마 상태 유지`) + break + + case 'WALL': + // ✅ 수정: 처마를 벽으로 변경 + this.transformEavesToWall(modifiedRing, i, edgeProp) + break + + case 'GABLE': + // ✅ 수정: 처마를 케라바로 변경 + this.transformEavesToGable(modifiedRing, i, edgeProp) + break + + default: + console.warn(`알 수 없는 edge 타입: ${edgeType}, 기본 EAVES로 처리`) + } + } catch (edgeError) { + console.error(`Edge ${i} 처리 중 오류:`, edgeError) + } + } + + const finalPolygon = this.prepareFinalPolygon(modifiedRing) + return finalPolygon + } catch (error) { + console.error('폴리곤 전처리 오류:', error) + throw error + } + } + + /** + * ✅ 처마를 벽으로 변경 (내부로 수축) + */ + static transformEavesToWall(ring, edgeIndex, edgeProp) { + console.log(`transformEavesToWall: edgeIndex=${edgeIndex}`) + + if (!ring || !Array.isArray(ring)) { + console.error('ring이 배열이 아니거나 undefined입니다') + return + } + + const totalPoints = ring.length + + if (edgeIndex < 0 || edgeIndex >= totalPoints) { + console.error(`edgeIndex ${edgeIndex}가 범위를 벗어났습니다`) + return + } + + const p1 = ring[edgeIndex] + const nextIndex = (edgeIndex + 1) % totalPoints + const p2 = ring[nextIndex] + + if (!Array.isArray(p1) || p1.length < 2 || !Array.isArray(p2) || p2.length < 2) { + console.error('점 형식이 잘못되었습니다') + return + } + + try { + // 폴리곤 중심 계산 + const centerX = ring.reduce((sum, p) => sum + p[0], 0) / totalPoints + const centerY = ring.reduce((sum, p) => sum + p[1], 0) / totalPoints + + // edge 중점 + const midX = (p1[0] + p2[0]) / 2 + const midY = (p1[1] + p2[1]) / 2 + + // 내향 방향 (처마 → 벽: 안쪽으로 수축) + const dirX = centerX - midX + const dirY = centerY - midY + const length = Math.sqrt(dirX * dirX + dirY * dirY) + + if (length < 0.001) { + console.warn('내향 방향 벡터 길이가 거의 0입니다') + return + } + + const unitX = dirX / length + const unitY = dirY / length + const shrinkDistance = edgeProp.shrink_distance || 0.8 // 벽 수축 거리 + + // 점들을 내부로 이동 + ring[edgeIndex] = [p1[0] + unitX * shrinkDistance, p1[1] + unitY * shrinkDistance] + + ring[nextIndex] = [p2[0] + unitX * shrinkDistance, p2[1] + unitY * shrinkDistance] + + console.log(`✅ WALL: Edge ${edgeIndex} 내부로 수축 완료 (${shrinkDistance})`) + } catch (calcError) { + console.error('벽 변환 계산 중 오류:', calcError) + } + } + + /** + * ✅ 처마를 케라바로 변경 (특별한 형태로 변형) + */ + // static transformEavesToGable(ring, edgeIndex, edgeProp) { + // console.log(`transformEavesToGable: edgeIndex=${edgeIndex}`); + // + // // 안전성 검증 + // if (!ring || !Array.isArray(ring)) { + // console.error('ring이 배열이 아니거나 undefined입니다'); + // return; + // } + // + // const totalPoints = ring.length; + // + // if (edgeIndex < 0 || edgeIndex >= totalPoints) { + // console.error(`edgeIndex ${edgeIndex}가 범위를 벗어났습니다`); + // return; + // } + // + // const p1 = ring[edgeIndex]; + // const nextIndex = (edgeIndex + 1) % totalPoints; + // const p2 = ring[nextIndex]; + // + // if (!Array.isArray(p1) || p1.length < 2 || + // !Array.isArray(p2) || p2.length < 2) { + // console.error('점 형식이 잘못되었습니다'); + // return; + // } + // + // try { + // // 케라바 변형: edge를 직선화하고 약간 내부로 이동 + // const centerX = ring.reduce((sum, p) => sum + p[0], 0) / totalPoints; + // const centerY = ring.reduce((sum, p) => sum + p[1], 0) / totalPoints; + // + // const midX = (p1[0] + p2[0]) / 2; + // const midY = (p1[1] + p2[1]) / 2; + // + // // 내향 방향으로 약간 이동 + // const dirX = centerX - midX; + // const dirY = centerY - midY; + // const length = Math.sqrt(dirX * dirX + dirY * dirY); + // + // if (length < 0.001) { + // console.warn('내향 방향 벡터 길이가 거의 0입니다'); + // return; + // } + // + // const unitX = dirX / length; + // const unitY = dirY / length; + // const gableInset = edgeProp.gable_inset || 0.3; // 케라바 안쪽 이동 거리 + // + // // edge의 방향 벡터 + // const edgeVecX = p2[0] - p1[0]; + // const edgeVecY = p2[1] - p1[1]; + // + // // 새로운 중점 (안쪽으로 이동) + // const newMidX = midX + unitX * gableInset; + // const newMidY = midY + unitY * gableInset; + // + // // 케라바를 위한 직선화된 점들 + // ring[edgeIndex] = [ + // newMidX - edgeVecX * 0.5, + // newMidY - edgeVecY * 0.5 + // ]; + // + // ring[nextIndex] = [ + // newMidX + edgeVecX * 0.5, + // newMidY + edgeVecY * 0.5 + // ]; + // + // console.log(`✅ GABLE: Edge ${edgeIndex} 케라바 변형 완료`); + // + // } catch (calcError) { + // console.error('케라바 변환 계산 중 오류:', calcError); + // } + // } + + static transformEavesToGable(ring, edgeIndex, edgeProp) { + // ✅ 캐라바면을 위한 특별 처리 + // 해당 edge를 "직선 제약 조건"으로 만들어야 함 + + const p1 = ring[edgeIndex] + const nextIndex = (edgeIndex + 1) % ring.length + const p2 = ring[nextIndex] + + // 캐라바면: edge를 완전히 직선으로 고정 + // 이렇게 하면 skeleton이 이 edge에 수직으로만 생성됨 + + // 중간점들을 제거하여 직선화 + const midX = (p1[0] + p2[0]) / 2 + const midY = (p1[1] + p2[1]) / 2 + + // 캐라바 edge를 단순 직선으로 만들어 + // SkeletonBuilder가 여기서 직선 skeleton을 생성하도록 유도 + console.log(`✅ GABLE: Edge ${edgeIndex}를 직선 캐라바로 설정`) + } + + // analyzeAdvancedEdgeTypes도 수정 + static analyzeAdvancedEdgeTypes(edgeProperties) { + const eavesEdges = [] // 기본 처마 (수정 안함) + const wallEdges = [] // 처마→벽 변경 + const gableEdges = [] // 처마→케라바 변경 + + edgeProperties.forEach((prop, i) => { + switch (prop?.edge_type) { + case 'EAVES': + eavesEdges.push(i) + break + case 'WALL': + wallEdges.push(i) + break + case 'GABLE': + gableEdges.push(i) + break + default: + console.warn(`Edge ${i}: 알 수 없는 타입 ${prop?.edge_type}, 기본 EAVES로 처리`) + eavesEdges.push(i) + } + }) + + let roofType + if (wallEdges.length === 0 && gableEdges.length === 0) { + roofType = 'pavilion' // 모든 면이 처마 + } else if (wallEdges.length === 4 && gableEdges.length === 0) { + roofType = 'hipped' // 모든 면이 벽 + } else if (gableEdges.length === 2 && wallEdges.length === 2) { + roofType = 'gabled' // 박공지붕 + } else { + roofType = 'complex' // 복합지붕 + } + + return { + roof_type: roofType, + eaves_edges: eavesEdges, // 기본 처마 + wall_edges: wallEdges, // 처마→벽 + gable_edges: gableEdges, // 처마→케라바 + } + } + + /** + * ✅ 폴리곤이 닫혀있는지 확인 + */ + static isPolygonClosed(ring) { + if (!ring || ring.length < 2) return false + + const firstPoint = ring[0] + const lastPoint = ring[ring.length - 1] + + const tolerance = 0.0001 // 부동소수점 허용 오차 + + return Math.abs(firstPoint[0] - lastPoint[0]) < tolerance && Math.abs(firstPoint[1] - lastPoint[1]) < tolerance + } + + /** + * ✅ BuildFromGeoJSON용 최종 polygon 준비 + */ + static prepareFinalPolygon(ring) { + // 1. 최소 점 개수 확인 + if (ring.length < 3) { + throw new Error(`폴리곤 점이 부족합니다: ${ring.length}개 (최소 3개 필요)`) + } + + // 2. 닫힌 폴리곤인지 다시 확인 + const isClosed = this.isPolygonClosed(ring) + + if (isClosed) { + console.log('여전히 닫힌 폴리곤입니다. 마지막 점 제거') + return ring.slice(0, -1) // 마지막 점 제거 + } + + // 3. 열린 폴리곤이면 그대로 반환 + console.log('열린 폴리곤 상태로 BuildFromGeoJSON에 전달') + return [...ring] // 복사본 반환 + } + + static expandEdgeForEaves(ring, edgeIndex, overhang) { + console.log(`expandEdgeForEaves 시작: edgeIndex=${edgeIndex}, overhang=${overhang}`) + + // 안전성 검증 + if (!ring || !Array.isArray(ring)) { + console.error('ring이 배열이 아니거나 undefined입니다') + return + } + + const totalPoints = ring.length - 1 // 마지막 중복점 제외 + console.log(`ring 길이: ${ring.length}, totalPoints: ${totalPoints}`) + + if (totalPoints <= 2) { + console.error('ring 점 개수가 부족합니다') + return + } + + if (edgeIndex < 0 || edgeIndex >= totalPoints) { + console.error(`edgeIndex ${edgeIndex}가 범위 [0, ${totalPoints - 1}]을 벗어났습니다`) + return + } + + // 안전한 점 접근 + const p1 = ring[edgeIndex] + const nextIndex = (edgeIndex + 1) % totalPoints + const p2 = ring[nextIndex] + + console.log(`p1 (index ${edgeIndex}):`, p1) + console.log(`p2 (index ${nextIndex}):`, p2) + + if (!Array.isArray(p1) || p1.length < 2 || !Array.isArray(p2) || p2.length < 2) { + console.error('점 형식이 잘못되었습니다') + console.error('p1:', p1, 'p2:', p2) + return + } + + if (typeof p1[0] !== 'number' || typeof p1[1] !== 'number' || typeof p2[0] !== 'number' || typeof p2[1] !== 'number') { + console.error('좌표값이 숫자가 아닙니다') + return + } + + try { + // 폴리곤 중심 계산 (마지막 중복점 제외) + const validPoints = ring.slice(0, totalPoints) + const centerX = validPoints.reduce((sum, p) => sum + p[0], 0) / totalPoints + const centerY = validPoints.reduce((sum, p) => sum + p[1], 0) / totalPoints + + console.log(`중심점: (${centerX}, ${centerY})`) + + // edge 중점 + const midX = (p1[0] + p2[0]) / 2 + const midY = (p1[1] + p2[1]) / 2 + + // 외향 방향 + const dirX = midX - centerX + const dirY = midY - centerY + const length = Math.sqrt(dirX * dirX + dirY * dirY) + + if (length < 0.001) { + // 거의 0인 경우 + console.warn('외향 방향 벡터 길이가 거의 0입니다, 확장하지 않습니다') + return + } + + const unitX = dirX / length + const unitY = dirY / length + + // 안전하게 점 수정 + ring[edgeIndex] = [p1[0] + unitX * overhang, p1[1] + unitY * overhang] + + ring[nextIndex] = [p2[0] + unitX * overhang, p2[1] + unitY * overhang] + + console.log(`✅ EAVES: Edge ${edgeIndex} 확장 완료 (${overhang})`) + console.log('수정된 p1:', ring[edgeIndex]) + console.log('수정된 p2:', ring[nextIndex]) + } catch (calcError) { + console.error('계산 중 오류:', calcError) + } + } + + /** + * ✅ 안전한 박공 조정 + */ + static adjustEdgeForGable(ring, edgeIndex, gableHeight) { + console.log(`adjustEdgeForGable 시작: edgeIndex=${edgeIndex}`) + + // 안전성 검증 (동일한 패턴) + if (!ring || !Array.isArray(ring)) { + console.error('ring이 배열이 아니거나 undefined입니다') + return + } + + const totalPoints = ring.length - 1 + + if (totalPoints <= 2) { + console.error('ring 점 개수가 부족합니다') + return + } + + if (edgeIndex < 0 || edgeIndex >= totalPoints) { + console.error(`edgeIndex ${edgeIndex}가 범위를 벗어났습니다`) + return + } + + const p1 = ring[edgeIndex] + const nextIndex = (edgeIndex + 1) % totalPoints + const p2 = ring[nextIndex] + + if (!Array.isArray(p1) || p1.length < 2 || !Array.isArray(p2) || p2.length < 2) { + console.error('점 형식이 잘못되었습니다') + return + } + + try { + const validPoints = ring.slice(0, totalPoints) + const centerX = validPoints.reduce((sum, p) => sum + p[0], 0) / totalPoints + const centerY = validPoints.reduce((sum, p) => sum + p[1], 0) / totalPoints + + const midX = (p1[0] + p2[0]) / 2 + const midY = (p1[1] + p2[1]) / 2 + + const dirX = centerX - midX + const dirY = centerY - midY + const length = Math.sqrt(dirX * dirX + dirY * dirY) + + if (length < 0.001) { + console.warn('중심 방향 벡터 길이가 거의 0입니다') + return + } + + const unitX = dirX / length + const unitY = dirY / length + const insetDistance = 0.5 + + const newMidX = midX + unitX * insetDistance + const newMidY = midY + unitY * insetDistance + + const edgeVecX = p2[0] - p1[0] + const edgeVecY = p2[1] - p1[1] + + ring[edgeIndex] = [newMidX - edgeVecX * 0.5, newMidY - edgeVecY * 0.5] + + ring[nextIndex] = [newMidX + edgeVecX * 0.5, newMidY + edgeVecY * 0.5] + + console.log(`✅ GABLE: Edge ${edgeIndex} 조정 완료`) + } catch (calcError) { + console.error('박공 조정 계산 중 오류:', calcError) + } + } + + static processGableSkeleton(skeleton, gableEdgeIndex, originalPolygon) { + // ✅ Gable edge에 해당하는 skeleton 정점들을 찾아서 + // 해당 edge의 중점으로 강제 이동 + + const gableEdge = originalPolygon[gableEdgeIndex] + const edgeMidpoint = calculateMidpoint(gableEdge) + + // skeleton 정점들을 edge 중점으로 "압축" + skeleton.Edges.forEach((edge) => { + if (isRelatedToGableEdge(edge, gableEdgeIndex)) { + // 해당 edge 관련 skeleton 정점들을 직선으로 정렬 + straightenSkeletonToEdge(edge, edgeMidpoint) + } + }) + } + + // ✅ Gable edge에 제약 조건을 추가하여 skeleton 생성 + static buildConstrainedSkeleton(polygon, edgeConstraints) { + const constraints = edgeConstraints + .map((constraint) => { + if (constraint.type === 'GABLE') { + return { + edgeIndex: constraint.edgeIndex, + forceLinear: true, // 직선 강제 + fixToMidpoint: true, // 중점 고정 + } + } + return null + }) + .filter((c) => c !== null) + + // 제약 조건이 적용된 skeleton 생성 + return SkeletonBuilder.build(polygon, constraints) + } +} /** * 지붕 폴리곤의 좌표를 스켈레톤 생성에 적합한 형태로 전처리합니다. @@ -41,262 +1599,22 @@ const preprocessPolygonCoordinates = (initialPoints) => { return coordinates; }; -/** - * 생성된 스켈레톤 엣지들로부터 중복되지 않는 고유한 선분 목록을 추출합니다. - * 각 엣지는 작은 폴리곤으로 구성되며, 인접한 엣지들은 선분을 공유하므로 중복 제거가 필요합니다. - * @param {Array} skeletonEdges - SkeletonBuilder로부터 반환된 엣지 배열 - * @returns {Array} 고유한 선분 객체의 배열 (e.g., [{x1, y1, x2, y2, edgeIndex}, ...]) - */ -const extractUniqueLinesFromEdges = (skeletonEdges) => { - const uniqueLines = new Set(); - const linesToDraw = []; +const isSameLine = (edgeStartX, edgeStartY, edgeEndX, edgeEndY, baseLine) => { + const tolerance = 0.1 - skeletonEdges.forEach((edge, edgeIndex) => { - // 엣지 데이터가 유효한 폴리곤인지 확인 - if (!edge || !edge.Polygon || edge.Polygon.length < 2) { - console.warn(`Edge ${edgeIndex} has invalid polygon data:`, edge.Polygon); - return; - } + // 시계방향 매칭 (edgeResult.Edge.Begin -> End와 baseLine.x1 -> x2) + const clockwiseMatch = + Math.abs(edgeStartX - baseLine.x1) < tolerance && + Math.abs(edgeStartY - baseLine.y1) < tolerance && + Math.abs(edgeEndX - baseLine.x2) < tolerance && + Math.abs(edgeEndY - baseLine.y2) < tolerance - // 폴리곤의 각 변을 선분으로 변환 - for (let i = 0; i < edge.Polygon.length; i++) { - const p1 = edge.Polygon[i]; - const p2 = edge.Polygon[(i + 1) % edge.Polygon.length]; // 다음 점 (마지막 점은 첫 점과 연결) + // 반시계방향 매칭 (edgeResult.Edge.Begin -> End와 baseLine.x2 -> x1) + const counterClockwiseMatch = + Math.abs(edgeStartX - baseLine.x2) < tolerance && + Math.abs(edgeStartY - baseLine.y2) < tolerance && + Math.abs(edgeEndX - baseLine.x1) < tolerance && + Math.abs(edgeEndY - baseLine.y1) < tolerance - // 선분의 시작점과 끝점을 일관된 순서로 정렬하여 정규화된 문자열 키 생성 - // 이를 통해 동일한 선분(방향만 다른 경우 포함)을 식별하고 중복을 방지 - const normalizedLineKey = p1.X < p2.X || (p1.X === p2.X && p1.Y < p2.Y) - ? `${p1.X},${p1.Y}-${p2.X},${p2.Y}` - : `${p2.X},${p2.Y}-${p1.X},${p1.Y}`; - - // Set에 정규화된 키가 없으면 새로운 선분으로 간주하고 추가 - if (!uniqueLines.has(normalizedLineKey)) { - uniqueLines.add(normalizedLineKey); - linesToDraw.push({ - x1: p1.X, y1: p1.Y, - x2: p2.X, y2: p2.Y, - edgeIndex - }); - } - } - }); - - return linesToDraw; -}; - -/** - * 지붕 폴리곤의 스켈레톤(중심선)을 생성하고 캔버스에 그립니다. - * @param {string} roofId - 대상 지붕 객체의 ID - * @param {fabric.Canvas} canvas - Fabric.js 캔버스 객체 - * @param {string} textMode - 텍스트 표시 모드 - */ -export const drawSkeletonRidgeRoof = (roofId, canvas, textMode) => { - try { - const roof = canvas?.getObjects().find((object) => object.id === roofId); - const wall = canvas.getObjects().find((object) => object.name === POLYGON_TYPE.WALL && object.attributes.roofId === roofId) - - if (!roof) { - console.error(`Roof with id "${roofId}" not found.`); - return; - } - - - // 1. 기존 스켈레톤 라인 제거 - const existingSkeletonLines = canvas.getObjects().filter(obj => - obj.parentId === roofId && obj.attributes?.type === 'skeleton' - ); - existingSkeletonLines.forEach(line => canvas.remove(line)); - - // 2. 지붕 폴리곤 좌표 전처리 - const coordinates = preprocessPolygonCoordinates(roof.points); - if (coordinates.length < 3) { - console.warn("Polygon has less than 3 unique points. Cannot generate skeleton."); - return; - } - - /** 외벽선 */ - const baseLines = wall.baseLines.filter((line) => line.attributes.planeSize > 0) - const baseLinePoints = baseLines.map((line) => ({ x: line.x1, y: line.y1 })) - - - // 3. 스켈레톤 생성 - const multiPolygon = [[coordinates]]; // GeoJSON MultiPolygon 형식 - const skeleton = SkeletonBuilder.BuildFromGeoJSON(multiPolygon); - - if (!skeleton || !skeleton.Edges || skeleton.Edges.length === 0) { - console.log('No valid skeleton edges found for this roof.'); - return; - } - - // 4. 스켈레톤 엣지에서 고유 선분 추출 - const linesToDraw = extractUniqueLinesFromEdges(skeleton.Edges); - - // 5. 캔버스에 스켈레톤 라인 렌더링 - const skeletonLines = []; - const outerLines = pointsToLines(coordinates); - - for (const baseLine of baseLines) { - const { type } = baseLine.get("attributes"); - - if(type === LINE_TYPE.WALLLINE.EAVES) { - - - - }else if(type === LINE_TYPE.WALLLINE.RIDGE) { - - } - } - - linesToDraw.forEach((line, index) => { - // 외곽선과 겹치는 스켈레톤 라인은 그리지 않음 - const isOverlapping = outerLines.some(outerLine => linesOverlap(line, outerLine)); - if (isOverlapping) { - // Array.find()를 사용하여 baseLines 배열에서 일치하는 라인을 찾습니다. - const foundBaseLine = baseLines.filter(baseLine => { - // baseLine (fabric.QLine)에서 좌표를 추출합니다. - const { p1: baseP1, p2: baseP2 } = getPointsFromQLine(baseLine); - - const attributes = baseLine.get('attributes'); - - // 2. 속성 객체에서 type 값을 추출합니다. - const type = attributes.type; - - // 이제 'type' 변수를 조건문 등에서 사용할 수 있습니다. - console.log('라인 타입:', type); - - // lineToDraw의 좌표 (p1, p2)와 baseLine의 좌표를 비교합니다. - // 라인 방향이 다를 수 있으므로 정방향과 역방향 모두 확인합니다. - - // 정방향 일치: (p1 -> p2) == (baseP1 -> baseP2) - const forwardMatch = - line.x1 === baseP1.x && line.y1 === baseP1.y && - line.x2 === baseP2.x && line.y2 === baseP2.y; - - // 역방향 일치: (p1 -> p2) == (baseP2 -> baseP1) - const reverseMatch = - line.x1 === baseP2.x && line.y1 === baseP2.y && - line.x2 === baseP1.x && line.y2 === baseP1.y; - - return forwardMatch || reverseMatch; - }); - - // 일치하는 라인을 찾았는지 확인 - if (foundBaseLine) { - console.log(`linesToDraw[${index}]와 일치하는 라인을 찾았습니다:`, foundBaseLine); - // 여기서 foundBaseLine을 사용하여 필요한 작업을 수행할 수 있습니다. - } else { - - console.log(`linesToDraw[${index}]에 대한 일치하는 라인을 찾지 못했습니다.`); - } - console.log(`Skeleton line (edge ${line.edgeIndex}) is overlapping with a baseLine. It will not be drawn.`); - return; - } - - const isDiagonal = Math.abs(line.x1 - line.x2) > 1e-6 && Math.abs(line.y1 - line.y2) > 1e-6; - const isEaves = baseLinePoints.some(point => linesOverlap(line, point)); - - const skeletonLine = new QLine([line.x1, line.y1, line.x2, line.y2], { - parentId: roofId, - stroke: '#ff0000', // 스켈레톤은 빨간색으로 표시 - strokeWidth: 2, - strokeDashArray: [3, 3], // 점선으로 표시 - name: isDiagonal ? LINE_TYPE.SUBLINE.HIP : LINE_TYPE.SUBLINE.RIDGE, - fontSize: roof.fontSize || 12, - textMode: textMode, - attributes: { - roofId: roofId, - type: LINE_TYPE.WALLLINE.EAVES, // 스켈레톤 타입 식별자 - skeletonIndex: line.edgeIndex, - lineIndex: index, - planeSize: calcLinePlaneSize(line), - actualSize: calcLinePlaneSize(line), // 실제 크기는 필요시 별도 계산 로직 추가 - }, - }); - - skeletonLine.startPoint = { x: line.x1, y: line.y1 }; - skeletonLine.endPoint = { x: line.x2, y: line.y2 }; - - skeletonLines.push(skeletonLine); - canvas.add(skeletonLine); - - // 6. roof 객체에 스켈레톤 라인 정보 업데이트 - roof.innerLines = [...(roof.innerLines || []), ...skeletonLines]; - skeletonLines.forEach(line => line.bringToFront()); - - canvas.renderAll(); - console.log(`Successfully drew ${linesToDraw.length} unique skeleton lines from ${skeleton.Edges.length} polygons.`); - - - - }); - - - - } catch (error) { - console.error('An error occurred while generating the skeleton:', error); - } -}; - -/** - * 두 선분이 동일 선상에 있으면서 서로 겹치는지 확인합니다. - * @param {object} line1 - 첫 번째 선분 객체 { x1, y1, x2, y2 } - * @param {object} line2 - 두 번째 선분 객체 { x1, y1, x2, y2 } - * @param {number} [epsilon=1e-6] - 부동 소수점 계산 오차 허용 범위 - * @returns {boolean} 동일 선상에서 겹치면 true, 아니면 false - */ -function linesOverlap(line1, line2, epsilon = 1e-6) { - // 1. 세 점의 면적 계산을 통해 동일 선상에 있는지 확인 (면적이 0에 가까우면 동일 선상) - const area1 = (line1.x2 - line1.x1) * (line2.y1 - line1.y1) - (line2.x1 - line1.x1) * (line1.y2 - line1.y1); - const area2 = (line1.x2 - line1.x1) * (line2.y2 - line1.y1) - (line2.x2 - line1.x1) * (line1.y2 - line1.y1); - - if (Math.abs(area1) > epsilon || Math.abs(area2) > epsilon) { - return false; // 동일 선상에 없음 - } - - // 2. 동일 선상에 있을 경우, 두 선분의 범위(x, y)가 겹치는지 확인 - const xOverlap = Math.max(line1.x1, line1.x2) >= Math.min(line2.x1, line2.x2) && - Math.max(line2.x1, line2.x2) >= Math.min(line1.x1, line1.x2); - - const yOverlap = Math.max(line1.y1, line1.y2) >= Math.min(line2.y1, line2.y2) && - Math.max(line2.y1, line2.y2) >= Math.min(line1.y1, line1.y2); - - return xOverlap && yOverlap; -} - -/** - * 점들의 배열을 닫힌 폴리곤을 구성하는 선분 객체의 배열로 변환합니다. - * @param {Array>} points - [x, y] 형태의 점 좌표 배열 - * @returns {Array<{x1: number, y1: number, x2: number, y2: number}>} 선분 객체 배열 - */ -function pointsToLines(points) { - if (!points || points.length < 2) { - return []; - } - - const lines = []; - const numPoints = points.length; - - for (let i = 0; i < numPoints; i++) { - const startPoint = points[i]; - const endPoint = points[(i + 1) % numPoints]; // 마지막 점은 첫 번째 점과 연결 - - lines.push({ - x1: startPoint[0], - y1: startPoint[1], - x2: endPoint[0], - y2: endPoint[1], - }); - } - - return lines; -} - - -/** - * fabric.QLine에서 시작점과 끝점을 가져옵니다. - * @param {fabric.QLine} line - * @returns {{p1: {x: number, y: number}, p2: {x: number, y: number}}} - */ -export const getPointsFromQLine = (line) => { - return { p1: { x: line.x1, y: line.y1 }, p2: { x: line.x2, y: line.y2 } }; -}; \ No newline at end of file + return clockwiseMatch || counterClockwiseMatch +} \ No newline at end of file From a8d9988f24ef216470e9bb1635b004bf2e1ca8ba Mon Sep 17 00:00:00 2001 From: "hyojun.choi" Date: Fri, 19 Sep 2025 10:19:49 +0900 Subject: [PATCH 11/20] =?UTF-8?q?=EC=A7=80=EB=B6=95=20=ED=9A=8C=EC=A0=84?= =?UTF-8?q?=20=EC=8B=9C=20=EB=82=B4=EB=B6=80=20=EC=98=A4=EB=B8=8C=EC=A0=9D?= =?UTF-8?q?=ED=8A=B8(=EA=B0=9C=EA=B5=AC,=20=EB=8F=84=EB=A8=B8,=20=EA=B7=B8?= =?UTF-8?q?=EB=A6=BC=EC=9E=90)=20=ED=9A=8C=EC=A0=84=20=EB=B0=98=EC=98=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/components/fabric/QPolygon.js | 4 +- src/hooks/module/useModuleBasicSetting.js | 7 +- src/hooks/object/useObjectBatch.js | 6 +- src/hooks/surface/useSurfaceShapeBatch.js | 101 +++++++--- src/util/fabric-extensions.js | 232 ++++++++++++++++++++++ 5 files changed, 315 insertions(+), 35 deletions(-) create mode 100644 src/util/fabric-extensions.js diff --git a/src/components/fabric/QPolygon.js b/src/components/fabric/QPolygon.js index 73bd6fd9..bfc283c7 100644 --- a/src/components/fabric/QPolygon.js +++ b/src/components/fabric/QPolygon.js @@ -714,7 +714,7 @@ export const QPolygon = fabric.util.createClass(fabric.Polygon, { }, inPolygonImproved(point) { - const vertices = this.points + const vertices = this.getCurrentPoints() let inside = false const testX = Number(point.x.toFixed(this.toFixed)) const testY = Number(point.y.toFixed(this.toFixed)) @@ -726,7 +726,7 @@ export const QPolygon = fabric.util.createClass(fabric.Polygon, { const yj = Number(vertices[j].y.toFixed(this.toFixed)) // 점이 정점 위에 있는지 확인 - if (Math.abs(xi - testX) < 0.01 && Math.abs(yi - testY) < 0.01) { + if (Math.abs(xi - testX) <= 0.01 && Math.abs(yi - testY) <= 0.01) { return true } diff --git a/src/hooks/module/useModuleBasicSetting.js b/src/hooks/module/useModuleBasicSetting.js index 933a423e..a0392407 100644 --- a/src/hooks/module/useModuleBasicSetting.js +++ b/src/hooks/module/useModuleBasicSetting.js @@ -15,6 +15,7 @@ import { } from '@/store/canvasAtom' import { calculateVisibleModuleHeight, getDegreeByChon, polygonToTurfPolygon, rectToPolygon, toFixedWithoutRounding } from '@/util/canvas-util' +import '@/util/fabric-extensions' // fabric 객체들에 getCurrentPoints 메서드 추가 import { basicSettingState, roofDisplaySelector } from '@/store/settingAtom' import offsetPolygon, { calculateAngle, createLinesFromPolygon } from '@/util/qpolygon-utils' import { QPolygon } from '@/components/fabric/QPolygon' @@ -265,14 +266,14 @@ export function useModuleBasicSetting(tabNum) { batchObjects.forEach((obj) => { //도머일때 if (obj.name === BATCH_TYPE.TRIANGLE_DORMER || obj.name === BATCH_TYPE.PENTAGON_DORMER) { - const groupPoints = obj.groupPoints + const groupPoints = obj.getCurrentPoints() const offsetObjects = offsetPolygon(groupPoints, 10) const dormerOffset = new QPolygon(offsetObjects, batchObjectOptions) dormerOffset.setViewLengthText(false) canvas.add(dormerOffset) //모듈설치면 만들기 } else { //개구, 그림자일때 - const points = obj.points + const points = obj.getCurrentPoints() const offsetObjects = offsetPolygon(points, 10) const offset = new QPolygon(offsetObjects, batchObjectOptions) offset.setViewLengthText(false) @@ -319,7 +320,7 @@ export function useModuleBasicSetting(tabNum) { const margin = moduleSelectionData.common.margin ? moduleSelectionData.common.margin : 200 //육지붕일때는 그냥 하드코딩 - offsetPoints = offsetPolygon(roof.points, -Number(margin) / 10) //육지붕일때 + offsetPoints = offsetPolygon(roof.getCurrentPoints(), -Number(margin) / 10) //육지붕일때 } else { //육지붕이 아닐때 if (allPointsOutside) { diff --git a/src/hooks/object/useObjectBatch.js b/src/hooks/object/useObjectBatch.js index 959a0798..ef2cf587 100644 --- a/src/hooks/object/useObjectBatch.js +++ b/src/hooks/object/useObjectBatch.js @@ -675,6 +675,8 @@ export function useObjectBatch({ isHidden, setIsHidden }) { } }) + objectGroup.recalculateGroupPoints() + isDown = false initEvent() // dbClickEvent() @@ -1426,7 +1428,7 @@ export function useObjectBatch({ isHidden, setIsHidden }) { //그림자는 아무데나 설치 할 수 있게 해달라고 함 if (obj.name === BATCH_TYPE.OPENING) { - const turfObject = pointsToTurfPolygon(obj.points) + const turfObject = pointsToTurfPolygon(obj.getCurrentPoints()) if (turf.booleanWithin(turfObject, turfSurface)) { obj.set({ @@ -1459,7 +1461,7 @@ export function useObjectBatch({ isHidden, setIsHidden }) { const calcLeft = obj.left - originLeft const calcTop = obj.top - originTop - const currentDormerPoints = obj.groupPoints.map((item) => { + const currentDormerPoints = obj.getCurrentPoints().map((item) => { return { x: item.x + calcLeft, y: item.y + calcTop, diff --git a/src/hooks/surface/useSurfaceShapeBatch.js b/src/hooks/surface/useSurfaceShapeBatch.js index 1d0445d8..e42a7025 100644 --- a/src/hooks/surface/useSurfaceShapeBatch.js +++ b/src/hooks/surface/useSurfaceShapeBatch.js @@ -1439,44 +1439,89 @@ export function useSurfaceShapeBatch({ isHidden, setIsHidden }) { const rotateSurfaceShapeBatch = () => { if (currentObject) { - // 기존 관련 객체들 제거 - const relatedObjects = canvas - .getObjects() - .filter( - (obj) => - obj.parentId === currentObject.id || - (obj.name === 'lengthText' && obj.parentId === currentObject.id) || - (obj.name === 'arrow' && obj.parentId === currentObject.id), - ) - relatedObjects.forEach((obj) => canvas.remove(obj)) - - // 현재 회전값에 90도 추가 - const currentAngle = currentObject.angle || 0 - const newAngle = (currentAngle + 90) % 360 - const originWidth = currentObject.originWidth - const originHeight = currentObject.originHeight - // 회전 적용 (width/height 교체 제거로 도형 깨짐 방지) - currentObject.rotate(newAngle) - - // QPolygon 내부 구조 재구성 (선이 깨지는 문제 해결) - if (currentObject.type === 'QPolygon' && currentObject.lines) { - currentObject.initLines() + // 관련 객체들 찾기 + // arrow는 제거 + const arrow = canvas.getObjects().find((obj) => obj.parentId === currentObject.id && obj.name === 'arrow') + if (arrow) { + canvas.remove(arrow) } - currentObject.set({ - originWidth: originHeight, - originHeight: originWidth, + const relatedObjects = canvas.getObjects().filter((obj) => obj.parentId === currentObject.id) + + // 그룹화할 객체들 배열 (currentObject + relatedObjects) + const objectsToGroup = [currentObject, ...relatedObjects] + + // 기존 객체들을 캔버스에서 제거 + objectsToGroup.forEach((obj) => canvas.remove(obj)) + + // fabric.Group 생성 + const group = new fabric.Group(objectsToGroup, { + originX: 'center', + originY: 'center', }) - currentObject.setCoords() - currentObject.fire('modified') + // 그룹을 캔버스에 추가 + canvas.add(group) + // 현재 회전값에 90도 추가 + const currentAngle = group.angle || 0 + const newAngle = (currentAngle + 90) % 360 + + // 그룹 전체를 회전 + group.rotate(newAngle) + group.setCoords() + + // 그룹을 해제하고 개별 객체로 복원 + group._restoreObjectsState() + canvas.remove(group) + + // 개별 객체들을 다시 캔버스에 추가하고 처리 + group.getObjects().forEach((obj) => { + canvas.add(obj) + obj.setCoords() + + // currentObject인 경우 추가 처리 + if (obj.id === currentObject.id) { + const originWidth = obj.originWidth + const originHeight = obj.originHeight + + // QPolygon 내부 구조 재구성 (선이 깨지는 문제 해결) + if (obj.type === 'QPolygon' && obj.lines) { + obj.initLines() + } + + obj.set({ + originWidth: originHeight, + originHeight: originWidth, + }) + } else { + // relatedObject인 경우에도 필요한 처리 + if (obj.type === 'QPolygon' && obj.lines) { + obj.initLines() + } + if (obj.type === 'group') { + // 회전 후의 points를 groupPoints로 업데이트 + // getCurrentPoints를 직접 호출하지 말고 recalculateGroupPoints만 실행 + + obj.recalculateGroupPoints() + + obj._objects?.forEach((obj) => { + obj.initLines() + obj.fire('modified') + }) + } + } + }) + currentObject.fire('modified') // 화살표와 선 다시 그리기 drawDirectionArrow(currentObject) setTimeout(() => { setPolygonLinesActualSize(currentObject) changeSurfaceLineType(currentObject) - }, 200) + }, 500) + + // currentObject를 다시 선택 상태로 설정 + canvas.setActiveObject(currentObject) canvas.renderAll() } } diff --git a/src/util/fabric-extensions.js b/src/util/fabric-extensions.js new file mode 100644 index 00000000..9410f764 --- /dev/null +++ b/src/util/fabric-extensions.js @@ -0,0 +1,232 @@ +import { fabric } from 'fabric' + +/** + * fabric.Rect에 getCurrentPoints 메서드를 추가 + * QPolygon의 getCurrentPoints와 동일한 방식으로 변형된 현재 점들을 반환 + */ +fabric.Rect.prototype.getCurrentPoints = function () { + // 사각형의 네 모서리 점들을 계산 + const width = this.width + const height = this.height + + // 사각형의 로컬 좌표계에서의 네 모서리 점 + const points = [ + { x: -width / 2, y: -height / 2 }, // 좌상단 + { x: width / 2, y: -height / 2 }, // 우상단 + { x: width / 2, y: height / 2 }, // 우하단 + { x: -width / 2, y: height / 2 }, // 좌하단 + ] + + // 변형 매트릭스 계산 + const matrix = this.calcTransformMatrix() + + // 각 점을 변형 매트릭스로 변환 + return points.map(function (p) { + const point = new fabric.Point(p.x, p.y) + return fabric.util.transformPoint(point, matrix) + }) +} + +/** + * fabric.Group에 getCurrentPoints 메서드를 추가 (도머 그룹용) + * 그룹의 groupPoints를 다시 계산하여 반환 + */ +fabric.Group.prototype.getCurrentPoints = function () { + // groupPoints를 다시 계산 + + // 그룹에 groupPoints가 있으면 해당 점들을 사용 (도머의 경우) + if (this.groupPoints && Array.isArray(this.groupPoints)) { + const matrix = this.calcTransformMatrix() + console.log('this.groupPoints', this.groupPoints) + return this.groupPoints.map(function (p) { + const point = new fabric.Point(p.x, p.y) + return fabric.util.transformPoint(point, matrix) + }) + } + + // groupPoints가 없으면 바운딩 박스를 사용 + const bounds = this.getBoundingRect() + const points = [ + { x: bounds.left, y: bounds.top }, + { x: bounds.left + bounds.width, y: bounds.top }, + { x: bounds.left + bounds.width, y: bounds.top + bounds.height }, + { x: bounds.left, y: bounds.top + bounds.height }, + ] + + return points.map(function (p) { + return new fabric.Point(p.x, p.y) + }) +} + +/** + * fabric.Group에 groupPoints 재계산 메서드 추가 + * 그룹 내 모든 객체의 점들을 기반으로 groupPoints를 새로 계산 + * Convex Hull 알고리즘을 사용하여 가장 외곽의 점들만 반환 + */ +fabric.Group.prototype.recalculateGroupPoints = function () { + if (!this._objects || this._objects.length === 0) { + return + } + + let allPoints = [] + + // 그룹 내 모든 객체의 점들을 수집 + this._objects.forEach(function (obj) { + if (obj.getCurrentPoints && typeof obj.getCurrentPoints === 'function') { + // getCurrentPoints가 있는 객체는 해당 메서드 사용 + const objPoints = obj.getCurrentPoints() + allPoints = allPoints.concat(objPoints) + } else if (obj.points && Array.isArray(obj.points)) { + // QPolygon과 같이 points 배열이 있는 경우 + const pathOffset = obj.pathOffset || { x: 0, y: 0 } + const matrix = obj.calcTransformMatrix() + const transformedPoints = obj.points + .map(function (p) { + return new fabric.Point(p.x - pathOffset.x, p.y - pathOffset.y) + }) + .map(function (p) { + return fabric.util.transformPoint(p, matrix) + }) + allPoints = allPoints.concat(transformedPoints) + } else { + // 일반 객체는 바운딩 박스의 네 모서리 점 사용 + const bounds = obj.getBoundingRect() + const cornerPoints = [ + { x: bounds.left, y: bounds.top }, + { x: bounds.left + bounds.width, y: bounds.top }, + { x: bounds.left + bounds.width, y: bounds.top + bounds.height }, + { x: bounds.left, y: bounds.top + bounds.height }, + ] + allPoints = allPoints.concat( + cornerPoints.map(function (p) { + return new fabric.Point(p.x, p.y) + }), + ) + } + }) + + if (allPoints.length > 0) { + // Convex Hull 알고리즘을 사용하여 외곽 점들만 추출 + const convexHullPoints = this.getConvexHull(allPoints) + + // 그룹의 로컬 좌표계로 변환하기 위해 그룹의 역변환 적용 + const groupMatrix = this.calcTransformMatrix() + const invertedMatrix = fabric.util.invertTransform(groupMatrix) + + this.groupPoints = convexHullPoints.map(function (p) { + const localPoint = fabric.util.transformPoint(p, invertedMatrix) + return { x: localPoint.x, y: localPoint.y } + }) + } +} + +/** + * Graham Scan 알고리즘을 사용한 Convex Hull 계산 + * 점들의 집합에서 가장 외곽의 점들만 반환 + */ +fabric.Group.prototype.getConvexHull = function (points) { + if (points.length < 3) return points + + // 중복 점 제거 + const uniquePoints = [] + const seen = new Set() + + points.forEach(function (p) { + const key = `${Math.round(p.x * 10) / 10},${Math.round(p.y * 10) / 10}` + if (!seen.has(key)) { + seen.add(key) + uniquePoints.push({ x: p.x, y: p.y }) + } + }) + + if (uniquePoints.length < 3) return uniquePoints + + // 가장 아래쪽 점을 찾기 (y가 가장 작고, 같으면 x가 가장 작은 점) + let pivot = uniquePoints[0] + for (let i = 1; i < uniquePoints.length; i++) { + if (uniquePoints[i].y < pivot.y || (uniquePoints[i].y === pivot.y && uniquePoints[i].x < pivot.x)) { + pivot = uniquePoints[i] + } + } + + // 극각에 따라 정렬 + const sortedPoints = uniquePoints + .filter(function (p) { return p !== pivot }) + .sort(function (a, b) { + const angleA = Math.atan2(a.y - pivot.y, a.x - pivot.x) + const angleB = Math.atan2(b.y - pivot.y, b.x - pivot.x) + if (angleA !== angleB) return angleA - angleB + + // 각도가 같으면 거리로 정렬 + const distA = Math.pow(a.x - pivot.x, 2) + Math.pow(a.y - pivot.y, 2) + const distB = Math.pow(b.x - pivot.x, 2) + Math.pow(b.y - pivot.y, 2) + return distA - distB + }) + + // Graham Scan 실행 + const hull = [pivot] + + for (let i = 0; i < sortedPoints.length; i++) { + const current = sortedPoints[i] + + // 반시계방향이 아닌 점들 제거 + while (hull.length > 1) { + const p1 = hull[hull.length - 2] + const p2 = hull[hull.length - 1] + const cross = (p2.x - p1.x) * (current.y - p1.y) - (p2.y - p1.y) * (current.x - p1.x) + + if (cross > 0) break // 반시계방향이면 유지 + hull.pop() // 시계방향이면 제거 + } + + hull.push(current) + } + + return hull +} + +/** + * fabric.Triangle에 getCurrentPoints 메서드를 추가 + * 삼각형의 세 꼭짓점을 반환 + */ +fabric.Triangle.prototype.getCurrentPoints = function () { + const width = this.width + const height = this.height + + // 삼각형의 로컬 좌표계에서의 세 꼭짓점 + const points = [ + { x: 0, y: -height / 2 }, // 상단 중앙 + { x: -width / 2, y: height / 2 }, // 좌하단 + { x: width / 2, y: height / 2 }, // 우하단 + ] + + // 변형 매트릭스 계산 + const matrix = this.calcTransformMatrix() + + // 각 점을 변형 매트릭스로 변환 + return points.map(function (p) { + const point = new fabric.Point(p.x, p.y) + return fabric.util.transformPoint(point, matrix) + }) +} + +/** + * fabric.Polygon에 getCurrentPoints 메서드를 추가 (QPolygon이 아닌 일반 Polygon용) + * QPolygon과 동일한 방식으로 구현 + */ +if (!fabric.Polygon.prototype.getCurrentPoints) { + fabric.Polygon.prototype.getCurrentPoints = function () { + const pathOffset = this.get('pathOffset') || { x: 0, y: 0 } + const matrix = this.calcTransformMatrix() + + return this.get('points') + .map(function (p) { + return new fabric.Point(p.x - pathOffset.x, p.y - pathOffset.y) + }) + .map(function (p) { + return fabric.util.transformPoint(p, matrix) + }) + } +} + +export default {} From 816e440ba0dc7a631e6d25e61e38920f07aacaa2 Mon Sep 17 00:00:00 2001 From: ysCha Date: Fri, 19 Sep 2025 18:02:24 +0900 Subject: [PATCH 12/20] =?UTF-8?q?skeleton=20=EB=B3=80=EA=B2=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/util/skeleton-utils.js | 623 +++++++++++++++++++++++++++++++++---- 1 file changed, 556 insertions(+), 67 deletions(-) diff --git a/src/util/skeleton-utils.js b/src/util/skeleton-utils.js index bbf87407..841bbcc2 100644 --- a/src/util/skeleton-utils.js +++ b/src/util/skeleton-utils.js @@ -319,7 +319,7 @@ const createInnerLinesFromSkeleton = (skeleton, baseLines, roof, canvas, textMod const innerLines = [] const processedInnerEdges = new Set() - const rawLines = [] + const skeletonLines = [] // 1. 기본 skeleton에서 모든 내부 선분 수집 //edge 순서와 baseLines 순서가 같을수가 없다. @@ -330,63 +330,52 @@ const createInnerLinesFromSkeleton = (skeleton, baseLines, roof, canvas, textMod //오른쪽 하단에서 시작하면 그 지점에서부터 시계 방향으로 진행합니다. //edgeIndex 대신에 실제 baseLines 선택라인을 찾아야 한다. const edgeResult = skeleton.Edges[edgeIndex] - + console.log(edgeResult) // 방향을 고려하지 않고 같은 라인인지 확인하는 함수 let edgeType = 'eaves' let baseLineIndex = 0 - processEavesEdge(edgeResult, baseLines, rawLines, processedInnerEdges, edgeIndex, baseLineIndex) - // // ✅ Edge 타입별 처리 분기 - // switch (edgeType) { - // case 'eaves': - // processEavesEdge(edgeResult, baseLines, rawLines, processedInnerEdges, edgeIndex, baseLineIndex) - // break - // - // case 'wall': - // processWallEdge(edgeResult, baseLines, rawLines, processedInnerEdges, edgeIndex) - // break - // - // case 'gable': - // processGableEdge(edgeResult, baseLines, rawLines, processedInnerEdges, edgeIndex, baseLineIndex) - // break - // - // default: - // console.warn(`알 수 없는 edge 타입: ${edgeType}`) - // processEavesEdge(edgeResult, baseLines, rawLines, processedInnerEdges, edgeIndex, baseLineIndex) - // } + processEavesEdge(edgeResult, baseLines, skeletonLines, processedInnerEdges) + } - for (let baseLineIndex = 0; baseLineIndex < baseLines.length; baseLineIndex++) { - if (baseLines[baseLineIndex].attributes.type === 'gable') { - // 일다 그려서 rawLines를 만들어 - for (let edgeIndex = 0; edgeIndex < skeleton.Edges.length; edgeIndex++) { + for (let edgeIndex = 0; edgeIndex < skeleton.Edges.length; edgeIndex++) { - const edgeResult = skeleton.Edges[edgeIndex] - const startX = edgeResult.Edge.Begin.X - const startY = edgeResult.Edge.Begin.Y - const endX = edgeResult.Edge.End.X - const endY = edgeResult.Edge.End.Y + const edgeResult = skeleton.Edges[edgeIndex] + const startX = edgeResult.Edge.Begin.X + const startY = edgeResult.Edge.Begin.Y + const endX = edgeResult.Edge.End.X + const endY = edgeResult.Edge.End.Y - //외벽선 동일 라인이면 + + //외벽선 라인과 같은 edgeResult를 찾는다 + for (let baseLineIndex = 0; baseLineIndex < baseLines.length; baseLineIndex++) { + + if (baseLines[baseLineIndex].attributes.type === 'gable') { + // 일다 그려서 skeletonLines를 만들어 +//외벽선 동일 라인이면 if (isSameLine(startX, startY, endX, endY, baseLines[baseLineIndex])) { - processGableEdge(edgeResult, baseLines, rawLines, processedInnerEdges, edgeIndex, baseLineIndex) // + processGableEdge(edgeResult, baseLines, skeletonLines, processedInnerEdges, edgeIndex, baseLineIndex) // break // 매칭되는 라인을 찾았으므로 루프 종료 } - } + } + + + } - console.log(`처리된 rawLines: ${rawLines.length}개`) + console.log(`처리된 skeletonLines: ${skeletonLines.length}개`) // 2. 겹치는 선분 병합 - // const mergedLines = mergeCollinearLines(rawLines) + // const mergedLines = mergeCollinearLines(skeletonLines) // console.log('mergedLines', mergedLines) // 3. QLine 객체로 변환 - for (const line of rawLines) { + for (const line of skeletonLines) { const { p1, p2, attributes, lineStyle } = line const innerLine = new QLine([p1.x, p1.y, p2.x, p2.y], { parentId: roof.id, @@ -409,10 +398,13 @@ const createInnerLinesFromSkeleton = (skeleton, baseLines, roof, canvas, textMod } // ✅ EAVES (처마) 처리 - 기본 skeleton 모두 사용 -function processEavesEdge(edgeResult, baseLines, rawLines, processedInnerEdges) { - console.log(`processEavesEdge::`, rawLines) +function processEavesEdge(edgeResult, baseLines, skeletonLines, processedInnerEdges) { + console.log(`processEavesEdge::`, skeletonLines) + const begin = edgeResult.Edge.Begin + const end = edgeResult.Edge.End - // 내부 선분 수집 (스케레톤은 다각형) + + //내부 선분 수집 (스케레톤은 다각형) const polygonPoints = edgeResult.Polygon.map((p) => ({ x: p.X, y: p.Y })) for (let i = 0; i < polygonPoints.length; i++) { @@ -421,14 +413,14 @@ function processEavesEdge(edgeResult, baseLines, rawLines, processedInnerEdges) const p2 = polygonPoints[(i + 1) % polygonPoints.length] // 외벽선 제외 후 추가 - if (!isOuterEdge(p1, p2, baseLines)) { - addRawLine(rawLines, processedInnerEdges, p1, p2, 'RIDGE', '#FF0000', 3) + if(begin !== edgeResult.Polygon[i] && end !== edgeResult.Polygon[i] ) { + addRawLine(skeletonLines, processedInnerEdges, p1, p2, 'RIDGE', '#FF0000', 3) } } } // ✅ WALL (벽) 처리 - 선분 개수 최소화 -function processWallEdge(edgeResult, baseLines, rawLines, processedInnerEdges, edgeIndex) { +function processWallEdge(edgeResult, baseLines, skeletonLines, processedInnerEdges, edgeIndex) { console.log(`WALL Edge ${edgeIndex}: 내부 선분 최소화`) const polygonPoints = edgeResult.Polygon.map((p) => ({ x: p.X, y: p.Y })) @@ -444,7 +436,7 @@ function processWallEdge(edgeResult, baseLines, rawLines, processedInnerEdges, e if (lineLength > 10) { // 최소 길이 조건 - addRawLine(rawLines, processedInnerEdges, p1, p2, 'HIP', '#000000', 2) + addRawLine(skeletonLines, processedInnerEdges, p1, p2, 'HIP', '#000000', 2) } else { console.log(`WALL: 짧은 선분 제거 (길이: ${lineLength.toFixed(1)})`) } @@ -453,7 +445,7 @@ function processWallEdge(edgeResult, baseLines, rawLines, processedInnerEdges, e } // ✅ GABLE (케라바) 처리 - 직선 생성, 다른 선분 제거 -function processGableEdge(edgeResult, baseLines, rawLines, processedInnerEdges, edgeIndex, baseLineIndex) { +function processGableEdge(edgeResult, baseLines, skeletonLines, processedInnerEdges, edgeIndex, baseLineIndex) { console.log(`GABLE Edge ${edgeResult}: 직선 skeleton 생성`) const diagonalLine = []; //대각선 라인 @@ -463,7 +455,6 @@ function processGableEdge(edgeResult, baseLines, rawLines, processedInnerEdges, // 1. 기존 복잡한 skeleton 선분들 무시 // 2. GABLE edge에 수직인 직선 생성 - const sourceEdge = edgeResult.Edge const gableStart = { x: sourceEdge.Begin.X, y: sourceEdge.Begin.Y } const gableEnd = { x: sourceEdge.End.X, y: sourceEdge.End.Y } @@ -486,10 +477,10 @@ function processGableEdge(edgeResult, baseLines, rawLines, processedInnerEdges, // }) // } // - // // 폴리곤 중심점 (대략적) - // const centerX = polygonPoints.reduce((sum, p) => sum + p.x, 0) / polygonPoints.length - // const centerY = polygonPoints.reduce((sum, p) => sum + p.y, 0) / polygonPoints.length - // const polygonCenter = { x: centerX, y: centerY } + // 폴리곤 중심점 (대략적) + const centerX = polygonPoints.reduce((sum, p) => sum + p.x, 0) / polygonPoints.length + const centerY = polygonPoints.reduce((sum, p) => sum + p.y, 0) / polygonPoints.length + const polygonCenter = { x: centerX, y: centerY } // // // 허용 오차 // const colinearityTolerance = 0.1 @@ -530,15 +521,114 @@ function processGableEdge(edgeResult, baseLines, rawLines, processedInnerEdges, const selectBaseLine = baseLines[baseLineIndex]; console.log('selectBaseLine:', selectBaseLine); - console.log('rawLines:', rawLines) - //selectBaseLine 과 같은 edgeResult.ed + console.log('skeletonLines:', skeletonLines) + + // selectBaseLine의 중간 좌표 계산 + const midPoint = { + x: (selectBaseLine.x1 + selectBaseLine.x2) / 2, + y: (selectBaseLine.y1 + selectBaseLine.y2) / 2 + }; + console.log('midPoint of selectBaseLine:', midPoint); // 대각선 보정(fallback) 제거: 항상 수평/수직 내부 용마루만 생성 + const edgePoints = edgeResult.Polygon.map(p => ({ x: p.X, y: p.Y })); - for (let i = rawLines.length - 1; i >= 0; i--) { - const line = rawLines[i]; - console.log('line:', line) - console.log('line.attributes.type:', line.attributes.type) + //제거 + for (let i = skeletonLines.length - 1; i >= 0; i--) { + const line = skeletonLines[i]; + console.log('line:', line) + console.log('line.attributes.type:', line.attributes.type) + + const linePoints = [line.p1, line.p2]; + + // Check if both points of the line are in the edgePoints + const isEdgeLine = linePoints.every(point => + edgePoints.some(ep => + Math.abs(ep.x - point.x) < 0.001 && + Math.abs(ep.y - point.y) < 0.001 + ) + ); + + if (isEdgeLine) { + skeletonLines.splice(i, 1); + } + } + + //확장 + // Extend lines that have endpoints in edgePoints to intersect with selectBaseLine + // Find diagonal lines (not horizontal or vertical) + // Extend lines that have endpoints in edgePoints + + for (let i = 0; i < skeletonLines.length; i++) { + const line = skeletonLines[i]; + const p1 = line.p1; + const p2 = line.p2; + const lineP1 = { x: line.p1.x, y: line.p1.y }; + const lineP2 = { x: line.p2.x, y: line.p2.y }; + + let hasP1 = false; + let hasP2 = false; + console.log('edgeResult.Edge::',edgeResult.Edge) + + const lineInfo = findMatchingLinePoints(line, edgeResult.Polygon); + console.log(lineInfo); + + //대각선 + //직선(마루) + if(lineInfo.hasMatch) { + if (lineInfo.matches[0].type === 'diagonal') { + + const intersection2 = getLineIntersectionParametric(lineP1, lineP2, gableStart, gableEnd); + console.log('intersection2:', intersection2); + if (lineInfo.matches[0].linePoint === 'p1') { + + + skeletonLines[i].p1 = { ...skeletonLines[i].p1, x: intersection2.x, y: intersection2.y }; + } else { + + skeletonLines[i].p2 = { ...skeletonLines[i].p2, x: intersection2.x, y: intersection2.y }; + } + + } else if (lineInfo.matches[0].type === 'horizontal') { + if (lineInfo.matches[0].linePoint === 'p1') { + skeletonLines[i].p1 = { ...skeletonLines[i].p1, x: edgeResult.Edge.Begin.X }; + } else { + skeletonLines[i].p2 = { ...skeletonLines[i].p2, x: edgeResult.Edge.Begin.X }; + } + + } else if (lineInfo.matches[0].type === 'vertical') { + if (lineInfo.matches[0].linePoint === 'p1') { + skeletonLines[i].p1 = { ...skeletonLines[i].p1, y: edgeResult.Edge.Begin.Y }; + } else { + skeletonLines[i].p2 = { ...skeletonLines[i].p2, y: edgeResult.Edge.Begin.Y }; + } + } + } + +// for (const polyPoint of edgeResult.Polygon) { +// +// if (polyPoint.X === lineP1.x && polyPoint.Y === lineP1.y) { +// const extendedPoint1 = getExtensionIntersection(lineP2.x, lineP2.y, lineP1.x, lineP1.y, edgeResult.Edge.Begin.X, edgeResult.Edge.Begin.Y, edgeResult.Edge.End.X,edgeResult.Edge.End.Y); +// console.log('extendedPoint1:', extendedPoint1); +// +// skeletonLines[i].p1 = { ...skeletonLines[i].p1, x: extendedPoint1.x, y: extendedPoint1.Y }; +// //skeletonLines[i].p2 = { ...skeletonLines[i].p2, x: edgeResult.Edge.Begin.X}; +// } +// +// if (polyPoint.X === lineP2.x && polyPoint.Y === lineP2.y) { +// const extendedPoint2 = getExtensionIntersection(lineP1.x, lineP1.y,lineP2.x, lineP2.y, edgeResult.Edge.Begin.X, edgeResult.Edge.Begin.Y, edgeResult.Edge.End.X,edgeResult.Edge.End.Y); +// console.log('extendedPoint2:', extendedPoint2); +// +// skeletonLines[i].p1 = { ...skeletonLines[i].p1, x: extendedPoint2.x, y: extendedPoint2.Y }; +// //skeletonLines[i].p2 = { ...skeletonLines[i].p2, y: edgeResult.Edge.Begin.Y }; +// } +// +// +// } + } + + + /* if (line.attributes.type === LINE_TYPE.SUBLINE.HIP || line.attributes.type === 'HIP') { // 선택한 기준선 을 중심으로 대각선 삭제 @@ -556,19 +646,32 @@ function processGableEdge(edgeResult, baseLines, rawLines, processedInnerEdges, const sharesEndPoint = pointsEqual(edgeEnd, lineStart) || pointsEqual(edgeEnd, lineEnd); if (sharesStartPoint || sharesEndPoint) { - rawLines.splice(i, 1); + skeletonLines.splice(i, 1); // ridge extension logic can go here + //gableMidpoint까지 확장 + + }else{ + //선택한 baseLine 연장(edgeResult.Polygon 의 좌표와 동일한 좌표를 찾아서 연장) + for (const polyPoint of edgeResult.Polygon) { + if (Math.abs(polyPoint.X - lineEnd.x) < 0.1 && Math.abs(polyPoint.Y - lineEnd.y) < 0.1) { + // 연장 로직 + } + } } - - - }else if (line.attributes.type === LINE_TYPE.SUBLINE.RIDGE || line.attributes.type === 'RIDGE') { //마루일때 + const lineP1 = { x: line.p1.x, y: line.p1.y }; + const lineP2 = { x: line.p2.x, y: line.p2.y }; + const extensionLine= { + maxX:'', + minX:'', + maxY:'', + minY:'', + } if(edgeResult.Polygon.length > 3){ - const lineP1 = { x: line.p1.x, y: line.p1.y }; - const lineP2 = { x: line.p2.x, y: line.p2.y }; + let hasP1 = false; let hasP2 = false; @@ -585,18 +688,74 @@ function processGableEdge(edgeResult, baseLines, rawLines, processedInnerEdges, } if (hasP1 && hasP2) { - rawLines.splice(i, 1); + skeletonLines.splice(i, 1); + //양쪽 대각선이 있으면 서로 만난다. + for (const polyPoint of edgeResult.Polygon) { + + } + + + + //가운데 연장선을 추가 + skeletonLines.push({ + p1: {x: midPoint.x, y: midPoint.y}, + p2: {x: centerX, y: centerY}, + attributes: { + type: LINE_TYPE.SUBLINE.RIDGE, + planeSize: calcLinePlaneSize({ x1: midPoint.x, y1: midPoint.y, x2: centerX, y2: centerY }), + isRidge: true, + }, + lineStyle: { + color: '#FF0000', + width: 2 + }, + }) + } + + + + + + + }else{ + console.log("mpoint",gableMidpoint) + console.log("midPoint", midPoint) + console.log("lineP1",lineP1) + console.log("lineP2",lineP2) + //gableMidpoint까지 확장 (x or y 동일) + //가로일때 gableMidPoint.y 동일 + // Extend horizontal lines to gable midpoint + // + if (Math.abs(lineP1.y - lineP2.y) < 0.3) { // 가로 라인 + const extension = getExtensionLine(midPoint, lineP1, lineP2); + if (extension) { // null 체크 추가 + if (extension.isStartExtension) { + skeletonLines[i].p1 = { ...skeletonLines[i].p1, x: extension.extensionPoint.x }; + } else { + skeletonLines[i].p2 = { ...skeletonLines[i].p2, x: extension.extensionPoint.x }; + } + } + } else { // 세로 라인 + const extension = getExtensionLine(midPoint, lineP1, lineP2); + if (extension) { // null 체크 추가 + if (extension.isStartExtension) { + skeletonLines[i].p1 = { ...skeletonLines[i].p1, y: extension.extensionPoint.y }; + } else { + skeletonLines[i].p2 = { ...skeletonLines[i].p2, y: extension.extensionPoint.y }; + } + } } } } - console.log('result rawLines:', rawLines) - } + console.log('result skeletonLines:', skeletonLines) +*/ + // addRawLine( - // rawLines, + // skeletonLines, // processedInnerEdges, // gableMidpoint, // polygonCenter, @@ -626,7 +785,7 @@ function isOuterEdge(p1, p2, baseLines) { }) } -function addRawLine(rawLines, processedInnerEdges, p1, p2, lineType, color, width) { +function addRawLine(skeletonLines, processedInnerEdges, p1, p2, lineType, color, width) { const edgeKey = [`${p1.x.toFixed(1)},${p1.y.toFixed(1)}`, `${p2.x.toFixed(1)},${p2.y.toFixed(1)}`].sort().join('|') if (processedInnerEdges.has(edgeKey)) return @@ -651,7 +810,7 @@ function addRawLine(rawLines, processedInnerEdges, p1, p2, lineType, color, widt // 대각선일 때 lineType을 HIP로 지정 const normalizedType = isDiagonal ? LINE_TYPE.SUBLINE.HIP : inputNormalizedType - rawLines.push({ + skeletonLines.push({ p1: p1, p2: p2, attributes: { @@ -1617,4 +1776,334 @@ const isSameLine = (edgeStartX, edgeStartY, edgeEndX, edgeEndY, baseLine) => { Math.abs(edgeEndY - baseLine.y1) < tolerance return clockwiseMatch || counterClockwiseMatch +} + +/** + * 중간점과 선분의 끝점을 비교하여 연장선(extensionLine)을 결정합니다. + * @param {Object} midPoint - 중간점 좌표 {x, y} + * @param {Object} lineP1 - 선분의 첫 번째 끝점 {x, y} + * @param {Object} lineP2 - 선분의 두 번째 끝점 {x, y} + * @returns {Object|null} - 연장선 설정 또는 null (연장 불필요 시) + */ +function getExtensionLine(midPoint, lineP1, lineP2) { + // 선분의 방향 계산 + const isHorizontal = Math.abs(lineP1.y - lineP2.y) < 0.3; // y 좌표가 거의 같으면 수평선 + const isVertical = Math.abs(lineP1.x - lineP2.x) < 0.3; // x 좌표가 거의 같으면 수직선 + + if (isHorizontal) { + // 수평선인 경우 - y 좌표가 midPoint와 같은지 확인 + if (Math.abs(lineP1.y - midPoint.y) > 0.3) { + return null; // y 좌표가 다르면 연장하지 않음 + } + + // 중간점이 선분의 왼쪽에 있는 경우 + if (midPoint.x < Math.min(lineP1.x, lineP2.x)) { + return { + isHorizontal: true, + isStartExtension: lineP1.x < lineP2.x, + extensionPoint: { ...midPoint, y: lineP1.y } + }; + } + // 중간점이 선분의 오른쪽에 있는 경우 + else if (midPoint.x > Math.max(lineP1.x, lineP2.x)) { + return { + isHorizontal: true, + isStartExtension: lineP1.x > lineP2.x, + extensionPoint: { ...midPoint, y: lineP1.y } + }; + } + } + else if (isVertical) { + // 수직선인 경우 - x 좌표가 midPoint와 같은지 확인 + if (Math.abs(lineP1.x - midPoint.x) > 0.3) { + return null; // x 좌표가 다르면 연장하지 않음 + } + + // 중간점이 선분의 위에 있는 경우 + if (midPoint.y < Math.min(lineP1.y, lineP2.y)) { + return { + isHorizontal: false, + isStartExtension: lineP1.y < lineP2.y, + extensionPoint: { ...midPoint, x: lineP1.x } + }; + } + // 중간점이 선분의 아래에 있는 경우 + else if (midPoint.y > Math.max(lineP1.y, lineP2.y)) { + return { + isHorizontal: false, + isStartExtension: lineP1.y > lineP2.y, + extensionPoint: { ...midPoint, x: lineP1.x } + }; + } + } + + // 기본값 반환 (연장 불필요) + return null; +} +function convertToClockwise(points) { + // 1. 다각형의 면적 계산 (시계/반시계 방향 판단용) + let area = 0; + const n = points.length; + + for (let i = 0; i < n; i++) { + const j = (i + 1) % n; + area += (points[j].X - points[i].X) * (points[j].Y + points[i].Y); + } + + // 2. 반시계방향이면 배열을 뒤집어 시계방향으로 변환 + if (area < 0) { + return [...points].reverse(); + } + + // 3. 이미 시계방향이면 그대로 반환 + return [...points]; +} + +/** + * skeletonLines에서 특정 점(polyPoint)을 지나는 라인을 찾는 함수 + * @param {Array} skeletonLines - 검색할 라인 배열 + * @param {Object} polyPoint - 검색할 점 {X, Y} + * @returns {Array} - 일치하는 라인 배열 + */ +function findLinesPassingPoint(skeletonLines, polyPoint) { + return skeletonLines.filter(line => { + // 라인의 시작점이나 끝점이 polyPoint와 일치하는지 확인 + const isP1Match = (Math.abs(line.p1.x - polyPoint.X) < 0.001 && + Math.abs(line.p1.y - polyPoint.Y) < 0.001); + const isP2Match = (Math.abs(line.p2.x - polyPoint.X) < 0.001 && + Math.abs(line.p2.y - polyPoint.Y) < 0.001); + + return isP1Match || isP2Match; + }); +} + +// 두 선분의 교차점을 찾는 함수 +// 두 선분의 교차점을 찾는 함수 (개선된 버전) +function findIntersection(p1, p2, p3, p4) { + // 선분1: p1 -> p2 + // 선분2: p3 -> p4 + + // 선분 방향 벡터 + const d1x = p2.x - p1.x; + const d1y = p2.y - p1.y; + const d2x = p4.x - p3.x; + const d2y = p4.y - p3.y; + + // 분모 계산 + const denominator = d1x * d2y - d1y * d2x; + + // 평행한 경우 (또는 매우 가까운 경우) + // if (Math.abs(denominator) < 0.0001) { + // return null; + // } + + // 매개변수 t와 u 계산 + const t = ((p3.x - p1.x) * d2y - (p3.y - p1.y) * d2x) / denominator; + const u = ((p3.x - p1.x) * d1y - (p3.y - p1.y) * d1x) / denominator; + + // 두 선분이 교차하는지 확인 (0 <= t <= 1, 0 <= u <= 1) + if (t >= -0.001 && t <= 1.001 && u >= -0.001 && u <= 1.001) { + // 교차점 계산 + const x = p1.x + t * d1x; + const y = p1.y + t * d1y; + return { x, y }; + } + + // 교차하지 않는 경우 + return null; +} +/** + * edgePoints와 skeletonLines의 교차점을 찾는 함수 + * @param {Array<{x: number, y: number}>} edgePoints - 엣지 포인트 배열 + * @param {Array} skeletonLines - 원시 라인 배열 (각 라인은 p1, p2 속성을 가짐) + * @returns {Array<{x: number, y: number, line: Object}>} 교차점과 해당 라인 정보 배열 + */ +function findIntersectionsWithEdgePoints(edgePoints, skeletonLines) { + const intersections = []; + + // edgePoints를 순회하며 각 점을 지나는 라인 찾기 + for (let i = 0; i < edgePoints.length; i++) { + const point = edgePoints[i]; + const nextPoint = edgePoints[(i + 1) % edgePoints.length]; + + // 현재 엣지 선분 + const edgeLine = { + x1: point.x, y1: point.y, + x2: nextPoint.x, y2: nextPoint.y + }; + + // 모든 skeletonLines와의 교차점 검사 + for (const rawLine of skeletonLines) { + // rawLine은 p1, p2 속성을 가짐 + const rawLineObj = { + x1: rawLine.p1.x, y1: rawLine.p1.y, + x2: rawLine.p2.x, y2: rawLine.p2.y + }; + + // 선분 교차 검사 + const intersection = findIntersection( + edgeLine.x1, edgeLine.y1, edgeLine.x2, edgeLine.y2, + rawLineObj.x1, rawLineObj.y1, rawLineObj.x2, rawLineObj.y2 + ); + + if (intersection) { + intersections.push({ + x: intersection.x, + y: intersection.y, + edgeIndex: i, + line: rawLine + }); + } + } + } + + return intersections; +} + +// Helper function to extend a line to intersect with polygon edges +function extendLineToIntersections(p1, p2, polygonPoints) { + let intersections = []; + const line = { p1, p2 }; + + // Check intersection with each polygon edge + for (let i = 0; i < polygonPoints.length; i++) { + const edgeP1 = polygonPoints[i]; + const edgeP2 = polygonPoints[(i + 1) % polygonPoints.length]; + + + } + + if (intersections.length < 2) return null; + + // Sort by distance from p1 + intersections.sort((a, b) => a.distance - b.distance); + + // Return the two farthest intersection points + return { + p1: { x: intersections[0].x, y: intersections[0].y }, + p2: { + x: intersections[intersections.length - 1].x, + y: intersections[intersections.length - 1].y + } + }; +} + +function getExtensionIntersection( + startX, startY, // 대각선 시작점 + currentX, currentY, // 대각선 현재 위치 + lineStartX, lineStartY, // 연장할 선의 시작점 + lineEndX, lineEndY // 연장할 선의 끝점 +) { + // 대각선 방향 벡터 + const dx = currentX - startX; + const dy = currentY - startY; + + // 연장할 선의 기울기 + const m = (lineEndY - lineStartY) / (lineEndX - lineStartX); + + // 매개변수 t 방정식에서 t를 구하기 위한 식 전개 + // 대각선의 parametric 방정식: x = startX + t*dx, y = startY + t*dy + // 연장할 선 방정식: y = m * (x - lineStartX) + lineStartY + // 이를 대입해 t 구함 + const numerator = m * (lineStartX - startX) + startY - lineStartY; + const denominator = dy - m * dx; + + if (denominator === 0) { + // 평행하거나 일치하여 교점 없음 + return null; + } + + const t = numerator / denominator; + + const intersectX = startX + t * dx; + const intersectY = startY + t * dy; + + return { x: intersectX, y: intersectY }; +} + +function findMatchingLinePoints(Aline, APolygon, epsilon = 1e-10) { + const { p1, p2 } = Aline; + const matches = []; + + // 선의 방향 판단 + function getLineDirection(point1, point2, epsilon = 1e-10) { + const deltaX = Math.abs(point1.x - point2.x); + const deltaY = Math.abs(point1.y - point2.y); + + if (deltaX < epsilon && deltaY < epsilon) { + return { + type: 'point', + description: '점 (두 좌표가 동일)' + }; + } else if (deltaX < epsilon) { + return { + type: 'vertical', + description: '수직선 (세로)' + }; + } else if (deltaY < epsilon) { + return { + type: 'horizontal', + description: '수평선 (가로)' + }; + } else { + return { + type: 'diagonal', + description: '대각선' + }; + } + } + + // 선의 방향 정보 계산 + const lineDirection = getLineDirection(p1, p2, epsilon); + + APolygon.forEach((point, index) => { + // p1과 비교 + if (Math.abs(p1.x - point.X) < epsilon && Math.abs(p1.y - point.Y) < epsilon) { + matches.push({ + linePoint: 'p1', + polygonIndex: index, + coordinates: { x: point.X, y: point.Y }, + lineDirection: lineDirection, + type: lineDirection.type + }); + } + // p2와 비교 + if (Math.abs(p2.x - point.X) < epsilon && Math.abs(p2.y - point.Y) < epsilon) { + matches.push({ + linePoint: 'p2', + polygonIndex: index, + coordinates: { x: point.X, y: point.Y }, + lineDirection: lineDirection, + type: lineDirection.type + }); + } + }); + + return { + hasMatch: matches.length > 0, + lineDirection: lineDirection, + matches: matches + }; +} + +function getLineIntersectionParametric(p1, p2, p3, p4) { + const d1 = { x: p2.x - p1.x, y: p2.y - p1.y }; // 첫번째 직선 방향벡터 + const d2 = { x: p4.x - p3.x, y: p4.y - p3.y }; // 두번째 직선 방향벡터 + + // 평행선 체크 (외적이 0이면 평행) + const cross = d1.x * d2.y - d1.y * d2.x; + if (Math.abs(cross) < Number.EPSILON) { + return null; // 평행선 + } + + // 매개변수 t 계산 + const dx = p3.x - p1.x; + const dy = p3.y - p1.y; + const t = (dx * d2.y - dy * d2.x) / cross; + + // 교점: p1 + t * d1 + return { + x: p1.x + t * d1.x, + y: p1.y + t * d1.y + }; } \ No newline at end of file From 93e54812c3c13daf455f9ab6d5760d67ee3e9db2 Mon Sep 17 00:00:00 2001 From: "hyojun.choi" Date: Mon, 22 Sep 2025 09:59:35 +0900 Subject: [PATCH 13/20] =?UTF-8?q?=EB=B0=B0=EC=B9=98=EB=A9=B4=20=EC=84=A0?= =?UTF-8?q?=ED=83=9D=20=EC=8B=9C=20=EA=B8=B0=EC=A1=B4=20roof=20stroke,=20s?= =?UTF-8?q?trokeWidth=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/components/floor-plan/CanvasMenu.jsx | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/src/components/floor-plan/CanvasMenu.jsx b/src/components/floor-plan/CanvasMenu.jsx index 1a92cfe8..6ddd94c7 100644 --- a/src/components/floor-plan/CanvasMenu.jsx +++ b/src/components/floor-plan/CanvasMenu.jsx @@ -196,6 +196,14 @@ export default function CanvasMenu(props) { text: getMessage('module.delete.confirm'), type: 'confirm', confirmFn: () => { + const roofs = canvas.getObjects().filter((obj) => obj.name === POLYGON_TYPE.ROOF) + roofs.forEach((roof) => { + roof.set({ + stroke: 'black', + strokeWidth: 3, + }) + }) + //해당 메뉴 이동시 배치면 삭제 setAllModuleSurfaceIsComplete(false) From 0acd9e422f0bb14a4f49c28f5085944a810038e9 Mon Sep 17 00:00:00 2001 From: ysCha Date: Tue, 23 Sep 2025 18:40:14 +0900 Subject: [PATCH 14/20] =?UTF-8?q?skeleton=20=EB=B3=80=EA=B2=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/util/skeleton-utils.js | 929 +++++++++++++++++-------------------- 1 file changed, 435 insertions(+), 494 deletions(-) diff --git a/src/util/skeleton-utils.js b/src/util/skeleton-utils.js index 841bbcc2..ba5b4e87 100644 --- a/src/util/skeleton-utils.js +++ b/src/util/skeleton-utils.js @@ -3,26 +3,28 @@ import Big from 'big.js' import { SkeletonBuilder } from '@/lib/skeletons' import { arePointsEqual, calcLineActualSize, calcLinePlaneSize, calculateAngle, toGeoJSON } from '@/util/qpolygon-utils' import { QLine } from '@/components/fabric/QLine' -import { getDegreeByChon, isPointOnLine } from '@/util/canvas-util' +import { getDegreeByChon } from '@/util/canvas-util' + /** * 지붕 폴리곤의 스켈레톤(중심선)을 생성하고 캔버스에 그립니다. * @param {string} roofId - 대상 지붕 객체의 ID * @param {fabric.Canvas} canvas - Fabric.js 캔버스 객체 * @param {string} textMode - 텍스트 표시 모드 + * @param existingSkeletonLines */ -export const drawSkeletonRidgeRoof = (roofId, canvas, textMode) => { +export const drawSkeletonRidgeRoof = (roofId, canvas, textMode, existingSkeletonLines = []) => { const roof = canvas?.getObjects().find((object) => object.id === roofId) if (!roof) { console.error(`Roof with id "${roofId}" not found.`); return; } - + const skeletonLines = [...existingSkeletonLines]; // 1. 기존 스켈레톤 라인 제거 - const existingSkeletonLines = canvas.getObjects().filter(obj => - obj.parentId === roofId && obj.attributes?.type === 'skeleton' - ); - existingSkeletonLines.forEach(line => canvas.remove(line)); + // const existingSkeletonLines = canvas.getObjects().filter(obj => + // obj.parentId === roofId && obj.attributes?.type === 'skeleton' + // ); + // existingSkeletonLines.forEach(line => canvas.remove(line)); // 2. 지붕 폴리곤 좌표 전처리 const coordinates = preprocessPolygonCoordinates(roof.points); @@ -45,7 +47,8 @@ export const drawSkeletonRidgeRoof = (roofId, canvas, textMode) => { const baseLines = wall.baseLines.filter((line) => line.attributes.planeSize > 0) const baseLinePoints = baseLines.map((line) => ({ x: line.x1, y: line.y1 })) - skeletonBuilder(roofId, canvas, textMode, roof) + skeletonBuilder(roofId, canvas, textMode, roof, skeletonLines) + } /** @@ -175,7 +178,7 @@ export const transformMultipleEdges = (skeleton, edgeConfigs) => { * @param roof * @param edgeProperties */ -export const skeletonBuilder = (roofId, canvas, textMode, roof) => { +export const skeletonBuilder = (roofId, canvas, textMode, roof, skeletonLines) => { // 1. 다각형 좌표를 GeoJSON 형식으로 변환합니다. const geoJSONPolygon = toGeoJSON(roof.points) @@ -188,7 +191,7 @@ export const skeletonBuilder = (roofId, canvas, textMode, roof) => { console.log('Edge 분석:', skeleton.edge_analysis) // 3. 라인을 그림 - const innerLines = createInnerLinesFromSkeleton(skeleton, roof.lines, roof, canvas, textMode) + const innerLines = createInnerLinesFromSkeleton(skeleton, roof.lines, roof, canvas, textMode, skeletonLines) console.log("innerLines::", innerLines) // 4. 생성된 선들을 캔버스에 추가하고 지붕 객체에 저장 @@ -312,14 +315,14 @@ const mergeCollinearLines = (lines) => { } //조건에 따른 스켈레톤을 그린다. -const createInnerLinesFromSkeleton = (skeleton, baseLines, roof, canvas, textMode) => { +const createInnerLinesFromSkeleton = (skeleton, baseLines, roof, canvas, textMode, skeletonLines) => { console.log('=== Edge Properties 기반 후처리 시작 ===') if (!skeleton || !skeleton.Edges) return [] const innerLines = [] const processedInnerEdges = new Set() - const skeletonLines = [] + //const skeletonLines = [] // 1. 기본 skeleton에서 모든 내부 선분 수집 //edge 순서와 baseLines 순서가 같을수가 없다. @@ -464,59 +467,12 @@ function processGableEdge(edgeResult, baseLines, skeletonLines, processedInnerEd x: (gableStart.x + gableEnd.x) / 2, y: (gableStart.y + gableEnd.y) / 2, } - // - // // polygonPoints와 gableMidpoint 비교: x 또는 y가 같은 점 찾기 (허용 오차 적용) - // const axisTolerance = 0.1 - // const sameXPoints = polygonPoints.filter((p) => Math.abs(p.x - gableMidpoint.x) < axisTolerance) - // const sameYPoints = polygonPoints.filter((p) => Math.abs(p.y - gableMidpoint.y) < axisTolerance) - // if (sameXPoints.length || sameYPoints.length) { - // console.log('GABLE: gableMidpoint와 같은 축의 폴리곤 점', { - // gableMidpoint, - // sameXPoints, - // sameYPoints, - // }) - // } - // + // 폴리곤 중심점 (대략적) const centerX = polygonPoints.reduce((sum, p) => sum + p.x, 0) / polygonPoints.length const centerY = polygonPoints.reduce((sum, p) => sum + p.y, 0) / polygonPoints.length const polygonCenter = { x: centerX, y: centerY } - // - // // 허용 오차 - // const colinearityTolerance = 0.1 - // - // // 폴리곤 선분 생성 (연속 점 쌍) - // const segments = [] - // for (let i = 0; i < polygonPoints.length; i++) { - // const p1 = polygonPoints[i] - // const p2 = polygonPoints[(i + 1) % polygonPoints.length] - // segments.push({ p1, p2 }) - // } - // - // // gableMidpoint와 같은 축(Y 또는 X)에 있는 수직/수평 선분만 추출 - // const sameAxisSegments = segments.filter(({ p1, p2 }) => { - // const isVertical = Math.abs(p1.x - p2.x) < colinearityTolerance - // const isHorizontal = Math.abs(p1.y - p2.y) < colinearityTolerance - // const sameXAxis = isVertical && Math.abs(p1.x - gableMidpoint.x) < axisTolerance - // const sameYAxis = isHorizontal && Math.abs(p1.y - gableMidpoint.y) < axisTolerance - // return sameXAxis || sameYAxis - // }) - // - // // 가장 가까운(또는 가장 긴) 용마루 후보 선택 - // let ridgeCandidate = null - // if (sameAxisSegments.length) { - // // 1) 중점과의 최단거리 기준 - // ridgeCandidate = sameAxisSegments.reduce((best, seg) => { - // const mid = { x: (seg.p1.x + seg.p2.x) / 2, y: (seg.p1.y + seg.p2.y) / 2 } - // const dist2 = (mid.x - gableMidpoint.x) ** 2 + (mid.y - gableMidpoint.y) ** 2 - // if (!best) return { seg, score: dist2 } - // return dist2 < best.score ? { seg, score: dist2 } : best - // }, null)?.seg - // - // - // } - // const selectBaseLine = baseLines[baseLineIndex]; @@ -555,214 +511,83 @@ function processGableEdge(edgeResult, baseLines, skeletonLines, processedInnerEd } //확장 - // Extend lines that have endpoints in edgePoints to intersect with selectBaseLine - // Find diagonal lines (not horizontal or vertical) - // Extend lines that have endpoints in edgePoints + const breakLinePont = findDisconnectedSkeletonLines(skeletonLines, baseLines) + console.log('breakLinePont:', breakLinePont) - for (let i = 0; i < skeletonLines.length; i++) { - const line = skeletonLines[i]; - const p1 = line.p1; - const p2 = line.p2; - const lineP1 = { x: line.p1.x, y: line.p1.y }; - const lineP2 = { x: line.p2.x, y: line.p2.y }; - - let hasP1 = false; - let hasP2 = false; - console.log('edgeResult.Edge::',edgeResult.Edge) - - const lineInfo = findMatchingLinePoints(line, edgeResult.Polygon); - console.log(lineInfo); - - //대각선 - //직선(마루) - if(lineInfo.hasMatch) { - if (lineInfo.matches[0].type === 'diagonal') { - - const intersection2 = getLineIntersectionParametric(lineP1, lineP2, gableStart, gableEnd); - console.log('intersection2:', intersection2); - if (lineInfo.matches[0].linePoint === 'p1') { +if(breakLinePont.disconnectedLines.length > 0) { - skeletonLines[i].p1 = { ...skeletonLines[i].p1, x: intersection2.x, y: intersection2.y }; - } else { + for (const dLine of breakLinePont.disconnectedLines) { + const inx = dLine.index; + const exLine = dLine.extendedLine; - skeletonLines[i].p2 = { ...skeletonLines[i].p2, x: intersection2.x, y: intersection2.y }; - } + //확장 + if (dLine.p1Connected) { + skeletonLines[inx].p2 = { ...skeletonLines[inx].p2, x: exLine.p2.x, y: exLine.p2.y }; - } else if (lineInfo.matches[0].type === 'horizontal') { - if (lineInfo.matches[0].linePoint === 'p1') { - skeletonLines[i].p1 = { ...skeletonLines[i].p1, x: edgeResult.Edge.Begin.X }; - } else { - skeletonLines[i].p2 = { ...skeletonLines[i].p2, x: edgeResult.Edge.Begin.X }; - } - - } else if (lineInfo.matches[0].type === 'vertical') { - if (lineInfo.matches[0].linePoint === 'p1') { - skeletonLines[i].p1 = { ...skeletonLines[i].p1, y: edgeResult.Edge.Begin.Y }; - } else { - skeletonLines[i].p2 = { ...skeletonLines[i].p2, y: edgeResult.Edge.Begin.Y }; - } + } else if (dLine.p2Connected) { + skeletonLines[inx].p1 = { ...skeletonLines[inx].p1, x: exLine.p1.x, y: exLine.p1.y }; } - } -// for (const polyPoint of edgeResult.Polygon) { + } +} + //확장(연장) +// for (let i = 0; i < skeletonLines.length; i++) { +// const line = skeletonLines[i]; +// const p1 = line.p1; +// const p2 = line.p2; +// const lineP1 = { x: line.p1.x, y: line.p1.y }; +// const lineP2 = { x: line.p2.x, y: line.p2.y }; // -// if (polyPoint.X === lineP1.x && polyPoint.Y === lineP1.y) { -// const extendedPoint1 = getExtensionIntersection(lineP2.x, lineP2.y, lineP1.x, lineP1.y, edgeResult.Edge.Begin.X, edgeResult.Edge.Begin.Y, edgeResult.Edge.End.X,edgeResult.Edge.End.Y); -// console.log('extendedPoint1:', extendedPoint1); +// let hasP1 = false; +// let hasP2 = false; +// console.log('edgeResult.Edge::',edgeResult.Edge) +// //선택한 라인과 다각형을 생성하는 라인 여부 +// const matchingLinePoint = findMatchingLinePoints(line, edgeResult.Polygon); +// console.log(matchingLinePoint); // -// skeletonLines[i].p1 = { ...skeletonLines[i].p1, x: extendedPoint1.x, y: extendedPoint1.Y }; -// //skeletonLines[i].p2 = { ...skeletonLines[i].p2, x: edgeResult.Edge.Begin.X}; +// +// if(matchingLinePoint.hasMatch) { +// +// if (matchingLinePoint.matches[0].type === 'diagonal') { +// console.log("lineP1:", lineP1) +// console.log("lineP2:", lineP2) +// const intersectionPoint = getLineIntersectionParametric(lineP1, lineP2, gableStart, gableEnd); +// console.log('intersectionPoint:', intersectionPoint); +// console.log('gableStart:', gableStart); +// console.log('gableEnd:', gableEnd); +// // 교차점이 생겼다면 절삭(교차점 이하(이상) 삭제) +// if (!intersectionPoint) { +// console.warn('No valid intersection point found between line and gable edge'); +// return; // or handle the null case appropriately +// } +// +// if (matchingLinePoint.matches[0].linePoint === 'p1') { +// skeletonLines[i].p1 = { ...skeletonLines[i].p1, x: intersectionPoint.x, y: intersectionPoint.y }; +// } else { +// skeletonLines[i].p2 = { ...skeletonLines[i].p2, x: intersectionPoint.x, y: intersectionPoint.y }; +// } +// +// } else if (matchingLinePoint.matches[0].type === 'horizontal') { +// if (matchingLinePoint.matches[0].linePoint === 'p1') { +// skeletonLines[i].p1 = { ...skeletonLines[i].p1, x: edgeResult.Edge.Begin.X }; +// } else { +// skeletonLines[i].p2 = { ...skeletonLines[i].p2, x: edgeResult.Edge.Begin.X }; +// } +// +// } else if (matchingLinePoint.matches[0].type === 'vertical') { +// if (matchingLinePoint.matches[0].linePoint === 'p1') { +// skeletonLines[i].p1 = { ...skeletonLines[i].p1, y: edgeResult.Edge.Begin.Y }; +// } else { +// skeletonLines[i].p2 = { ...skeletonLines[i].p2, y: edgeResult.Edge.Begin.Y }; +// } // } // -// if (polyPoint.X === lineP2.x && polyPoint.Y === lineP2.y) { -// const extendedPoint2 = getExtensionIntersection(lineP1.x, lineP1.y,lineP2.x, lineP2.y, edgeResult.Edge.Begin.X, edgeResult.Edge.Begin.Y, edgeResult.Edge.End.X,edgeResult.Edge.End.Y); -// console.log('extendedPoint2:', extendedPoint2); -// -// skeletonLines[i].p1 = { ...skeletonLines[i].p1, x: extendedPoint2.x, y: extendedPoint2.Y }; -// //skeletonLines[i].p2 = { ...skeletonLines[i].p2, y: edgeResult.Edge.Begin.Y }; -// } -// -// // } - } +// +// } - /* - if (line.attributes.type === LINE_TYPE.SUBLINE.HIP || line.attributes.type === 'HIP') { - - // 선택한 기준선 을 중심으로 대각선 삭제 - // Get line and edge points - const edgeStart = { x: edgeResult.Edge.Begin.X, y: edgeResult.Edge.Begin.Y }; - const edgeEnd = { x: edgeResult.Edge.End.X, y: edgeResult.Edge.End.Y }; - const lineStart = { x: line.p1.x, y: line.p1.y }; - const lineEnd = { x: line.p2.x, y: line.p2.y }; - - const pointsEqual = (p1, p2) => { - return p1.x === p2.x && p1.y === p2.y; - } - // Check if line shares an endpoint with the edge - const sharesStartPoint = pointsEqual(edgeStart, lineStart) || pointsEqual(edgeStart, lineEnd); - const sharesEndPoint = pointsEqual(edgeEnd, lineStart) || pointsEqual(edgeEnd, lineEnd); - - if (sharesStartPoint || sharesEndPoint) { - skeletonLines.splice(i, 1); - // ridge extension logic can go here - //gableMidpoint까지 확장 - - }else{ - //선택한 baseLine 연장(edgeResult.Polygon 의 좌표와 동일한 좌표를 찾아서 연장) - for (const polyPoint of edgeResult.Polygon) { - if (Math.abs(polyPoint.X - lineEnd.x) < 0.1 && Math.abs(polyPoint.Y - lineEnd.y) < 0.1) { - // 연장 로직 - } - } - } - - }else if (line.attributes.type === LINE_TYPE.SUBLINE.RIDGE || line.attributes.type === 'RIDGE') { - //마루일때 - const lineP1 = { x: line.p1.x, y: line.p1.y }; - const lineP2 = { x: line.p2.x, y: line.p2.y }; - const extensionLine= { - maxX:'', - minX:'', - maxY:'', - minY:'', - } - - if(edgeResult.Polygon.length > 3){ - - let hasP1 = false; - let hasP2 = false; - - for (const polyPoint of edgeResult.Polygon) { - if (!hasP1 && polyPoint.X === lineP1.x && polyPoint.Y === lineP1.y) { - hasP1 = true; - } - if (!hasP2 && polyPoint.X === lineP2.x && polyPoint.Y === lineP2.y) { - hasP2 = true; - } - - // Early exit if both points are found - if (hasP1 && hasP2) break; - } - - if (hasP1 && hasP2) { - skeletonLines.splice(i, 1); - //양쪽 대각선이 있으면 서로 만난다. - for (const polyPoint of edgeResult.Polygon) { - - } - - - - //가운데 연장선을 추가 - skeletonLines.push({ - p1: {x: midPoint.x, y: midPoint.y}, - p2: {x: centerX, y: centerY}, - attributes: { - type: LINE_TYPE.SUBLINE.RIDGE, - planeSize: calcLinePlaneSize({ x1: midPoint.x, y1: midPoint.y, x2: centerX, y2: centerY }), - isRidge: true, - }, - lineStyle: { - color: '#FF0000', - width: 2 - }, - }) - } - - - - - - - }else{ - console.log("mpoint",gableMidpoint) - console.log("midPoint", midPoint) - console.log("lineP1",lineP1) - console.log("lineP2",lineP2) - //gableMidpoint까지 확장 (x or y 동일) - //가로일때 gableMidPoint.y 동일 - // Extend horizontal lines to gable midpoint - // - if (Math.abs(lineP1.y - lineP2.y) < 0.3) { // 가로 라인 - const extension = getExtensionLine(midPoint, lineP1, lineP2); - if (extension) { // null 체크 추가 - if (extension.isStartExtension) { - skeletonLines[i].p1 = { ...skeletonLines[i].p1, x: extension.extensionPoint.x }; - } else { - skeletonLines[i].p2 = { ...skeletonLines[i].p2, x: extension.extensionPoint.x }; - } - } - } else { // 세로 라인 - const extension = getExtensionLine(midPoint, lineP1, lineP2); - if (extension) { // null 체크 추가 - if (extension.isStartExtension) { - skeletonLines[i].p1 = { ...skeletonLines[i].p1, y: extension.extensionPoint.y }; - } else { - skeletonLines[i].p2 = { ...skeletonLines[i].p2, y: extension.extensionPoint.y }; - } - } - } - - } - } - - console.log('result skeletonLines:', skeletonLines) -*/ - - - - // addRawLine( - // skeletonLines, - // processedInnerEdges, - // gableMidpoint, - // polygonCenter, - // 'RIDGE', - // '#0000FF', // 파란색으로 구분 - // 3, // 두껍게 - // ) } // ✅ 헬퍼 함수들 @@ -1092,6 +917,7 @@ const extendLineToOppositeEdge = (p1, p2, polygonPoints, selectedEdgeIndex) => { // 방향 벡터 정규화 const length = Math.sqrt(direction.x * direction.x + direction.y * direction.y) + if (length === 0) return null const normalizedDir = { @@ -1195,6 +1021,7 @@ const transformWallToRoof = (roof, canvas, edgeIndex) => { return roof } + /** * 사용 예제: 첫 번째 edge를 45도 각도로 변형 * drawSkeletonWithTransformedEdges(roofId, canvas, textMode, [ @@ -1558,7 +1385,7 @@ class Advanced2DRoofBuilder extends SkeletonBuilder { } if (edgeIndex < 0 || edgeIndex >= totalPoints) { - console.error(`edgeIndex ${edgeIndex}가 범위 [0, ${totalPoints - 1}]을 벗어났습니다`) + console.error(`edgeIndex ${edgeIndex}가 범위를 벗어났습니다`) return } @@ -1658,9 +1485,13 @@ class Advanced2DRoofBuilder extends SkeletonBuilder { const centerX = validPoints.reduce((sum, p) => sum + p[0], 0) / totalPoints const centerY = validPoints.reduce((sum, p) => sum + p[1], 0) / totalPoints + console.log(`중심점: (${centerX}, ${centerY})`) + + // edge 중점 const midX = (p1[0] + p2[0]) / 2 const midY = (p1[1] + p2[1]) / 2 + // 외향 방향 const dirX = centerX - midX const dirY = centerY - midY const length = Math.sqrt(dirX * dirX + dirY * dirY) @@ -1778,87 +1609,6 @@ const isSameLine = (edgeStartX, edgeStartY, edgeEndX, edgeEndY, baseLine) => { return clockwiseMatch || counterClockwiseMatch } -/** - * 중간점과 선분의 끝점을 비교하여 연장선(extensionLine)을 결정합니다. - * @param {Object} midPoint - 중간점 좌표 {x, y} - * @param {Object} lineP1 - 선분의 첫 번째 끝점 {x, y} - * @param {Object} lineP2 - 선분의 두 번째 끝점 {x, y} - * @returns {Object|null} - 연장선 설정 또는 null (연장 불필요 시) - */ -function getExtensionLine(midPoint, lineP1, lineP2) { - // 선분의 방향 계산 - const isHorizontal = Math.abs(lineP1.y - lineP2.y) < 0.3; // y 좌표가 거의 같으면 수평선 - const isVertical = Math.abs(lineP1.x - lineP2.x) < 0.3; // x 좌표가 거의 같으면 수직선 - - if (isHorizontal) { - // 수평선인 경우 - y 좌표가 midPoint와 같은지 확인 - if (Math.abs(lineP1.y - midPoint.y) > 0.3) { - return null; // y 좌표가 다르면 연장하지 않음 - } - - // 중간점이 선분의 왼쪽에 있는 경우 - if (midPoint.x < Math.min(lineP1.x, lineP2.x)) { - return { - isHorizontal: true, - isStartExtension: lineP1.x < lineP2.x, - extensionPoint: { ...midPoint, y: lineP1.y } - }; - } - // 중간점이 선분의 오른쪽에 있는 경우 - else if (midPoint.x > Math.max(lineP1.x, lineP2.x)) { - return { - isHorizontal: true, - isStartExtension: lineP1.x > lineP2.x, - extensionPoint: { ...midPoint, y: lineP1.y } - }; - } - } - else if (isVertical) { - // 수직선인 경우 - x 좌표가 midPoint와 같은지 확인 - if (Math.abs(lineP1.x - midPoint.x) > 0.3) { - return null; // x 좌표가 다르면 연장하지 않음 - } - - // 중간점이 선분의 위에 있는 경우 - if (midPoint.y < Math.min(lineP1.y, lineP2.y)) { - return { - isHorizontal: false, - isStartExtension: lineP1.y < lineP2.y, - extensionPoint: { ...midPoint, x: lineP1.x } - }; - } - // 중간점이 선분의 아래에 있는 경우 - else if (midPoint.y > Math.max(lineP1.y, lineP2.y)) { - return { - isHorizontal: false, - isStartExtension: lineP1.y > lineP2.y, - extensionPoint: { ...midPoint, x: lineP1.x } - }; - } - } - - // 기본값 반환 (연장 불필요) - return null; -} -function convertToClockwise(points) { - // 1. 다각형의 면적 계산 (시계/반시계 방향 판단용) - let area = 0; - const n = points.length; - - for (let i = 0; i < n; i++) { - const j = (i + 1) % n; - area += (points[j].X - points[i].X) * (points[j].Y + points[i].Y); - } - - // 2. 반시계방향이면 배열을 뒤집어 시계방향으로 변환 - if (area < 0) { - return [...points].reverse(); - } - - // 3. 이미 시계방향이면 그대로 반환 - return [...points]; -} - /** * skeletonLines에서 특정 점(polyPoint)을 지나는 라인을 찾는 함수 * @param {Array} skeletonLines - 검색할 라인 배열 @@ -1912,198 +1662,389 @@ function findIntersection(p1, p2, p3, p4) { // 교차하지 않는 경우 return null; } -/** - * edgePoints와 skeletonLines의 교차점을 찾는 함수 - * @param {Array<{x: number, y: number}>} edgePoints - 엣지 포인트 배열 - * @param {Array} skeletonLines - 원시 라인 배열 (각 라인은 p1, p2 속성을 가짐) - * @returns {Array<{x: number, y: number, line: Object}>} 교차점과 해당 라인 정보 배열 - */ -function findIntersectionsWithEdgePoints(edgePoints, skeletonLines) { - const intersections = []; - // edgePoints를 순회하며 각 점을 지나는 라인 찾기 - for (let i = 0; i < edgePoints.length; i++) { - const point = edgePoints[i]; - const nextPoint = edgePoints[(i + 1) % edgePoints.length]; - // 현재 엣지 선분 - const edgeLine = { - x1: point.x, y1: point.y, - x2: nextPoint.x, y2: nextPoint.y +// baseLine 좌표 추출 헬퍼 함수 +const extractBaseLineCoordinates = (baseLine) => { + const left = baseLine.left || 0; + const top = baseLine.top || 0; + const width = baseLine.width || 0; + const height = baseLine.height || 0; + + // 수평선인 경우 (height가 0에 가까움) + if (Math.abs(height) < 0.1) { + return { + p1: { x: left, y: top }, + p2: { x: left + width, y: top } }; + } + // 수직선인 경우 (width가 0에 가까움) + else if (Math.abs(width) < 0.1) { + return { + p1: { x: left, y: top }, + p2: { x: left, y: top + height } + }; + } + // 기타 경우 (기본값) + else { + return { + p1: { x: left, y: top }, + p2: { x: left + width, y: top + height } + }; + } +}; - // 모든 skeletonLines와의 교차점 검사 - for (const rawLine of skeletonLines) { - // rawLine은 p1, p2 속성을 가짐 - const rawLineObj = { - x1: rawLine.p1.x, y1: rawLine.p1.y, - x2: rawLine.p2.x, y2: rawLine.p2.y - }; +// 연결이 끊어진 라인들을 찾는 함수 +export const findDisconnectedSkeletonLines = (skeletonLines, baseLines, options = {}) => { + const { + includeDiagonal = true, + includeStraight = true, + minLength = 0 + } = options; - // 선분 교차 검사 - const intersection = findIntersection( - edgeLine.x1, edgeLine.y1, edgeLine.x2, edgeLine.y2, - rawLineObj.x1, rawLineObj.y1, rawLineObj.x2, rawLineObj.y2 - ); - - if (intersection) { - intersections.push({ - x: intersection.x, - y: intersection.y, - edgeIndex: i, - line: rawLine - }); - } - } + if (!skeletonLines?.length) { + return { + disconnectedLines: [], + diagonalLines: [], + straightLines: [], + statistics: { total: 0, diagonal: 0, straight: 0, disconnected: 0 } + }; } - return intersections; -} + const disconnectedLines = []; + const diagonalLines = []; + const straightLines = []; -// Helper function to extend a line to intersect with polygon edges -function extendLineToIntersections(p1, p2, polygonPoints) { - let intersections = []; - const line = { p1, p2 }; + // 점 일치 확인 헬퍼 함수 + const pointsEqual = (p1, p2, epsilon = 0.1) => { + return Math.abs(p1.x - p2.x) < epsilon && Math.abs(p1.y - p2.y) < epsilon; + }; - // Check intersection with each polygon edge - for (let i = 0; i < polygonPoints.length; i++) { - const edgeP1 = polygonPoints[i]; - const edgeP2 = polygonPoints[(i + 1) % polygonPoints.length]; + // baseLine 좌표 추출 + const extractBaseLineCoordinates = (baseLine) => { + const left = baseLine.left || 0; + const top = baseLine.top || 0; + const width = baseLine.width || 0; + const height = baseLine.height || 0; - - } - - if (intersections.length < 2) return null; - - // Sort by distance from p1 - intersections.sort((a, b) => a.distance - b.distance); - - // Return the two farthest intersection points - return { - p1: { x: intersections[0].x, y: intersections[0].y }, - p2: { - x: intersections[intersections.length - 1].x, - y: intersections[intersections.length - 1].y + if (Math.abs(height) < 0.1) { + return { p1: { x: left, y: top }, p2: { x: left + width, y: top } }; + } else if (Math.abs(width) < 0.1) { + return { p1: { x: left, y: top }, p2: { x: left, y: top + height } }; + } else { + return { p1: { x: left, y: top }, p2: { x: left + width, y: top + height } }; } }; -} -function getExtensionIntersection( - startX, startY, // 대각선 시작점 - currentX, currentY, // 대각선 현재 위치 - lineStartX, lineStartY, // 연장할 선의 시작점 - lineEndX, lineEndY // 연장할 선의 끝점 -) { - // 대각선 방향 벡터 - const dx = currentX - startX; - const dy = currentY - startY; + // baseLine에 점이 있는지 확인 + const isPointOnBase = (point) => { + return baseLines?.some(baseLine => { + const coords = extractBaseLineCoordinates(baseLine); + return pointsEqual(point, coords.p1) || pointsEqual(point, coords.p2); + }) || false; + }; - // 연장할 선의 기울기 - const m = (lineEndY - lineStartY) / (lineEndX - lineStartX); + // baseLine과 교차하는지 확인 + const isIntersectingWithBase = (skeletonLine) => { + return baseLines?.some(baseLine => { + const coords = extractBaseLineCoordinates(baseLine); + const intersection = findIntersection( + skeletonLine.p1.x, skeletonLine.p1.y, skeletonLine.p2.x, skeletonLine.p2.y, + coords.p1.x, coords.p1.y, coords.p2.x, coords.p2.y + ); + return intersection !== null; + }) || false; + }; - // 매개변수 t 방정식에서 t를 구하기 위한 식 전개 - // 대각선의 parametric 방정식: x = startX + t*dx, y = startY + t*dy - // 연장할 선 방정식: y = m * (x - lineStartX) + lineStartY - // 이를 대입해 t 구함 - const numerator = m * (lineStartX - startX) + startY - lineStartY; - const denominator = dy - m * dx; + // 라인 타입 확인 + const getLineType = (p1, p2) => { + const dx = Math.abs(p2.x - p1.x); + const dy = Math.abs(p2.y - p1.y); + const tolerance = 0.1; - if (denominator === 0) { - // 평행하거나 일치하여 교점 없음 - return null; - } + if (dy < tolerance) return 'horizontal'; + if (dx < tolerance) return 'vertical'; + return 'diagonal'; + }; - const t = numerator / denominator; + // 라인 길이 계산 + const getLineLength = (p1, p2) => { + return Math.sqrt(Math.pow(p2.x - p1.x, 2) + Math.pow(p2.y - p1.y, 2)); + }; - const intersectX = startX + t * dx; - const intersectY = startY + t * dy; + /** + * 연결 상태 확인 함수 + * + * @param {Object} line - 검사할 skeletonLine (p1, p2) + * @param {number} lineIndex - 현재 라인의 인덱스 + * @returns {{isConnected: boolean, p1Connected: boolean, p2Connected: boolean}} 연결되어 있으면 true, 끊어져 있으면 false + * + * 연결 판단 기준: + * 1. p1이 baseLine과 연결되어 있는지 확인 + * 2. p1이 연결되어 있으면 p2가 skeletonLine과 연결되어 있는지 확인 + * 3. p1이 연결되어 있지 않으면 p2가 baseLine과 연결되어 있는지 확인 + * 4. p2도 연결되어 있지 않으면 p1과 p2가 skeletonLine과 연결되어 있는지 확인 + */ + const isConnected = (line, lineIndex) => { + const result= { + isConnected: false, + p1Connected: false, + p2Connected: false, + extendedLine: [] + } + const { p1, p2 } = line; - return { x: intersectX, y: intersectY }; -} + // 1. p1이 baseLine과 연결되어 있는지 확인 + const isP1OnBase = isPointOnBase(p1); + const isP2OnBase = isPointOnBase(p2); -function findMatchingLinePoints(Aline, APolygon, epsilon = 1e-10) { - const { p1, p2 } = Aline; - const matches = []; + if (isP1OnBase || isP2OnBase) { - // 선의 방향 판단 - function getLineDirection(point1, point2, epsilon = 1e-10) { - const deltaX = Math.abs(point1.x - point2.x); - const deltaY = Math.abs(point1.y - point2.y); - if (deltaX < epsilon && deltaY < epsilon) { - return { - type: 'point', - description: '점 (두 좌표가 동일)' - }; - } else if (deltaX < epsilon) { - return { - type: 'vertical', - description: '수직선 (세로)' - }; - } else if (deltaY < epsilon) { - return { - type: 'horizontal', - description: '수평선 (가로)' - }; + // 2. p1 또는 p2가 baseLine과 연결되어 있음 + // -> 연결되지 않은 점이 skeletonLine과 연결되어 있는지 확인 + for (let i = 0; i < skeletonLines.length; i++) { + if (i === lineIndex) continue; // 자기 자신은 제외 + + const otherLine = skeletonLines[i]; + const otherP1 = otherLine.p1; + const otherP2 = otherLine.p2; + + // p2가 다른 skeletonLine의 p1 또는 p2와 일치하는지 확인 + if (pointsEqual(p2, otherP1) || pointsEqual(p2, otherP2)) { + result.p1Connected = true; + result.p2Connected = true; + result.isConnected = true; + // p2가 연결되어 있으므로 전체 라인이 연결됨 + }else{ + result.p1Connected = true; + result.p2Connected = false; + result.isConnected = false; + } + + if (pointsEqual(p1, otherP1) || pointsEqual(p1, otherP2)) { + result.p1Connected = true; + result.p2Connected = true; + // p1이 다른 skeletonLine의 p1 또는 p2와 일치하는지 확인 + result.isConnected = true; + // p1이 연결되어 있으므로 전체 라인이 연결됨 + }else{ + result.p1Connected = true; + result.p2Connected = false; + result.isConnected = false; + } + + } + return result; + } else { - return { - type: 'diagonal', - description: '대각선' - }; - } - } + // 3. p1과 p2 모두 baseLine과 연결되어 있지 않음 + // -> p1과 p2가 skeletonLine과 연결되어 있는지 확인 + let p1Connected = false; // p1이 skeletonLine과 연결되어 있는지 + let p2Connected = false; // p2가 skeletonLine과 연결되어 있는지 - // 선의 방향 정보 계산 - const lineDirection = getLineDirection(p1, p2, epsilon); - APolygon.forEach((point, index) => { - // p1과 비교 - if (Math.abs(p1.x - point.X) < epsilon && Math.abs(p1.y - point.Y) < epsilon) { - matches.push({ - linePoint: 'p1', - polygonIndex: index, - coordinates: { x: point.X, y: point.Y }, - lineDirection: lineDirection, - type: lineDirection.type - }); + for (let i = 0; i < skeletonLines.length; i++) { + if (i === lineIndex) continue; // 자기 자신은 제외 + + const otherLine = skeletonLines[i]; + const otherP1 = otherLine.p1; + const otherP2 = otherLine.p2; + + // p1이 다른 skeletonLine의 p1 또는 p2와 일치하는지 확인 + if (!p1Connected && (pointsEqual(p1, otherP1) || pointsEqual(p1, otherP2))) { + p1Connected = true; + result.p1Connected = true; + }else{ + // p1이 skeletonLine과 연결되지 않음 - baseLine까지 연장 + result.extendedLine = extendToBaseLine(p1, baseLines); + } + +// p2가 다른 skeletonLine의 p1 또는 p2와 일치하는지 확인 + if (!p2Connected && (pointsEqual(p2, otherP1) || pointsEqual(p2, otherP2))) { + p2Connected = true; + result.p2Connected = true; + }else{ + // p2가 skeletonLine과 연결되지 않음 - baseLine까지 연장 + result.extendedLine = extendToBaseLine(p1, baseLines); + } + + // p1과 p2가 모두 연결되어 있으면 전체 라인이 연결됨 + if (p1Connected && p2Connected) { + result.isConnected = true; + } + + + } + + return result } - // p2와 비교 - if (Math.abs(p2.x - point.X) < epsilon && Math.abs(p2.y - point.Y) < epsilon) { - matches.push({ - linePoint: 'p2', - polygonIndex: index, - coordinates: { x: point.X, y: point.Y }, - lineDirection: lineDirection, - type: lineDirection.type + }; + + // 각 라인 분석 + skeletonLines.forEach((line, index) => { + const { p1, p2 } = line; + const length = getLineLength(p1, p2); + const type = getLineType(p1, p2); + + if (length < minLength) return; + + const connected = isConnected(line, index); + const extendedLine = connected.extendedLine; + const p1Connected = connected.p1Connected; + const p2Connected = connected.p2Connected; + + if (type === 'diagonal') { + diagonalLines.push({ line, index, length, type, connected}); + } else { + straightLines.push({ line, index, length, type, connected }); + } + + if (!connected.isConnected) { + disconnectedLines.push({ + line, index, length, type, + isDiagonal: type === 'diagonal', + isHorizontal: type === 'horizontal', + isVertical: type === 'vertical', + p1Connected: p1Connected, + p2Connected: p2Connected, + extendedLine: extendedLine, + + + }); } }); + const filteredDisconnected = includeDiagonal && includeStraight + ? disconnectedLines + : disconnectedLines.filter(item => + (includeDiagonal && item.isDiagonal) || + (includeStraight && (item.isHorizontal || item.isVertical)) + ); + return { - hasMatch: matches.length > 0, - lineDirection: lineDirection, - matches: matches + disconnectedLines: filteredDisconnected, + diagonalLines: includeDiagonal ? diagonalLines : [], + straightLines: includeStraight ? straightLines : [], + statistics: { + total: skeletonLines.length, + diagonal: diagonalLines.length, + straight: straightLines.length, + disconnected: filteredDisconnected.length + } }; -} +}; +const extendToBaseLine = (point, baseLines) => { + // point에서 가장 가까운 baseLine을 찾아서 연장 + let closestBaseLine = null; + let minDistance = Infinity; -function getLineIntersectionParametric(p1, p2, p3, p4) { - const d1 = { x: p2.x - p1.x, y: p2.y - p1.y }; // 첫번째 직선 방향벡터 - const d2 = { x: p4.x - p3.x, y: p4.y - p3.y }; // 두번째 직선 방향벡터 + for (const baseLine of baseLines) { + // point와 baseLine 사이의 거리 계산 + const distance = getDistanceToLine(point, baseLine); - // 평행선 체크 (외적이 0이면 평행) - const cross = d1.x * d2.y - d1.y * d2.x; - if (Math.abs(cross) < Number.EPSILON) { - return null; // 평행선 + if (distance < minDistance) { + minDistance = distance; + closestBaseLine = baseLine; + } } - // 매개변수 t 계산 - const dx = p3.x - p1.x; - const dy = p3.y - p1.y; - const t = (dx * d2.y - dy * d2.x) / cross; + if (closestBaseLine) { + // point에서 closestBaseLine으로 연장하는 라인 생성 + // 연장된 라인을 skeletonLines에 추가 + return { + p1: point, + p2: getProjectionPoint(point, closestBaseLine) + } + } +}; +/** + * 점과 선분 사이의 거리를 계산하는 함수 + * @param {Object} point - 거리를 계산할 점 {x, y} + * @param {Object} line - 선분 {x1, y1, x2, y2} + * @returns {number} 점과 선분 사이의 최단 거리 + */ +const getDistanceToLine = (point, line) => { + const { x: px, y: py } = point; + const { x1, y1, x2, y2 } = line; - // 교점: p1 + t * d1 - return { - x: p1.x + t * d1.x, - y: p1.y + t * d1.y - }; -} \ No newline at end of file + // 선분의 방향 벡터 + const dx = x2 - x1; + const dy = y2 - y1; + const lineLength = Math.sqrt(dx * dx + dy * dy); + + if (lineLength === 0) { + // 선분이 점인 경우 + return Math.sqrt((px - x1) * (px - x1) + (py - y1) * (py - y1)); + } + + // 선분의 단위 방향 벡터 + const ux = dx / lineLength; + const uy = dy / lineLength; + + // 점에서 선분 시작점으로의 벡터 + const vx = px - x1; + const vy = py - y1; + + // 투영된 길이 (스칼라 투영) + const projectionLength = vx * ux + vy * uy; + + // 투영점이 선분 범위 내에 있는지 확인 + if (projectionLength >= 0 && projectionLength <= lineLength) { + // 투영점이 선분 내에 있음 + const distance = Math.sqrt(vx * vx + vy * vy - projectionLength * projectionLength); + return distance; + } else { + // 투영점이 선분 밖에 있음 - 끝점까지의 거리 중 작은 값 + const distToStart = Math.sqrt(vx * vx + vy * vy); + const distToEnd = Math.sqrt((px - x2) * (px - x2) + (py - y2) * (py - y2)); + return Math.min(distToStart, distToEnd); + } +}; + +/** + * 점을 선분에 투영한 점을 반환하는 함수 + * @param {Object} point - 투영할 점 {x, y} + * @param {Object} line - 선분 {x1, y1, x2, y2} + * @returns {Object} 투영된 점 {x, y} + */ +const getProjectionPoint = (point, line) => { + const { x: px, y: py } = point; + const { x1, y1, x2, y2 } = line; + + // 선분의 방향 벡터 + const dx = x2 - x1; + const dy = y2 - y1; + const lineLength = Math.sqrt(dx * dx + dy * dy); + + if (lineLength === 0) { + // 선분이 점인 경우 + return { x: x1, y: y1 }; + } + + // 선분의 단위 방향 벡터 + const ux = dx / lineLength; + const uy = dy / lineLength; + + // 점에서 선분 시작점으로의 벡터 + const vx = px - x1; + const vy = py - y1; + + // 투영된 길이 (스칼라 투영) + const projectionLength = vx * ux + vy * uy; + + // 투영점이 선분 범위 내에 있는지 확인 + if (projectionLength >= 0 && projectionLength <= lineLength) { + // 투영점이 선분 내에 있음 + const projX = x1 + ux * projectionLength; + const projY = y1 + uy * projectionLength; + return { x: projX, y: projY }; + } else if (projectionLength < 0) { + // 투영점이 시작점 앞에 있음 + return { x: x1, y: y1 }; + } else { + // 투영점이 끝점 뒤에 있음 + return { x: x2, y: y2 }; + } +}; \ No newline at end of file From eeab13b9cdbbfd89c2564f763387d5c4199fb3c7 Mon Sep 17 00:00:00 2001 From: ysCha Date: Wed, 24 Sep 2025 10:02:52 +0900 Subject: [PATCH 15/20] =?UTF-8?q?=EC=B4=88=EA=B8=B0ALL=EB=AC=B8=EC=A0=9C?= =?UTF-8?q?=20=ED=95=B4=EA=B2=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../modal/basic/step/Orientation.jsx | 27 +++++++++++++++---- 1 file changed, 22 insertions(+), 5 deletions(-) diff --git a/src/components/floor-plan/modal/basic/step/Orientation.jsx b/src/components/floor-plan/modal/basic/step/Orientation.jsx index 976e2cd1..96fbc6e4 100644 --- a/src/components/floor-plan/modal/basic/step/Orientation.jsx +++ b/src/components/floor-plan/modal/basic/step/Orientation.jsx @@ -77,7 +77,7 @@ export const Orientation = forwardRef((props, ref) => { }; useEffect(() => { - if (basicSetting.roofSizeSet == '3') { + if (basicSetting.roofSizeSet === '3') { restoreModuleInstArea() } }, []) @@ -187,7 +187,7 @@ export const Orientation = forwardRef((props, ref) => { title: getMessage('module.not.found'), icon: 'warning', }) - return + } } } @@ -250,8 +250,17 @@ export const Orientation = forwardRef((props, ref) => { // 필터링된 목록의 첫 번째 모듈을 자동 선택 if (filtered.length > 0) { - setSelectedModules(filtered[0]) + const firstModule = filtered[0] + setSelectedModules(firstModule) + // 상위 컴포넌트의 handleChangeModule 호출 + if (handleChangeModule) { + handleChangeModule(firstModule) + } } + } else { + // 모듈 리스트가 비어있는 경우 + setFilteredModuleList([]) + setSelectedModules(null) } } @@ -342,10 +351,14 @@ export const Orientation = forwardRef((props, ref) => { setSelectedModuleSeries(currentSeries) } else { setSelectedModuleSeries(allOption) + // "ALL"이 선택되면 자동으로 모듈 필터링 및 선택 실행 + setTimeout(() => handleChangeModuleSeries(allOption), 0) } } else { // 선택된 모듈이 없으면 "전체"를 기본 선택 setSelectedModuleSeries(allOption) + // "ALL"이 선택되면 자동으로 모듈 필터링 및 선택 실행 + setTimeout(() => handleChangeModuleSeries(allOption), 0) } } } @@ -369,6 +382,9 @@ export const Orientation = forwardRef((props, ref) => { if (filtered.length > 0 && !selectedModules) { setSelectedModules(filtered[0]) } + } else if (moduleList.length === 0 && filteredModuleList.length === 0 && selectedModuleSeries) { + // 모듈 리스트가 비어있는 경우 빈 배열로 설정 + setFilteredModuleList([]) } }, [moduleList, selectedModuleSeries]); return ( @@ -462,6 +478,7 @@ export const Orientation = forwardRef((props, ref) => { sourceKey={'itemId'} showKey={'itemNm'} onChange={(e) => handleChangeModule(e)} + showFirstOptionWhenEmpty = {true} /> )} @@ -512,7 +529,7 @@ export const Orientation = forwardRef((props, ref) => { - {basicSetting && basicSetting.roofSizeSet == '3' && ( + {basicSetting && basicSetting.roofSizeSet === '3' && (
{getMessage('modal.module.basic.setting.module.placement.area')}
@@ -523,7 +540,7 @@ export const Orientation = forwardRef((props, ref) => { )}
- {basicSetting && basicSetting.roofSizeSet != '3' && ( + {basicSetting && basicSetting.roofSizeSet !== '3' && (
From 0efc90f1359b08fff07553b176ec69f6d2de563f Mon Sep 17 00:00:00 2001 From: ysCha Date: Wed, 24 Sep 2025 13:03:18 +0900 Subject: [PATCH 16/20] =?UTF-8?q?[1237]=20=EB=AA=A8=EB=93=88/=EA=B0=80?= =?UTF-8?q?=EB=8C=80=20=EB=AA=A9=EB=A1=9D=20-=20timeout=20=EC=97=B0?= =?UTF-8?q?=EC=9E=A5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../floor-plan/modal/basic/step/Trestle.jsx | 38 +++++++++++++------ 1 file changed, 27 insertions(+), 11 deletions(-) diff --git a/src/components/floor-plan/modal/basic/step/Trestle.jsx b/src/components/floor-plan/modal/basic/step/Trestle.jsx index c8b40ef3..77ade743 100644 --- a/src/components/floor-plan/modal/basic/step/Trestle.jsx +++ b/src/components/floor-plan/modal/basic/step/Trestle.jsx @@ -18,6 +18,11 @@ const Trestle = forwardRef((props, ref) => { const currentAngleType = useRecoilValue(currentAngleTypeSelector) const pitchText = useRecoilValue(pitchTextSelector) const [selectedRoof, setSelectedRoof] = useState(null) + const [isAutoSelecting, setIsAutoSelecting] = useState(false) // 자동 선택 중인지 상태 + const [autoSelectTimeout, setAutoSelectTimeout] = useState(null) // 타임아웃 참조 + const autoSelectTimeoutRef = useRef(null) + // 공통 타임아웃 설정 (밀리초) + const AUTO_SELECT_TIMEOUT = 700 // API 호출 완료 대기 시간 const { trestleState, trestleDetail, @@ -63,7 +68,7 @@ const Trestle = forwardRef((props, ref) => { useEffect(() => { - if (roofs && !selectedRoof) { + if (roofs && roofs.length > 0 && !selectedRoof) { console.log("roofs:::::", roofs.length) setLengthBase(roofs[0].length); setSelectedRoof(roofs[0]) @@ -71,12 +76,12 @@ const Trestle = forwardRef((props, ref) => { if (selectedRoof && selectedRoof.lenAuth === "C") { onChangeLength(selectedRoof.length); } - if (selectedRoof && ["C", "R"].includes(selectedRoof.raftAuth)) { + if (selectedRoof && ["C", "R"].includes(selectedRoof.raftAuth) && roofs && roofs.length > 0) { onChangeRaftBase(roofs[0]); } //모듈 설치 영역 복구 restoreModuleInstArea() - }, [roofs]) + }, [roofs, selectedRoof]) // selectedRoof 추가 useEffect(() => { if (flag && moduleSelectionData) { @@ -161,7 +166,7 @@ const Trestle = forwardRef((props, ref) => { useEffect(() => { if (constructionList.length > 0) { - const existingConstruction = constructionList.find((construction) => construction.constTp === trestleState?.construction?.constTp) + const existingConstruction = constructionList.find((construction) => construction.constTp === trestleState.constTp) if (existingConstruction) { setSelectedConstruction(existingConstruction) } else if (autoSelectStep === 'construction') { @@ -252,7 +257,7 @@ const Trestle = forwardRef((props, ref) => { // 다음 단계(가대메이커) 자동 선택 설정 - 지연 실행 setTimeout(() => { setAutoSelectStep('trestle') - }, 500) // API 호출 완료를 위한 더 긴 지연 + }, AUTO_SELECT_TIMEOUT) // API 호출 완료를 위한 더 긴 지연 } const onChangeHajebichi = (e) => { @@ -282,7 +287,7 @@ const Trestle = forwardRef((props, ref) => { // 다음 단계(가대메이커) 자동 선택 설정 - 지연 실행 setTimeout(() => { setAutoSelectStep('trestle') - }, 500) + }, AUTO_SELECT_TIMEOUT) } const onChangeTrestleMaker = (e) => { @@ -305,7 +310,7 @@ const Trestle = forwardRef((props, ref) => { // API 호출 완료 후 다음 단계(공법) 자동 선택 설정 setTimeout(() => { setAutoSelectStep('constMthd') - }, 300) + }, AUTO_SELECT_TIMEOUT) } const onChangeConstMthd = (e) => { @@ -325,10 +330,21 @@ const Trestle = forwardRef((props, ref) => { }, }) + // 기존 타임아웃 취소 + if (autoSelectTimeoutRef.current) { + clearTimeout(autoSelectTimeoutRef.current) + } + + // 자동 선택 중 상태 활성화 + setIsAutoSelecting(true) + // API 호출 완료 후 다음 단계(지붕밑바탕) 자동 선택 설정 - setTimeout(() => { + const timeoutId = setTimeout(() => { setAutoSelectStep('roofBase') - }, 300) + setIsAutoSelecting(false) + }, AUTO_SELECT_TIMEOUT) + + autoSelectTimeoutRef.current = timeoutId } const onChangeRoofBase = (e) => { @@ -356,7 +372,7 @@ const Trestle = forwardRef((props, ref) => { // API 호출 완료 후 다음 단계(construction) 자동 선택 설정 setTimeout(() => { setAutoSelectStep('construction') - }, 300) + }, AUTO_SELECT_TIMEOUT) } const handleConstruction = (index) => { @@ -451,7 +467,7 @@ const Trestle = forwardRef((props, ref) => { ...selectedRoofBase, }, construction: { - ...constructionList.find((data) => data.constTp === trestleState.constTp), + ...constructionList.find((data) => newAddedRoofs[index].construction.constTp === data.constTp), cvrYn, snowGdPossYn, cvrChecked, From 4df39defc66bda8968771a453ac9293062ee04da Mon Sep 17 00:00:00 2001 From: ysCha Date: Thu, 25 Sep 2025 10:50:02 +0900 Subject: [PATCH 17/20] next trestleState.constTp --- src/components/floor-plan/modal/basic/step/Trestle.jsx | 1 + 1 file changed, 1 insertion(+) diff --git a/src/components/floor-plan/modal/basic/step/Trestle.jsx b/src/components/floor-plan/modal/basic/step/Trestle.jsx index 77ade743..673fffdb 100644 --- a/src/components/floor-plan/modal/basic/step/Trestle.jsx +++ b/src/components/floor-plan/modal/basic/step/Trestle.jsx @@ -21,6 +21,7 @@ const Trestle = forwardRef((props, ref) => { const [isAutoSelecting, setIsAutoSelecting] = useState(false) // 자동 선택 중인지 상태 const [autoSelectTimeout, setAutoSelectTimeout] = useState(null) // 타임아웃 참조 const autoSelectTimeoutRef = useRef(null) + // 공통 타임아웃 설정 (밀리초) const AUTO_SELECT_TIMEOUT = 700 // API 호출 완료 대기 시간 const { From 9c0403c9473972207dd09d0a325cc04b757d770b Mon Sep 17 00:00:00 2001 From: ysCha Date: Thu, 25 Sep 2025 10:54:50 +0900 Subject: [PATCH 18/20] trestleState.constTp === data.constTp), --- src/components/floor-plan/modal/basic/step/Trestle.jsx | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/components/floor-plan/modal/basic/step/Trestle.jsx b/src/components/floor-plan/modal/basic/step/Trestle.jsx index 673fffdb..0bf9bd50 100644 --- a/src/components/floor-plan/modal/basic/step/Trestle.jsx +++ b/src/components/floor-plan/modal/basic/step/Trestle.jsx @@ -468,7 +468,8 @@ const Trestle = forwardRef((props, ref) => { ...selectedRoofBase, }, construction: { - ...constructionList.find((data) => newAddedRoofs[index].construction.constTp === data.constTp), + //...constructionList.find((data) => newAddedRoofs[index].construction.constTp === data.constTp), + ...constructionList.find((data) => trestleState.constTp === data.constTp), cvrYn, snowGdPossYn, cvrChecked, From 446969161850b952b7a5e8a4267fbd3f5499fe3c Mon Sep 17 00:00:00 2001 From: ysCha Date: Fri, 26 Sep 2025 10:57:03 +0900 Subject: [PATCH 19/20] =?UTF-8?q?[1237]=20=EB=AA=A8=EB=93=88=20=EC=9E=90?= =?UTF-8?q?=EB=8F=99=20=EC=84=A0=ED=83=9D=20=EB=AC=B8=EC=A0=9C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../floor-plan/modal/basic/step/Trestle.jsx | 13 +++++++++++-- 1 file changed, 11 insertions(+), 2 deletions(-) diff --git a/src/components/floor-plan/modal/basic/step/Trestle.jsx b/src/components/floor-plan/modal/basic/step/Trestle.jsx index 0bf9bd50..08bed95b 100644 --- a/src/components/floor-plan/modal/basic/step/Trestle.jsx +++ b/src/components/floor-plan/modal/basic/step/Trestle.jsx @@ -23,7 +23,7 @@ const Trestle = forwardRef((props, ref) => { const autoSelectTimeoutRef = useRef(null) // 공통 타임아웃 설정 (밀리초) - const AUTO_SELECT_TIMEOUT = 700 // API 호출 완료 대기 시간 + const AUTO_SELECT_TIMEOUT = 500 // API 호출 완료 대기 시간 const { trestleState, trestleDetail, @@ -66,7 +66,7 @@ const Trestle = forwardRef((props, ref) => { const [flag, setFlag] = useState(false) const tempModuleSelectionData = useRef(null) const [autoSelectStep, setAutoSelectStep] = useState(null) // 'raftBase', 'trestle', 'constMthd', 'roofBase', 'construction' - + const prevHajebichiRef = useRef(); useEffect(() => { if (roofs && roofs.length > 0 && !selectedRoof) { @@ -80,6 +80,15 @@ const Trestle = forwardRef((props, ref) => { if (selectedRoof && ["C", "R"].includes(selectedRoof.raftAuth) && roofs && roofs.length > 0) { onChangeRaftBase(roofs[0]); } + + if ( + selectedRoof && ["C", "R"].includes(selectedRoof.roofPchAuth) && roofs && roofs.length > 0 && + roofs[0].hajebichi !== prevHajebichiRef.current + ) { + prevHajebichiRef.current = roofs[0].hajebichi; + onChangeHajebichi(roofs[0].hajebichi); + } + //모듈 설치 영역 복구 restoreModuleInstArea() }, [roofs, selectedRoof]) // selectedRoof 추가 From 9786c1fbf726130854bea7fbb4e6532a5acf0a7a Mon Sep 17 00:00:00 2001 From: ysCha Date: Fri, 26 Sep 2025 18:01:08 +0900 Subject: [PATCH 20/20] =?UTF-8?q?[1306]=20=EA=B0=80=EB=8C=80=20=EC=84=A4?= =?UTF-8?q?=EC=A0=95=EC=9D=98=20=EB=B6=88=EB=9F=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../floor-plan/modal/basic/step/Trestle.jsx | 33 +++++++++---------- 1 file changed, 16 insertions(+), 17 deletions(-) diff --git a/src/components/floor-plan/modal/basic/step/Trestle.jsx b/src/components/floor-plan/modal/basic/step/Trestle.jsx index 08bed95b..13f46a31 100644 --- a/src/components/floor-plan/modal/basic/step/Trestle.jsx +++ b/src/components/floor-plan/modal/basic/step/Trestle.jsx @@ -76,15 +76,10 @@ const Trestle = forwardRef((props, ref) => { } if (selectedRoof && selectedRoof.lenAuth === "C") { onChangeLength(selectedRoof.length); - } - if (selectedRoof && ["C", "R"].includes(selectedRoof.raftAuth) && roofs && roofs.length > 0) { + }else if (selectedRoof && ["C", "R"].includes(selectedRoof.raftAuth) && roofs && roofs.length > 0) { onChangeRaftBase(roofs[0]); - } - - if ( - selectedRoof && ["C", "R"].includes(selectedRoof.roofPchAuth) && roofs && roofs.length > 0 && - roofs[0].hajebichi !== prevHajebichiRef.current - ) { + }else if (selectedRoof && ["C", "R"].includes(selectedRoof.roofPchAuth) && roofs && roofs.length > 0 && + roofs[0].hajebichi !== prevHajebichiRef.current ) { prevHajebichiRef.current = roofs[0].hajebichi; onChangeHajebichi(roofs[0].hajebichi); } @@ -289,7 +284,7 @@ const Trestle = forwardRef((props, ref) => { roof: { moduleTpCd: selectedModules.itemTp ?? '', roofMatlCd: selectedRoof?.roofMatlCd ?? '', - raft: selectedRaftBase?.clCode, + raft: selectedRaftBase?.clCode ?? selectedRoof?.roofBaseCd, hajebichi: e, }, }) @@ -312,7 +307,8 @@ const Trestle = forwardRef((props, ref) => { roof: { moduleTpCd: selectedModules.itemTp ?? '', roofMatlCd: selectedRoof?.roofMatlCd ?? '', - raft: selectedRaftBase?.clCode, + raft: selectedRaftBase?.clCode ?? selectedRoof?.roofBaseCd, + //hajebichi: selectedRaftBase?.hajebichi ?? selectedRoof?.hajebichi, trestleMkrCd: e.trestleMkrCd, }, }) @@ -334,7 +330,8 @@ const Trestle = forwardRef((props, ref) => { roof: { moduleTpCd: selectedModules.itemTp ?? '', roofMatlCd: selectedRoof?.roofMatlCd ?? '', - raft: selectedRaftBase?.clCode, + raft: selectedRaftBase?.clCode ?? selectedRoof?.roofBaseCd, + //hajebichi: selectedRaftBase?.hajebichi ?? selectedRoof?.hajebichi, trestleMkrCd: selectedTrestle?.trestleMkrCd, constMthdCd: e.constMthdCd, }, @@ -366,7 +363,8 @@ const Trestle = forwardRef((props, ref) => { roof: { moduleTpCd: selectedModules.itemTp ?? '', roofMatlCd: selectedRoof?.roofMatlCd ?? '', - raft: selectedRaftBase?.clCode, + raft: selectedRaftBase?.clCode ?? selectedRoof?.roofBaseCd, + //hajebichi: selectedRaftBase?.hajebichi ?? selectedRoof?.hajebichi, trestleMkrCd: selectedTrestle?.trestleMkrCd, constMthdCd: selectedConstMthd?.constMthdCd, roofBaseCd: e.roofBaseCd, @@ -392,7 +390,8 @@ const Trestle = forwardRef((props, ref) => { roof: { moduleTpCd: selectedModules.itemTp ?? '', roofMatlCd: selectedRoof?.roofMatlCd ?? '', - raft: selectedRaftBase?.clCode, + raft: selectedRaftBase?.clCode ?? selectedRoof?.roofBaseCd, + //hajebichi: selectedRaftBase?.hajebichi ?? selectedRoof?.hajebichi, trestleMkrCd: selectedTrestle.trestleMkrCd, constMthdCd: selectedConstMthd.constMthdCd, roofBaseCd: selectedRoofBase.roofBaseCd, @@ -429,7 +428,7 @@ const Trestle = forwardRef((props, ref) => { ridgeMargin, kerabaMargin, roofIndex: selectedRoof.index, - raft: selectedRaftBase?.clCode, + raft: selectedRaftBase?.clCode ?? selectedRoof?.roofBaseCd, trestle: { hajebichi: hajebichi, length: lengthBase, @@ -466,8 +465,8 @@ const Trestle = forwardRef((props, ref) => { ridgeMargin, kerabaMargin, roofIndex: roof.index, - raft: selectedRaftBase?.clCode, - hajebichi: hajebichi, + raft: selectedRaftBase?.clCode ?? selectedRoof?.raft ?? '', + //hajebichi: selectedRaftBase?.hajebichi ?? selectedRoof?.hajebichi ?? 0, trestle: { length: lengthBase, hajebichi: hajebichi, @@ -552,7 +551,7 @@ const Trestle = forwardRef((props, ref) => { } if (['C', 'R'].includes(roof.roofPchAuth)) { - if (!roof?.roofPchBase) { + if (!roof?.hajebichi) { Swal.fire({ title: getMessage('modal.module.basic.settting.module.error7', [roof.nameJp]), // 하제비치를 입력해주세요. icon: 'warning',