diff --git a/package.json b/package.json index 14b1299b..7be9b3f3 100644 --- a/package.json +++ b/package.json @@ -55,11 +55,14 @@ }, "devDependencies": { "@turf/turf": "^7.0.0", + "@types/node": "^24.3.0", + "@types/react": "^19.1.11", "convertapi": "^1.14.0", "postcss": "^8", "prettier": "^3.3.3", "react-color-palette": "^7.2.2", "sass": "^1.77.8", - "tailwindcss": "^3.4.1" + "tailwindcss": "^3.4.1", + "typescript": "^5.9.2" } } diff --git a/src/common/common.js b/src/common/common.js index 61fdabd1..9d15e988 100644 --- a/src/common/common.js +++ b/src/common/common.js @@ -13,6 +13,7 @@ export const MENU = { MOVEMENT_SHAPE_UPDOWN: 'movementShapeUpdown', // 동선이동.형올림내림 OUTLINE_EDIT_OFFSET: 'outlineEditOffset', // 외벽선 편집 및 오프셋 ROOF_SHAPE_ALLOC: 'rootShapeAlloc', // 지붕면 항당 + ALL_REMOVE: 'allRemove', // 전체 삭제 DEFAULT: 'roofCoveringDefault', // 아무것도 선택 안할 경우 }, // 지붕덮개 BATCH_CANVAS: { diff --git a/src/components/common/input/CalcInput.jsx b/src/components/common/input/CalcInput.jsx new file mode 100644 index 00000000..311e71c3 --- /dev/null +++ b/src/components/common/input/CalcInput.jsx @@ -0,0 +1,434 @@ +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) + + // 외부 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() + } + } + } + + 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) + } 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) + } + }) + } + + // 연산자 처리 함수 수정 + 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.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) + } + }) + } + + // 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) { + 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 + } + + 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('.')) { + 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) // 엔터키로 호출됨을 표시 + setShowKeypad(false) + } + // 키패드가 숨겨진 상태에서 엔터키: 페이지로 전달 (preventDefault 하지 않음) + break + case 'Backspace': + case 'Delete': + handleDelete() + break + + case 'Escape': + handleClear() + setShowKeypad(false) + break + + default: + break + } + } + } + + 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 버튼 */} + + + {/* = 버튼 */} + +
+
+ )} +
+ ) +}) + +CalculatorInput.displayName = 'CalculatorInput' diff --git a/src/components/fabric/QLine.js b/src/components/fabric/QLine.js index 7881712e..77254d5d 100644 --- a/src/components/fabric/QLine.js +++ b/src/components/fabric/QLine.js @@ -2,6 +2,7 @@ import { fabric } from 'fabric' import { v4 as uuidv4 } from 'uuid' import { getDirectionByPoint } from '@/util/canvas-util' import { calcLinePlaneSize } from '@/util/qpolygon-utils' +import { logger } from '@/util/logger' export const QLine = fabric.util.createClass(fabric.Line, { type: 'QLine', @@ -69,7 +70,14 @@ export const QLine = fabric.util.createClass(fabric.Line, { }, setLength() { - this.length = calcLinePlaneSize(this) / 10 + // Ensure all required properties are valid numbers + const { x1, y1, x2, y2 } = this; + if (isNaN(x1) || isNaN(y1) || isNaN(x2) || isNaN(y2)) { + logger.error('Invalid coordinates in QLine:', { x1, y1, x2, y2 }); + this.length = 0; + return; + } + this.length = calcLinePlaneSize({ x1, y1, x2, y2 }) / 10; }, addLengthText() { diff --git a/src/components/floor-plan/CanvasFrame.jsx b/src/components/floor-plan/CanvasFrame.jsx index 7b196bed..4b718c05 100644 --- a/src/components/floor-plan/CanvasFrame.jsx +++ b/src/components/floor-plan/CanvasFrame.jsx @@ -66,12 +66,17 @@ export default function CanvasFrame() { canvas?.loadFromJSON(JSON.parse(plan.canvasStatus), function () { canvasLoadInit() //config된 상태로 캔버스 객체를 그린다 canvas?.renderAll() // 캔버스를 다시 그립니다. + if (canvas.getObjects().filter((obj) => obj.name === POLYGON_TYPE.MODULE).length > 0) { - setSelectedMenu('module') - } else if (canvas.getObjects().length === 0 || canvas.getObjects().filter((obj) => obj.name === POLYGON_TYPE.WALL).length > 0) { + setTimeout(() => { + setSelectedMenu('module') + }, 500) + } else if (canvas.getObjects().filter((obj) => obj.name === POLYGON_TYPE.WALL).length > 0) { setSelectedMenu('outline') } else { - setSelectedMenu('surface') + setTimeout(() => { + setSelectedMenu('surface') + }, 500) } const roofs = canvas.getObjects().filter((obj) => obj.name === POLYGON_TYPE.ROOF) diff --git a/src/components/floor-plan/modal/basic/step/Placement.jsx b/src/components/floor-plan/modal/basic/step/Placement.jsx index e96cf5c0..091129a8 100644 --- a/src/components/floor-plan/modal/basic/step/Placement.jsx +++ b/src/components/floor-plan/modal/basic/step/Placement.jsx @@ -169,6 +169,7 @@ const Placement = forwardRef((props, refs) => {
+ {moduleData.header.map((data) => ( ))} + {selectedModules?.itemList && @@ -216,7 +218,7 @@ const Placement = forwardRef((props, refs) => { className="input-origin block" name="row" value={props.layoutSetup[index]?.row ?? 1} - defaultValue={0} + //defaultValue={0} onChange={(e) => handleLayoutSetup(e, item.itemId, index)} /> @@ -228,7 +230,7 @@ const Placement = forwardRef((props, refs) => { className="input-origin block" name="col" value={props.layoutSetup[index]?.col ?? 1} - defaultValue={0} + //defaultValue={0} onChange={(e) => handleLayoutSetup(e, item.itemId, index)} /> diff --git a/src/components/floor-plan/modal/circuitTrestle/CircuitTrestleSetting.jsx b/src/components/floor-plan/modal/circuitTrestle/CircuitTrestleSetting.jsx index 4c09868a..68893c23 100644 --- a/src/components/floor-plan/modal/circuitTrestle/CircuitTrestleSetting.jsx +++ b/src/components/floor-plan/modal/circuitTrestle/CircuitTrestleSetting.jsx @@ -122,7 +122,7 @@ export default function CircuitTrestleSetting({ id }) { canvas.set({ zoom: 1 }) // roof 객체들을 찾아서 중앙점 계산 - const roofs = canvas.getObjects().filter((obj) => obj.name === 'roof') + const roofs = canvas.getObjects().filter((obj) => obj.name === 'roof' && !obj.wall) if (roofs.length > 0) { // 모든 roof의 x, y 좌표를 수집 diff --git a/src/components/floor-plan/modal/eavesGable/type/Eaves.jsx b/src/components/floor-plan/modal/eavesGable/type/Eaves.jsx index f36fcc55..f9548dad 100644 --- a/src/components/floor-plan/modal/eavesGable/type/Eaves.jsx +++ b/src/components/floor-plan/modal/eavesGable/type/Eaves.jsx @@ -66,7 +66,9 @@ export default function Eaves({ pitchRef, offsetRef, widthRef, radioTypeRef, pit
- +
+ +
@@ -79,7 +81,9 @@ export default function Eaves({ pitchRef, offsetRef, widthRef, radioTypeRef, pit
- +
+ +
@@ -105,7 +109,9 @@ export default function Eaves({ pitchRef, offsetRef, widthRef, radioTypeRef, pit
- +
+ +
diff --git a/src/components/floor-plan/modal/eavesGable/type/Gable.jsx b/src/components/floor-plan/modal/eavesGable/type/Gable.jsx index 999687fd..7dd21539 100644 --- a/src/components/floor-plan/modal/eavesGable/type/Gable.jsx +++ b/src/components/floor-plan/modal/eavesGable/type/Gable.jsx @@ -37,7 +37,9 @@ export default function Gable({ pitchRef, offsetRef, widthRef, radioTypeRef, pit
- +
+ +
@@ -50,7 +52,9 @@ export default function Gable({ pitchRef, offsetRef, widthRef, radioTypeRef, pit
- +
+ +
@@ -74,7 +78,9 @@ export default function Gable({ pitchRef, offsetRef, widthRef, radioTypeRef, pit
- +
+ +
@@ -92,7 +98,9 @@ export default function Gable({ pitchRef, offsetRef, widthRef, radioTypeRef, pit
- +
+ +
diff --git a/src/components/floor-plan/modal/eavesGable/type/WallMerge.jsx b/src/components/floor-plan/modal/eavesGable/type/WallMerge.jsx index 31cf3909..0ed01ede 100644 --- a/src/components/floor-plan/modal/eavesGable/type/WallMerge.jsx +++ b/src/components/floor-plan/modal/eavesGable/type/WallMerge.jsx @@ -23,7 +23,9 @@ export default function WallMerge({ offsetRef, radioTypeRef }) {
- +
+ +
@@ -36,7 +38,9 @@ export default function WallMerge({ offsetRef, radioTypeRef }) {
- +
+ +
@@ -54,7 +58,9 @@ export default function WallMerge({ offsetRef, radioTypeRef }) {
- +
+ +
diff --git a/src/components/floor-plan/modal/flowDirection/FlowDirectionSetting.jsx b/src/components/floor-plan/modal/flowDirection/FlowDirectionSetting.jsx index a84efccf..4c1f0991 100644 --- a/src/components/floor-plan/modal/flowDirection/FlowDirectionSetting.jsx +++ b/src/components/floor-plan/modal/flowDirection/FlowDirectionSetting.jsx @@ -8,6 +8,7 @@ import { usePopup } from '@/hooks/usePopup' import { canvasState } from '@/store/canvasAtom' import { usePolygon } from '@/hooks/usePolygon' import { useSurfaceShapeBatch } from '@/hooks/surface/useSurfaceShapeBatch' +import { useRoofFn } from '@/hooks/common/useRoofFn' const FLOW_DIRECTION_TYPE = { EIGHT_AZIMUTH: 'eightAzimuth', @@ -19,6 +20,7 @@ export default function FlowDirectionSetting(props) { const { id, pos = contextPopupPosition, target } = props const canvas = useRecoilValue(canvasState) const { getMessage } = useMessage() + const { setSurfaceShapePattern } = useRoofFn() const { changeSurfaceLineType } = useSurfaceShapeBatch({}) @@ -79,6 +81,7 @@ export default function FlowDirectionSetting(props) { surfaceCompass: orientation, surfaceCompassType: type, }) + setSurfaceShapePattern(roof, null, null, roof.roofMaterial) drawDirectionArrow(roof) canvas?.renderAll() changeSurfaceLineType(roof) diff --git a/src/components/floor-plan/modal/lineTypes/OuterLineWall.jsx b/src/components/floor-plan/modal/lineTypes/OuterLineWall.jsx index 2eb5bcd0..ffbfa9f4 100644 --- a/src/components/floor-plan/modal/lineTypes/OuterLineWall.jsx +++ b/src/components/floor-plan/modal/lineTypes/OuterLineWall.jsx @@ -2,29 +2,90 @@ import { useMessage } from '@/hooks/useMessage' import { normalizeDigits } from '@/util/input-utils' +import { getChonByDegree, getDegreeByChon } from '@/util/canvas-util' +import { CalculatorInput } from '@/components/common/input/CalcInput' +import { useEffect, useRef } from 'react' export default function OuterLineWall({ props }) { const { getMessage } = useMessage() const { length1, setLength1, length1Ref, arrow1, setArrow1 } = props + + // 키보드 입력 처리 + useEffect(() => { + const handleKeyDown = (e) => { + // 계산기 키패드가 보이는지 확인 + const keypadVisible = document.querySelector('.keypad-container') + + // 계산기 키패드가 보이면 계산기가 처리하도록 함 + if (keypadVisible) { + return + } + + // 이미 계산기 input에 포커스가 있으면 계산기가 처리하도록 함 + if (document.activeElement && document.activeElement.classList.contains('calculator-input')) { + return + } + + // 엔터키는 계산기가 숨겨진 상태에서만 페이지가 처리 + if (e.key === 'Enter') { + // 엔터키를 페이지 레벨로 전달 (useOuterLineWall.js에서 처리) + return + } + + // 숫자 키가 입력되면 계산기 input에 포커스만 주기 + if (/^[0-9+\-*\/=.]$/.test(e.key) || e.key === 'Backspace' || e.key === 'Delete') { + const calcInput = document.querySelector('.calculator-input') + if (calcInput) { + // 포커스만 주고 이벤트는 preventDefault 하지 않음 + calcInput.focus() + calcInput.click() + } + } + } + + // capture: true로 설정하여 다른 핸들러보다 먼저 실행 + document.addEventListener('keydown', handleKeyDown, { capture: true }) + return () => document.removeEventListener('keydown', handleKeyDown, { capture: true }) + }, []) return (
{getMessage('straight.line')}
- {*/} + {/* if (length1Ref.current.value === '0') {*/} + {/* length1Ref.current.value = ''*/} + {/* }*/} + {/* }}*/} + {/* onChange={(e) => setLength1(normalizeDigits(e.target.value))}*/} + {/* placeholder="3000"*/} + {/*/>*/} + + setLength1(value)} + options={{ + allowNegative: false, + allowDecimal: false //(index !== 0), + }} + placeholder={'3000'} ref={length1Ref} - onFocus={(e) => { - if (length1Ref.current.value === '0') { + onFocus={() => { + if (length1Ref.current && length1Ref.current.value === '0') { length1Ref.current.value = '' } }} - onChange={(e) => setLength1(normalizeDigits(e.target.value))} - placeholder="3000" />
diff --git a/src/components/floor-plan/modal/movement/type/FlowLine.jsx b/src/components/floor-plan/modal/movement/type/FlowLine.jsx index 337ec50b..7c400fd4 100644 --- a/src/components/floor-plan/modal/movement/type/FlowLine.jsx +++ b/src/components/floor-plan/modal/movement/type/FlowLine.jsx @@ -12,11 +12,12 @@ export default function FlowLine({ FLOW_LINE_REF }) { const { getMessage } = useMessage() const [type, setType] = useState(FLOW_LINE_TYPE.DOWN_LEFT) const [filledInput, setFilledInput] = useState('') + const [pointerInput, setPointerInput] = useState('100') const currentObject = useRecoilValue(currentObjectState) const handleFocus = () => { if (currentObject === null) { - FLOW_LINE_REF.POINTER_INPUT_REF.current.value = '' - FLOW_LINE_REF.FILLED_INPUT_REF.current.value = '' + setPointerInput('') + setFilledInput('') FLOW_LINE_REF.FILLED_INPUT_REF.current.blur() } } @@ -35,7 +36,7 @@ export default function FlowLine({ FLOW_LINE_REF }) {
{getMessage('modal.movement.flow.line.position')}
- +
@@ -71,7 +72,6 @@ export default function FlowLine({ FLOW_LINE_REF }) {
- { const v = normalizeDecimal(e.target.value) e.target.value = v @@ -342,6 +343,34 @@ export default function PlacementShapeSetting({ id, pos = { x: 50, y: 180 }, pla setCurrentRoof({ ...currentRoof, pitch: num === '' ? '' : getChonByDegree(num), angle: num === '' ? '' : num }) } }} + /> */} + { + if (index === 0) { + const num = value === '' ? '' : Number(value) + setCurrentRoof(prev => ({ + ...prev, + pitch: num === '' ? '' : num, + angle: num === '' ? '' : getDegreeByChon(num), + })) + } else { + const num = value === '' ? '' : Number(value) + setCurrentRoof( prev => ({ + ...prev, + pitch: num === '' ? '' : getChonByDegree(num), + angle: num === '' ? '' : num, + })) + } + }} + options={{ + allowNegative: false, + allowDecimal: false //(index !== 0), + }} />
{index === 0 ? '寸' : '度'} @@ -420,10 +449,8 @@ export default function PlacementShapeSetting({ id, pos = { x: 50, y: 180 }, pla
r.clCode === ( currentRoof.raft?? currentRoof?.raftBaseCd))?.clCodeNm - } - value={currentRoof?.raft??currentRoof?.raftBaseCd} + title={raftCodes?.find((r) => r.clCode === (currentRoof.raft ?? currentRoof?.raftBaseCd))?.clCodeNm} + value={currentRoof?.raft ?? currentRoof?.raftBaseCd} onChange={(e) => handleRafterChange(e.clCode)} sourceKey="clCode" targetKey={currentRoof?.raft ? 'raft' : 'raftBaseCd'} diff --git a/src/components/floor-plan/modal/roofAllocation/RoofAllocationSetting.jsx b/src/components/floor-plan/modal/roofAllocation/RoofAllocationSetting.jsx index 7c9d4f51..32364844 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 (
- +
{pitchText} diff --git a/src/hooks/common/useMenu.js b/src/hooks/common/useMenu.js index 7836b73b..40c1b6a0 100644 --- a/src/hooks/common/useMenu.js +++ b/src/hooks/common/useMenu.js @@ -66,6 +66,9 @@ export default function useMenu() { case MENU.ROOF_COVERING.ROOF_SHAPE_ALLOC: addPopup(popupId, 1, ) break + case MENU.ROOF_COVERING.ALL_REMOVE: + deleteAllSurfacesAndObjects() + break } } diff --git a/src/hooks/common/useRoofFn.js b/src/hooks/common/useRoofFn.js index 0635b89e..eb640d76 100644 --- a/src/hooks/common/useRoofFn.js +++ b/src/hooks/common/useRoofFn.js @@ -6,6 +6,8 @@ import { POLYGON_TYPE } from '@/common/common' import { useEvent } from '@/hooks/useEvent' import { useLine } from '@/hooks/useLine' import { outerLinePointsState } from '@/store/outerLineAtom' +import { usePolygon } from '@/hooks/usePolygon' +import { useText } from '@/hooks/useText' const ROOF_COLOR = { 0: 'rgb(199,240,213)', @@ -13,6 +15,7 @@ const ROOF_COLOR = { 2: 'rgb(187,204,255)', 3: 'rgb(228,202,255)', } + export function useRoofFn() { const canvas = useRecoilValue(canvasState) const selectedRoofMaterial = useRecoilValue(selectedRoofMaterialSelector) @@ -20,6 +23,8 @@ export function useRoofFn() { const { addCanvasMouseEventListener, initEvent } = useEvent() const resetPoints = useResetRecoilState(outerLinePointsState) const { addPitchText } = useLine() + const { setPolygonLinesActualSize } = usePolygon() + const { changeCorridorDimensionText } = useText() //면형상 선택 클릭시 지붕 패턴 입히기 function setSurfaceShapePattern(polygon, mode = 'onlyBorder', trestleMode = false, roofMaterial, isForceChange = false, isDisplay = false) { @@ -27,6 +32,9 @@ export function useRoofFn() { if (!polygon) { return } + if (polygon.wall) { + return + } if (polygon.points.length < 3) { return } @@ -44,6 +52,7 @@ export function useRoofFn() { let width = (roofMaterial.width || 226) / 10 let height = (roofMaterial.length || 158) / 10 + const index = roofMaterial.index ?? 0 let roofStyle = 2 const inputPatternSize = { width: width, height: height } //임시 사이즈 @@ -169,6 +178,8 @@ export function useRoofFn() { polygon.set('fill', null) polygon.set('fill', pattern) polygon.roofMaterial = roofMaterial + setPolygonLinesActualSize(polygon) + changeCorridorDimensionText() polygon.canvas?.renderAll() } catch (e) { console.log(e) @@ -303,7 +314,15 @@ export function useRoofFn() { } function convertAbsolutePoint(area) { - return area.points.map((p) => fabric.util.transformPoint({ x: p.x - area.pathOffset.x, y: p.y - area.pathOffset.y }, area.calcTransformMatrix())) + return area.points.map((p) => + fabric.util.transformPoint( + { + x: p.x - area.pathOffset.x, + y: p.y - area.pathOffset.y, + }, + area.calcTransformMatrix(), + ), + ) } const removeOuterLines = (currentMousePos) => { diff --git a/src/hooks/module/useModuleBasicSetting.js b/src/hooks/module/useModuleBasicSetting.js index 7a700dc0..933a423e 100644 --- a/src/hooks/module/useModuleBasicSetting.js +++ b/src/hooks/module/useModuleBasicSetting.js @@ -63,6 +63,7 @@ export function useModuleBasicSetting(tabNum) { const { checkModuleDisjointSurface } = useTurf() useEffect(() => { + initEvent() return () => { //수동 설치시 초기화 removeMouseEvent('mouse:up') @@ -139,7 +140,7 @@ export function useModuleBasicSetting(tabNum) { roof.lines.forEach((line) => { line.attributes = { ...line.attributes, - offset: getOffset(offsetObjects.addRoof, line, roof.pitch, roof.from), + offset: getOffset(offsetObjects.addRoof, line, roof.roofMaterial.pitch), } }) //배치면 설치 영역 @@ -209,9 +210,9 @@ export function useModuleBasicSetting(tabNum) { const calculateHeightRate = 1 / Math.cos((degree * Math.PI) / 180) const calculateValue = calculateHeightRate / calculateExpression(degree) - const eavesResult = from === 'roofCover' ? (data.eavesMargin * Math.cos((degree * Math.PI) / 180)) / 10 : data.eavesMargin / 10 - const ridgeResult = from === 'roofCover' ? (data.ridgeMargin * Math.cos((degree * Math.PI) / 180)) / 10 : data.ridgeMargin / 10 - const kerabaMargin = from === 'roofCover' && isDiagonal ? data.kerabaMargin / calculateValue / 10 : data.kerabaMargin / 10 + const eavesResult = +roofSizeSet === 1 ? (data.eavesMargin * Math.cos((degree * Math.PI) / 180)) / 10 : data.eavesMargin / 10 + const ridgeResult = +roofSizeSet === 1 ? (data.ridgeMargin * Math.cos((degree * Math.PI) / 180)) / 10 : data.ridgeMargin / 10 + const kerabaMargin = +roofSizeSet === 1 && isDiagonal ? data.kerabaMargin / calculateValue / 10 : data.kerabaMargin / 10 switch (line.attributes.type) { case LINE_TYPE.WALLLINE.EAVES: @@ -232,7 +233,7 @@ export function useModuleBasicSetting(tabNum) { //가대 상세 데이터 기준으로 모듈 설치 배치면 생성 const makeModuleInstArea = (roof, trestleDetail) => { //지붕 객체 반환 - + if (tabNum == 3) { if (!roof) { return @@ -556,7 +557,7 @@ export function useModuleBasicSetting(tabNum) { let tmpHeight = flowDirection === 'south' || flowDirection === 'north' ? moduleHeight : moduleWidth let { width, height } = - moduleSetupSurface.from === 'roofCover' + +roofSizeSet === 1 ? calculateVisibleModuleHeight(tmpWidth, tmpHeight, getDegreeByChon(moduleSetupSurfaces[i].roofMaterial.pitch), flowDirection) : { width: tmpWidth, height: tmpHeight } @@ -1056,11 +1057,11 @@ export function useModuleBasicSetting(tabNum) { let tmpHeight = flowDirection === 'south' || flowDirection === 'north' ? moduleHeight : moduleWidth width = - moduleSetupSurface.from === 'roofCover' + +roofSizeSet === 1 ? calculateVisibleModuleHeight(tmpWidth, tmpHeight, getDegreeByChon(moduleSetupSurfaces[i].roofMaterial.pitch), flowDirection).width : tmpWidth height = - moduleSetupSurface.from === 'roofCover' + +roofSizeSet === 1 ? calculateVisibleModuleHeight(tmpWidth, tmpHeight, getDegreeByChon(moduleSetupSurfaces[i].roofMaterial.pitch), flowDirection).height : tmpHeight @@ -1386,11 +1387,11 @@ export function useModuleBasicSetting(tabNum) { //복시도, 실치수에 따른 모듈 높이 조정 width = - trestlePolygon.from === 'roofCover' + +roofSizeSet === 1 ? calculateVisibleModuleHeight(tmpWidth, tmpHeight, getDegreeByChon(trestlePolygon.roofMaterial.pitch), flowDirection).width : tmpWidth height = - trestlePolygon.from === 'roofCover' + +roofSizeSet === 1 ? calculateVisibleModuleHeight(tmpWidth, tmpHeight, getDegreeByChon(trestlePolygon.roofMaterial.pitch), flowDirection).height : tmpHeight @@ -2974,11 +2975,11 @@ export function useModuleBasicSetting(tabNum) { const pointY2 = top //디버깅 - const finalLine = new QLine([pointX1, pointY1, pointX2, pointY2], { - stroke: 'red', - strokeWidth: 1, - selectable: true, - }) + // const finalLine = new QLine([pointX1, pointY1, pointX2, pointY2], { + // stroke: 'red', + // strokeWidth: 1, + // selectable: true, + // }) // canvas?.add(finalLine) // canvas?.renderAll() @@ -3106,11 +3107,11 @@ export function useModuleBasicSetting(tabNum) { const pointY2 = coords[2].y + ((coords[2].x - top) / (coords[2].x - coords[1].x)) * (coords[1].y - coords[2].y) //디버깅용 - const finalLine = new QLine([pointX1, pointY1, pointX2, pointY2], { - stroke: 'red', - strokeWidth: 1, - selectable: true, - }) + // const finalLine = new QLine([pointX1, pointY1, pointX2, pointY2], { + // stroke: 'red', + // strokeWidth: 1, + // selectable: true, + // }) // canvas?.add(finalLine) // canvas?.renderAll() @@ -3308,7 +3309,7 @@ export function useModuleBasicSetting(tabNum) { let tmpHeight = flowDirection === 'south' || flowDirection === 'north' ? moduleHeight : moduleWidth let { width, height } = - moduleSetupSurface.from === 'roofCover' + +roofSizeSet === 1 ? calculateVisibleModuleHeight(tmpWidth, tmpHeight, getDegreeByChon(moduleSetupSurfaces[i].roofMaterial.pitch), flowDirection) : { width: tmpWidth, height: tmpHeight } @@ -4027,7 +4028,7 @@ export function useModuleBasicSetting(tabNum) { 10 } - return moduleSetupSurface.from === 'roofCover' + return +roofSizeSet === 1 ? calculateVisibleModuleHeight(tmpWidth, tmpHeight, getDegreeByChon(moduleSetupSurface.roofMaterial.pitch), moduleSetupSurface.direction) : { width: tmpWidth, height: tmpHeight } } diff --git a/src/hooks/module/useTrestle.js b/src/hooks/module/useTrestle.js index 05325822..73fb92f8 100644 --- a/src/hooks/module/useTrestle.js +++ b/src/hooks/module/useTrestle.js @@ -82,7 +82,6 @@ export const useTrestle = () => { } let rackInfos = [] - if (rack) { rackInfos = Object.keys(rack).map((key) => { return { key, value: rack[key] } @@ -2484,8 +2483,8 @@ export const useTrestle = () => { // 각도에 따른 길이 반환 function getTrestleLength(length, degree, surface) { - if (surface.from !== 'roofCover') { - // 지붕덮개로부터 온게 아니면 그냥 length 리턴 + if (+roofSizeSet !== 1) { + // 복시도 입력이 아닌경우 그냥 길이 return return length } const radians = (degree * Math.PI) / 180 diff --git a/src/hooks/option/useCanvasSetting.js b/src/hooks/option/useCanvasSetting.js index e2742ff2..9b060fde 100644 --- a/src/hooks/option/useCanvasSetting.js +++ b/src/hooks/option/useCanvasSetting.js @@ -18,7 +18,6 @@ import { basicSettingState, correntObjectNoState, corridorDimensionSelector, - fetchRoofMaterialsState, roofMaterialsAtom, selectedRoofMaterialSelector, settingModalFirstOptionsState, @@ -41,6 +40,8 @@ import { useCanvasPopupStatusController } from '@/hooks/common/useCanvasPopupSta import { v4 as uuidv4 } from 'uuid' import { useEvent } from '@/hooks/useEvent' import { logger } from '@/util/logger' +import { useText } from '@/hooks/useText' +import { usePolygon } from '@/hooks/usePolygon' const defaultDotLineGridSetting = { INTERVAL: { @@ -118,7 +119,6 @@ export function useCanvasSetting(executeEffect = true) { const { getRoofMaterialList, getModuleTypeItemList } = useMasterController() const [roofMaterials, setRoofMaterials] = useRecoilState(roofMaterialsAtom) const [addedRoofs, setAddedRoofs] = useRecoilState(addedRoofsState) - const [fetchRoofMaterials, setFetchRoofMaterials] = useRecoilState(fetchRoofMaterialsState) const setCurrentMenu = useSetRecoilState(currentMenuState) const resetModuleSelectionData = useResetRecoilState(moduleSelectionDataState) /* 다음으로 넘어가는 최종 데이터 */ @@ -133,6 +133,9 @@ export function useCanvasSetting(executeEffect = true) { const { addPopup } = usePopup() const [popupId, setPopupId] = useState(uuidv4()) + const { changeCorridorDimensionText } = useText() + const { setPolygonLinesActualSize } = usePolygon() + const SelectOptions = [ { id: 1, name: getMessage('modal.canvas.setting.grid.dot.line.setting.line.origin'), value: 1 }, { id: 2, name: '1/2', value: 1 / 2 }, @@ -197,7 +200,7 @@ export function useCanvasSetting(executeEffect = true) { } }, [addedRoofs]) - useEffect(() => { + /*useEffect(() => { if (!executeEffect) { return } @@ -212,7 +215,7 @@ export function useCanvasSetting(executeEffect = true) { setAddedRoofs(newAddedRoofs) } setBasicSettings({ ...basicSetting, selectedRoofMaterial: selectedRoofMaterial }) - }, [roofMaterials]) + }, [roofMaterials])*/ useEffect(() => { if (!canvas) { @@ -221,39 +224,13 @@ export function useCanvasSetting(executeEffect = true) { if (!executeEffect) { return } - const { column } = corridorDimension - const lengthTexts = canvas.getObjects().filter((obj) => obj.name === 'lengthText') - const group = canvas.getObjects().filter((obj) => obj.type === 'group') - group.forEach((obj) => { - obj._objects - .filter((obj2) => obj2.name === 'lengthText') - .forEach((obj3) => { - lengthTexts.push(obj3) - }) - }) - - switch (column) { - case 'corridorDimension': - lengthTexts.forEach((obj) => { - if (obj.planeSize) { - obj.set({ text: obj.planeSize.toString() }) - } - }) - break - case 'realDimension': - lengthTexts.forEach((obj) => { - if (obj.actualSize) { - obj.set({ text: obj.actualSize.toString() }) - } - }) - break - case 'noneDimension': - lengthTexts.forEach((obj) => { - obj.set({ text: '' }) - }) - break + const roofs = canvasObjects.filter((obj) => obj.name === POLYGON_TYPE.ROOF) + if (roofs.length > 0) { + roofs.forEach((roof) => { + setPolygonLinesActualSize(roof) + }) + changeCorridorDimensionText() } - canvas?.renderAll() }, [corridorDimension]) useEffect(() => { @@ -448,17 +425,18 @@ export function useCanvasSetting(executeEffect = true) { } if (addRoofs.length > 0) { - setAddedRoofs(addRoofs) - - setBasicSettings({ - ...basicSetting, - roofMaterials: addRoofs[0], - planNo: roofsRow[0].planNo, - roofSizeSet: roofsRow[0].roofSizeSet, - roofAngleSet: roofsRow[0].roofAngleSet, - roofsData: roofsArray, - selectedRoofMaterial: addRoofs.find((roof) => roof.selected), + setBasicSettings((prev) => { + return { + ...basicSetting, + roofMaterials: addRoofs[0], + planNo: roofsRow[0].planNo, + roofSizeSet: roofsRow[0].roofSizeSet, + roofAngleSet: roofsRow[0].roofAngleSet, + roofsData: roofsArray, + selectedRoofMaterial: addRoofs.find((roof) => roof.selected), + } }) + setAddedRoofs(addRoofs) setCanvasSetting({ ...basicSetting, diff --git a/src/hooks/roofcover/useRoofAllocationSetting.js b/src/hooks/roofcover/useRoofAllocationSetting.js index 417286e4..3fef0ee9 100644 --- a/src/hooks/roofcover/useRoofAllocationSetting.js +++ b/src/hooks/roofcover/useRoofAllocationSetting.js @@ -28,6 +28,7 @@ import { outerLinePointsState } from '@/store/outerLineAtom' import { QcastContext } from '@/app/QcastProvider' import { usePlan } from '@/hooks/usePlan' import { roofsState } from '@/store/roofAtom' +import { useText } from '@/hooks/useText' export function useRoofAllocationSetting(id) { const canvas = useRecoilValue(canvasState) @@ -60,6 +61,7 @@ export function useRoofAllocationSetting(id) { const [moduleSelectionData, setModuleSelectionData] = useRecoilState(moduleSelectionDataState) const resetPoints = useResetRecoilState(outerLinePointsState) const [corridorDimension, setCorridorDimension] = useRecoilState(corridorDimensionSelector) + const { changeCorridorDimensionText } = useText() useEffect(() => { /** 배치면 초기설정에서 선택한 지붕재 배열 설정 */ @@ -127,9 +129,9 @@ export function useRoofAllocationSetting(id) { } }) } else { - if(roofList.length > 0){ + if (roofList.length > 0) { roofsArray = roofList - }else{ + } else { roofsArray = [ { planNo: planNo, @@ -188,7 +190,6 @@ export function useRoofAllocationSetting(id) { }) //데이터 동기화 setCurrentRoofList(selectRoofs) - }) } catch (error) { console.error('Data fetching error:', error) @@ -459,6 +460,10 @@ export function useRoofAllocationSetting(id) { /** 모듈 선택 데이터 초기화 */ // modifyModuleSelectionData() setModuleSelectionData({ ...moduleSelectionData, roofConstructions: newRoofList }) + + setTimeout(() => { + changeCorridorDimensionText('realDimension') + }, 500) } /** diff --git a/src/hooks/surface/useSurfaceShapeBatch.js b/src/hooks/surface/useSurfaceShapeBatch.js index 863de4bf..874526f8 100644 --- a/src/hooks/surface/useSurfaceShapeBatch.js +++ b/src/hooks/surface/useSurfaceShapeBatch.js @@ -1,9 +1,8 @@ 'use client' -import { useEffect } from 'react' -import { useRecoilState, useRecoilValue, useResetRecoilState } from 'recoil' +import { useRecoilValue, useResetRecoilState } from 'recoil' import { canvasSettingState, canvasState, currentCanvasPlanState, globalPitchState } from '@/store/canvasAtom' -import { MENU, POLYGON_TYPE, LINE_TYPE } from '@/common/common' +import { LINE_TYPE, MENU, POLYGON_TYPE } from '@/common/common' import { getIntersectionPoint, toFixedWithoutRounding } from '@/util/canvas-util' import { degreesToRadians } from '@turf/turf' import { QPolygon } from '@/components/fabric/QPolygon' @@ -20,13 +19,12 @@ import { useRoofFn } from '@/hooks/common/useRoofFn' import { outerLinePointsState } from '@/store/outerLineAtom' import { placementShapeDrawingPointsState } from '@/store/placementShapeDrawingAtom' import { getBackGroundImage } from '@/lib/imageActions' -import PlacementSurfaceLineProperty from '@/components/floor-plan/modal/placementShape/PlacementSurfaceLineProperty' -import { v4 as uuidv4 } from 'uuid' import { useCanvasSetting } from '@/hooks/option/useCanvasSetting' +import { useText } from '@/hooks/useText' export function useSurfaceShapeBatch({ isHidden, setIsHidden }) { const { getMessage } = useMessage() - const { drawDirectionArrow, addPolygon } = usePolygon() + const { drawDirectionArrow, addPolygon, addLengthText } = usePolygon() const lengthTextFont = useRecoilValue(fontSelector('lengthText')) const resetOuterLinePoints = useResetRecoilState(outerLinePointsState) const resetPlacementShapeDrawingPoints = useResetRecoilState(placementShapeDrawingPointsState) @@ -40,6 +38,7 @@ export function useSurfaceShapeBatch({ isHidden, setIsHidden }) { // const { addCanvasMouseEventListener, initEvent } = useContext(EventContext) const { addPopup, closePopup } = usePopup() const { setSurfaceShapePattern } = useRoofFn() + const { changeCorridorDimensionText } = useText() const currentCanvasPlan = useRecoilValue(currentCanvasPlanState) const { fetchSettings } = useCanvasSetting(false) @@ -848,7 +847,7 @@ export function useSurfaceShapeBatch({ isHidden, setIsHidden }) { const selection = new fabric.ActiveSelection(selectionArray, { canvas: canvas, - draggable: true, + // draggable: true, lockMovementX: false, // X축 이동 허용 lockMovementY: false, // Y축 이동 허용 originX: 'center', @@ -858,7 +857,7 @@ export function useSurfaceShapeBatch({ isHidden, setIsHidden }) { canvas.setActiveObject(selection) addCanvasMouseEventListener('mouse:up', (e) => { - canvas.selection = true + canvas.selection = false canvas.discardActiveObject() // 모든 선택 해제 canvas.requestRenderAll() // 화면 업데이트 @@ -875,10 +874,13 @@ export function useSurfaceShapeBatch({ isHidden, setIsHidden }) { } }) - canvas.renderAll() - roof.fire('polygonMoved') + // roof.fire('polygonMoved') + roof.fire('modified') drawDirectionArrow(roof) + changeCorridorDimensionText() + addLengthText(roof) initEvent() + canvas.renderAll() }) } } diff --git a/src/hooks/useCanvasEvent.js b/src/hooks/useCanvasEvent.js index 2acc929f..d991a2cf 100644 --- a/src/hooks/useCanvasEvent.js +++ b/src/hooks/useCanvasEvent.js @@ -5,6 +5,8 @@ import { canvasSizeState, canvasState, canvasZoomState, currentMenuState, curren import { QPolygon } from '@/components/fabric/QPolygon' import { fontSelector } from '@/store/fontAtom' import { MENU, POLYGON_TYPE } from '@/common/common' +import { useText } from '@/hooks/useText' +import { usePolygon } from '@/hooks/usePolygon' // 캔버스에 필요한 이벤트 export function useCanvasEvent() { @@ -15,6 +17,8 @@ export function useCanvasEvent() { const [canvasZoom, setCanvasZoom] = useRecoilState(canvasZoomState) const lengthTextOption = useRecoilValue(fontSelector('lengthText')) const currentMenu = useRecoilValue(currentMenuState) + const { changeCorridorDimensionText } = useText() + const { setPolygonLinesActualSize } = usePolygon() useEffect(() => { canvas?.setZoom(canvasZoom / 100) @@ -63,6 +67,13 @@ export function useCanvasEvent() { textObjs.forEach((obj) => { obj.bringToFront() }) + + if (target.name === POLYGON_TYPE.ROOF) { + setTimeout(() => { + setPolygonLinesActualSize(target) + changeCorridorDimensionText() + }, 300) + } } if (target.name === 'cell') { @@ -116,48 +127,6 @@ export function useCanvasEvent() { target.setControlVisible(controlKey, false) }) }) - /*target.on('editing:exited', () => { - if (isNaN(target.text.trim())) { - target.set({ text: previousValue }) - canvas?.renderAll() - return - } - const updatedValue = parseFloat(target.text.trim()) - const targetParent = target.parent - const points = targetParent.getCurrentPoints() - const i = target.idx // Assuming target.index gives the index of the point - - const startPoint = points[i] - const endPoint = points[(i + 1) % points.length] - - const dx = endPoint.x - startPoint.x - const dy = endPoint.y - startPoint.y - - const currentLength = Math.sqrt(dx * dx + dy * dy) - const scaleFactor = updatedValue / currentLength - - const newEndPoint = { - x: startPoint.x + dx * scaleFactor, - y: startPoint.y + dy * scaleFactor, - } - - const newPoints = [...points] - newPoints[(i + 1) % points.length] = newEndPoint - - for (let idx = i + 1; idx < points.length; idx++) { - if (newPoints[idx].x === endPoint.x) { - newPoints[idx].x = newEndPoint.x - } else if (newPoints[idx].y === endPoint.y) { - newPoints[idx].y = newEndPoint.y - } - } - - const newPolygon = new QPolygon(newPoints, targetParent.initOptions) - canvas?.add(newPolygon) - canvas?.remove(targetParent) - canvas?.renderAll() - })*/ - target.on('moving', (e) => { target.uuid = uuidv4() diff --git a/src/hooks/useCirCuitTrestle.js b/src/hooks/useCirCuitTrestle.js index 12e1b28b..a35d3c8c 100644 --- a/src/hooks/useCirCuitTrestle.js +++ b/src/hooks/useCirCuitTrestle.js @@ -10,7 +10,7 @@ import { selectedModelsState, seriesState, } from '@/store/circuitTrestleAtom' -import { moduleSelectionDataState, selectedModuleState } from '@/store/selectedModuleOptions' +import { selectedModuleState } from '@/store/selectedModuleOptions' import { useContext, useEffect } from 'react' import { useRecoilState, useRecoilValue, useResetRecoilState, useSetRecoilState } from 'recoil' import { useMessage } from './useMessage' @@ -101,7 +101,11 @@ export function useCircuitTrestle(executeEffect = false) { // result 배열에서 roofSurface 값을 기준으로 순서대로 정렬한다. - return groupSort(result) + if (pcsCheck.division) { + return groupSort(result) + } else { + return result + } } const groupSort = (arr) => { diff --git a/src/hooks/useEvent.js b/src/hooks/useEvent.js index 748e680a..dfbe492c 100644 --- a/src/hooks/useEvent.js +++ b/src/hooks/useEvent.js @@ -129,7 +129,7 @@ export function useEvent() { let arrivalPoint = { x: pointer.x, y: pointer.y } if (adsorptionPointMode) { - const roofsPoints = roofs.map((roof) => roof.points).flat() + const roofsPoints = roofs.map((roof) => roof.getCurrentPoints()).flat() roofAdsorptionPoints.current = [...roofsPoints] const auxiliaryLines = canvas.getObjects().filter((obj) => obj.name === 'auxiliaryLine') diff --git a/src/hooks/useLine.js b/src/hooks/useLine.js index 9d1a48eb..58f87e7d 100644 --- a/src/hooks/useLine.js +++ b/src/hooks/useLine.js @@ -1,15 +1,18 @@ import { useRecoilValue } from 'recoil' import { - ANGLE_TYPE, canvasState, currentAngleTypeSelector, fontFamilyState, fontSizeState, + globalPitchState, pitchTextSelector, showAngleUnitSelector, } from '@/store/canvasAtom' import { QLine } from '@/components/fabric/QLine' -import { getChonByDegree, getDegreeByChon } from '@/util/canvas-util' +import { basicSettingState } from '@/store/settingAtom' +import { calcLineActualSize } from '@/util/qpolygon-utils' +import { getDegreeByChon } from '@/util/canvas-util' +import { useText } from '@/hooks/useText' export const useLine = () => { const canvas = useRecoilValue(canvasState) @@ -18,6 +21,10 @@ export const useLine = () => { const currentAngleType = useRecoilValue(currentAngleTypeSelector) const pitchText = useRecoilValue(pitchTextSelector) const angleUnit = useRecoilValue(showAngleUnitSelector) + const roofSizeSet = useRecoilValue(basicSettingState).roofSizeSet + const globalPitch = useRecoilValue(globalPitchState) + + const { changeCorridorDimensionText } = useText() const addLine = (points = [], options) => { const line = new QLine(points, { @@ -151,6 +158,57 @@ export const useLine = () => { }) } + /** + * 복도치수, 실제치수에 따라 actualSize를 설정한다. + * @param line + * @param direction polygon의 방향 + * @param pitch + */ + const setActualSize = (line, direction, pitch = globalPitch) => { + const { x1, y1, x2, y2 } = line + + const isHorizontal = y1 === y2 + const isVertical = x1 === x2 + const isDiagonal = !isHorizontal && !isVertical + const lineLength = line.getLength() + + line.attributes = { ...line.attributes, planeSize: line.getLength(), actualSize: line.getLength() } + + if (+roofSizeSet === 1) { + if (direction === 'south' || direction === 'north') { + if (isVertical) { + line.attributes = { + ...line.attributes, + actualSize: calcLineActualSize(line, getDegreeByChon(pitch)), + } + } else if (isDiagonal) { + const yLength = Math.abs(y2 - y1) * 10 + + const h = yLength * Math.tan(getDegreeByChon(pitch) * (Math.PI / 180)) + + const actualSize = Math.sqrt(h ** 2 + lineLength ** 2) + line.attributes = { ...line.attributes, actualSize: actualSize } + } + } else if (direction === 'west' || direction === 'east') { + if (isHorizontal) { + line.attributes = { + ...line.attributes, + actualSize: calcLineActualSize(line, getDegreeByChon(pitch)), + } + } else if (isDiagonal) { + const xLength = Math.abs(x2 - x1) * 10 + + const h = xLength * Math.tan(getDegreeByChon(pitch) * (Math.PI / 180)) + + const actualSize = Math.sqrt(h ** 2 + lineLength ** 2) + line.attributes = { ...line.attributes, actualSize: actualSize } + } + } + } + + line.attributes = { ...line.attributes, actualSize: Number(line.attributes.actualSize.toFixed(0)) } + } + return { addLine, removeLine, @@ -160,5 +218,6 @@ export const useLine = () => { removePitchText, addPitchTextsByOuterLines, getLengthByLine, + setActualSize, } } diff --git a/src/hooks/usePolygon.js b/src/hooks/usePolygon.js index 12d7e188..a1cbc4f0 100644 --- a/src/hooks/usePolygon.js +++ b/src/hooks/usePolygon.js @@ -4,7 +4,7 @@ import { fabric } from 'fabric' import { calculateIntersection, findAndRemoveClosestPoint, getDegreeByChon, getDegreeInOrientation, isPointOnLine } from '@/util/canvas-util' import { QPolygon } from '@/components/fabric/QPolygon' import { isSamePoint, removeDuplicatePolygons } from '@/util/qpolygon-utils' -import { flowDisplaySelector } from '@/store/settingAtom' +import { basicSettingState, flowDisplaySelector } from '@/store/settingAtom' import { fontSelector } from '@/store/fontAtom' import { QLine } from '@/components/fabric/QLine' import { LINE_TYPE, POLYGON_TYPE } from '@/common/common' @@ -18,6 +18,9 @@ export const usePolygon = () => { const currentAngleType = useRecoilValue(currentAngleTypeSelector) const pitchText = useRecoilValue(pitchTextSelector) const globalPitch = useRecoilValue(globalPitchState) + const roofSizeSet = useRecoilValue(basicSettingState).roofSizeSet + + const { setActualSize } = useLine() const { getLengthByLine } = useLine() @@ -86,27 +89,30 @@ export const usePolygon = () => { const maxY = line.top + line.length const degree = (Math.atan2(y2 - y1, x2 - x1) * 180) / Math.PI - const text = new fabric.Textbox(planeSize ? planeSize.toString() : length.toString(), { - left: left, - top: top, - fontSize: lengthTextFontOptions.fontSize.value, - minX, - maxX, - minY, - maxY, - parentDirection: line.direction, - parentDegree: degree, - parentId: polygon.id, - planeSize: planeSize ?? length, - actualSize: actualSize ?? length, - editable: false, - selectable: true, - lockRotation: true, - lockScalingX: true, - lockScalingY: true, - parent: polygon, - name: 'lengthText', - }) + const text = new fabric.Textbox( + +roofSizeSet === 1 ? (actualSize ? actualSize.toString() : length.toString()) : planeSize ? planeSize.toString() : length.toString(), + { + left: left, + top: top, + fontSize: lengthTextFontOptions.fontSize.value, + minX, + maxX, + minY, + maxY, + parentDirection: line.direction, + parentDegree: degree, + parentId: polygon.id, + planeSize: planeSize ?? length, + actualSize: actualSize ?? length, + editable: false, + selectable: true, + lockRotation: true, + lockScalingX: true, + lockScalingY: true, + parent: polygon, + name: 'lengthText', + }, + ) polygon.texts.push(text) canvas.add(text) }) @@ -921,12 +927,69 @@ export const usePolygon = () => { } return !shouldRemove }) - + // 중복된 라인들을 canvas에서 제거 linesToRemove.forEach((line) => { canvas.remove(line) }) + // innerLines가 합쳐졌을 때 polygonLine과 같은 경우 그 polygonLine의 need를 false로 변경 + const mergeOverlappingInnerLines = (lines) => { + const mergedLines = [] + const processed = new Set() + + lines.forEach((line, index) => { + if (processed.has(index)) return + + let currentLine = { ...line } + processed.add(index) + + // 현재 라인과 겹치는 다른 라인들을 찾아서 합치기 + for (let i = index + 1; i < lines.length; i++) { + if (processed.has(i)) continue + + const otherLine = lines[i] + if (checkLineOverlap(currentLine, otherLine)) { + // 두 라인을 합치기 - 가장 긴 범위로 확장 + const isVertical = Math.abs(currentLine.x1 - currentLine.x2) < 1 + + if (isVertical) { + const allYPoints = [currentLine.y1, currentLine.y2, otherLine.y1, otherLine.y2] + currentLine.y1 = Math.min(...allYPoints) + currentLine.y2 = Math.max(...allYPoints) + currentLine.x1 = currentLine.x2 = (currentLine.x1 + otherLine.x1) / 2 + } else { + const allXPoints = [currentLine.x1, currentLine.x2, otherLine.x1, otherLine.x2] + currentLine.x1 = Math.min(...allXPoints) + currentLine.x2 = Math.max(...allXPoints) + currentLine.y1 = currentLine.y2 = (currentLine.y1 + otherLine.y1) / 2 + } + + processed.add(i) + } + } + + mergedLines.push(currentLine) + }) + + return mergedLines + } + + const mergedInnerLines = mergeOverlappingInnerLines(innerLines) + + // 합쳐진 innerLine과 동일한 polygonLine의 need를 false로 설정 + polygonLines.forEach((polygonLine) => { + mergedInnerLines.forEach((mergedInnerLine) => { + const isSameLine = + (isSamePoint(polygonLine.startPoint, mergedInnerLine.startPoint) && isSamePoint(polygonLine.endPoint, mergedInnerLine.endPoint)) || + (isSamePoint(polygonLine.startPoint, mergedInnerLine.endPoint) && isSamePoint(polygonLine.endPoint, mergedInnerLine.startPoint)) + + if (isSameLine) { + polygonLine.need = false + } + }) + }) + canvas.renderAll() /*polygonLines.forEach((line) => { @@ -934,6 +997,7 @@ export const usePolygon = () => { canvas.add(line) }) canvas.renderAll()*/ + polygonLines = polygonLines.filter((line) => line.need) polygonLines.forEach((line) => { /*const originStroke = line.stroke @@ -1589,8 +1653,8 @@ export const usePolygon = () => { const remainingLines = [...allLines] // 사용 가능한 line들의 복사본 // isStart가 true인 line들만 시작점으로 사용 - const startLines = remainingLines.filter(line => line.attributes?.isStart === true) - + const startLines = remainingLines.filter((line) => line.attributes?.isStart === true) + startLines.forEach((startLine) => { // 현재 남아있는 line들로 그래프 생성 const graph = {} @@ -1615,13 +1679,13 @@ export const usePolygon = () => { const startPoint = { ...startLine.startPoint } // 시작점 let arrivalPoint = { ...startLine.endPoint } // 도착점 - + const roof = getPath(startPoint, arrivalPoint, graph) if (roof.length > 0) { roofs.push(roof) - + // 사용된 startLine을 remainingLines에서 제거 - const startLineIndex = remainingLines.findIndex(line => line === startLine) + const startLineIndex = remainingLines.findIndex((line) => line === startLine) if (startLineIndex !== -1) { remainingLines.splice(startLineIndex, 1) } @@ -1675,6 +1739,22 @@ export const usePolygon = () => { canvas.renderAll() } + /** + * 폴리곤의 라인 속성을 복도치수, 실제치수에 따라 actualSize 설정 + * @param polygon + */ + const setPolygonLinesActualSize = (polygon) => { + if (!polygon.lines || polygon.lines.length === 0 || !polygon.roofMaterial) { + return + } + + polygon.lines.forEach((line) => { + setActualSize(line, polygon.direction, +polygon.roofMaterial?.pitch) + }) + + addLengthText(polygon) + } + return { addPolygon, addPolygonByLines, @@ -1683,5 +1763,6 @@ export const usePolygon = () => { addLengthText, splitPolygonWithLines, splitPolygonWithSeparate, + setPolygonLinesActualSize, } } diff --git a/src/hooks/useText.js b/src/hooks/useText.js new file mode 100644 index 00000000..134ade4a --- /dev/null +++ b/src/hooks/useText.js @@ -0,0 +1,51 @@ +import { useRecoilValue } from 'recoil' +import { corridorDimensionSelector } from '@/store/settingAtom' +import { canvasState } from '@/store/canvasAtom' + +export function useText() { + const canvas = useRecoilValue(canvasState) + const corridorDimension = useRecoilValue(corridorDimensionSelector) + + const changeCorridorDimensionText = (columnText) => { + let { column } = corridorDimension + if (columnText) { + column = columnText + } + const lengthTexts = canvas.getObjects().filter((obj) => obj.name === 'lengthText') + const group = canvas.getObjects().filter((obj) => obj.type === 'group') + group.forEach((obj) => { + obj._objects + .filter((obj2) => obj2.name === 'lengthText') + .forEach((obj3) => { + lengthTexts.push(obj3) + }) + }) + + switch (column) { + case 'corridorDimension': + lengthTexts.forEach((obj) => { + if (obj.planeSize) { + obj.set({ text: obj.planeSize.toString() }) + } + }) + break + case 'realDimension': + lengthTexts.forEach((obj) => { + if (obj.actualSize) { + obj.set({ text: obj.actualSize.toString() }) + } + }) + break + case 'noneDimension': + lengthTexts.forEach((obj) => { + obj.set({ text: '' }) + }) + break + } + canvas?.renderAll() + } + + return { + changeCorridorDimensionText, + } +} diff --git a/src/lib/skeletons/Circular/CircularList.ts b/src/lib/skeletons/Circular/CircularList.ts new file mode 100644 index 00000000..690f5e5f --- /dev/null +++ b/src/lib/skeletons/Circular/CircularList.ts @@ -0,0 +1,113 @@ +import CircularNode from "./CircularNode"; + +export interface ICircularList { + readonly Size: number; + + AddNext(node: CircularNode, newNode: CircularNode): void; + + AddPrevious(node: CircularNode, newNode: CircularNode): void; + + AddLast(node: CircularNode): void; + + Remove(node: CircularNode): void; +} + +export default class CircularList implements ICircularList { + private _first: T = null; + private _size: number = 0; + + public AddNext(node: CircularNode, newNode: CircularNode) { + if (newNode.List !== null) + throw new Error("Node is already assigned to different list!"); + + newNode.List = this; + + newNode.Previous = node; + newNode.Next = node.Next; + + node.Next.Previous = newNode; + node.Next = newNode; + + this._size++; + } + + AddPrevious(node: CircularNode, newNode: CircularNode) { + if (newNode.List !== null) + throw new Error("Node is already assigned to different list!"); + + newNode.List = this; + + newNode.Previous = node.Previous; + newNode.Next = node; + + node.Previous.Next = newNode; + node.Previous = newNode; + + this._size++; + } + + AddLast(node: CircularNode) { + if (node.List !== null) + throw new Error("Node is already assigned to different list!"); + + if (this._first === null) { + this._first = node as T; + + node.List = this; + node.Next = node; + node.Previous = node; + + this._size++; + } else + this.AddPrevious(this._first, node); + } + + Remove(node: CircularNode) { + if (node.List !== this) + throw new Error("Node is not assigned to this list!"); + + if (this._size <= 0) + throw new Error("List is empty can't remove!"); + + node.List = null; + + if (this._size === 1) + this._first = null; + + else { + if (this._first === node) + this._first = this._first.Next; + + node.Previous.Next = node.Next; + node.Next.Previous = node.Previous; + } + + node.Previous = null; + node.Next = null; + + this._size--; + } + + public get Size(): number { + return this._size; + } + + public First(): T { + return this._first; + } + + public* Iterate(): Generator { + let current = this._first; + let i = 0; + + while (current !== null) { + yield current; + + if (++i === this.Size) { + return; + } + + current = current.Next; + } + } +} \ No newline at end of file diff --git a/src/lib/skeletons/Circular/CircularNode.ts b/src/lib/skeletons/Circular/CircularNode.ts new file mode 100644 index 00000000..cfbda42a --- /dev/null +++ b/src/lib/skeletons/Circular/CircularNode.ts @@ -0,0 +1,19 @@ +import {ICircularList} from "./CircularList"; + +export default class CircularNode { + public List: ICircularList = null; + public Next: CircularNode = null; + public Previous: CircularNode = null; + + public AddNext(node: CircularNode) { + this.List.AddNext(this, node); + } + + public AddPrevious(node: CircularNode) { + this.List.AddPrevious(this, node); + } + + public Remove() { + this.List.Remove(this); + } +} \ No newline at end of file diff --git a/src/lib/skeletons/Circular/Edge.ts b/src/lib/skeletons/Circular/Edge.ts new file mode 100644 index 00000000..a7620f57 --- /dev/null +++ b/src/lib/skeletons/Circular/Edge.ts @@ -0,0 +1,28 @@ +import CircularNode from "./CircularNode"; +import Vector2d from "../Primitives/Vector2d"; +import LineLinear2d from "../Primitives/LineLinear2d"; +import LineParametric2d from "../Primitives/LineParametric2d"; + +export default class Edge extends CircularNode { + public readonly Begin: Vector2d; + public readonly End: Vector2d; + public readonly Norm: Vector2d; + + public readonly LineLinear2d: LineLinear2d; + public BisectorNext: LineParametric2d = null; + public BisectorPrevious: LineParametric2d = null; + + constructor(begin: Vector2d, end: Vector2d) { + super(); + + this.Begin = begin; + this.End = end; + + this.LineLinear2d = new LineLinear2d(begin, end); + this.Norm = end.Sub(begin).Normalized(); + } + + public ToString(): string { + return `Edge [p1=${this.Begin}, p2=${this.End}]`; + } +} \ No newline at end of file diff --git a/src/lib/skeletons/Circular/Vertex.ts b/src/lib/skeletons/Circular/Vertex.ts new file mode 100644 index 00000000..2b82a6f0 --- /dev/null +++ b/src/lib/skeletons/Circular/Vertex.ts @@ -0,0 +1,39 @@ +import CircularNode from "./CircularNode"; +import Vector2d from "../Primitives/Vector2d"; +import LineParametric2d from "../Primitives/LineParametric2d"; +import Edge from "./Edge"; +import {FaceNode} from "../Path/FaceNode"; + +export default class Vertex extends CircularNode { + readonly RoundDigitCount = 5; + + public Point: Vector2d = null; + public readonly Distance: number; + public readonly Bisector: LineParametric2d = null; + + public readonly NextEdge: Edge = null; + public readonly PreviousEdge: Edge = null; + + public LeftFace: FaceNode = null; + public RightFace: FaceNode = null; + + public IsProcessed: boolean; + + constructor(point: Vector2d, distance: number, bisector: LineParametric2d, previousEdge: Edge, nextEdge: Edge) { + super(); + + this.Point = point; + this.Distance = +distance.toFixed(this.RoundDigitCount); + this.Bisector = bisector; + this.PreviousEdge = previousEdge; + this.NextEdge = nextEdge; + + this.IsProcessed = false; + } + + public ToString(): string { + return "Vertex [v=" + this.Point + ", IsProcessed=" + this.IsProcessed + + ", Bisector=" + this.Bisector + ", PreviousEdge=" + this.PreviousEdge + + ", NextEdge=" + this.NextEdge; + } +} \ No newline at end of file diff --git a/src/lib/skeletons/EdgeResult.ts b/src/lib/skeletons/EdgeResult.ts new file mode 100644 index 00000000..c95ba235 --- /dev/null +++ b/src/lib/skeletons/EdgeResult.ts @@ -0,0 +1,13 @@ +import Edge from "./Circular/Edge"; +import Vector2d from "./Primitives/Vector2d"; +import {List} from "./Utils"; + +export default class EdgeResult { + public readonly Edge: Edge; + public readonly Polygon: List; + + constructor(edge: Edge, polygon: List) { + this.Edge = edge; + this.Polygon = polygon; + } +} \ No newline at end of file diff --git a/src/lib/skeletons/Events/Chains/ChainType.ts b/src/lib/skeletons/Events/Chains/ChainType.ts new file mode 100644 index 00000000..70b63170 --- /dev/null +++ b/src/lib/skeletons/Events/Chains/ChainType.ts @@ -0,0 +1,7 @@ +enum ChainType { + Edge, + ClosedEdge, + Split +} + +export default ChainType; \ No newline at end of file diff --git a/src/lib/skeletons/Events/Chains/EdgeChain.ts b/src/lib/skeletons/Events/Chains/EdgeChain.ts new file mode 100644 index 00000000..f25efbaf --- /dev/null +++ b/src/lib/skeletons/Events/Chains/EdgeChain.ts @@ -0,0 +1,40 @@ +import IChain from "./IChain"; +import EdgeEvent from "../EdgeEvent"; +import {List} from "../../Utils"; +import Edge from "../../Circular/Edge"; +import Vertex from "../../Circular/Vertex"; +import ChainType from "./ChainType"; + +export default class EdgeChain implements IChain { + private readonly _closed: boolean; + public EdgeList: List; + + constructor(edgeList: List) { + this.EdgeList = edgeList; + this._closed = this.PreviousVertex === this.NextVertex; + } + + public get PreviousEdge(): Edge { + return this.EdgeList[0].PreviousVertex.PreviousEdge; + } + + public get NextEdge(): Edge { + return this.EdgeList[this.EdgeList.Count - 1].NextVertex.NextEdge; + } + + public get PreviousVertex(): Vertex { + return this.EdgeList[0].PreviousVertex; + } + + public get NextVertex(): Vertex { + return this.EdgeList[this.EdgeList.Count - 1].NextVertex; + } + + public get CurrentVertex(): Vertex { + return null; + } + + public get ChainType(): ChainType { + return this._closed ? ChainType.ClosedEdge : ChainType.Edge; + } +} \ No newline at end of file diff --git a/src/lib/skeletons/Events/Chains/IChain.ts b/src/lib/skeletons/Events/Chains/IChain.ts new file mode 100644 index 00000000..59d22bf5 --- /dev/null +++ b/src/lib/skeletons/Events/Chains/IChain.ts @@ -0,0 +1,17 @@ +import Edge from "../../Circular/Edge"; +import Vertex from "../../Circular/Vertex"; +import ChainType from "./ChainType"; + +export default interface IChain { + get PreviousEdge(): Edge; + + get NextEdge(): Edge; + + get PreviousVertex(): Vertex; + + get NextVertex(): Vertex; + + get CurrentVertex(): Vertex; + + get ChainType(): ChainType; +} \ No newline at end of file diff --git a/src/lib/skeletons/Events/Chains/SingleEdgeChain.ts b/src/lib/skeletons/Events/Chains/SingleEdgeChain.ts new file mode 100644 index 00000000..305cdd2b --- /dev/null +++ b/src/lib/skeletons/Events/Chains/SingleEdgeChain.ts @@ -0,0 +1,40 @@ +import IChain from "./IChain"; +import Edge from "../../Circular/Edge"; +import Vertex from "../../Circular/Vertex"; +import ChainType from "./ChainType"; + +export default class SingleEdgeChain implements IChain { + private readonly _nextVertex: Vertex; + private readonly _oppositeEdge: Edge; + private readonly _previousVertex: Vertex; + + constructor(oppositeEdge: Edge, nextVertex: Vertex) { + this._oppositeEdge = oppositeEdge; + this._nextVertex = nextVertex; + this._previousVertex = nextVertex.Previous as Vertex; + } + + public get PreviousEdge(): Edge { + return this._oppositeEdge; + } + + public get NextEdge(): Edge { + return this._oppositeEdge; + } + + public get PreviousVertex(): Vertex { + return this._previousVertex; + } + + public get NextVertex(): Vertex { + return this._nextVertex; + } + + public get CurrentVertex(): Vertex { + return null; + } + + public get ChainType(): ChainType { + return ChainType.Split; + } +} \ No newline at end of file diff --git a/src/lib/skeletons/Events/Chains/SplitChain.ts b/src/lib/skeletons/Events/Chains/SplitChain.ts new file mode 100644 index 00000000..8cc0e522 --- /dev/null +++ b/src/lib/skeletons/Events/Chains/SplitChain.ts @@ -0,0 +1,45 @@ +import IChain from "./IChain"; +import Edge from "../../Circular/Edge"; +import Vertex from "../../Circular/Vertex"; +import ChainType from "./ChainType"; +import VertexSplitEvent from "../VertexSplitEvent"; +import SplitEvent from "../SplitEvent"; + +export default class SplitChain implements IChain { + private readonly _splitEvent: SplitEvent; + + constructor(event: SplitEvent) { + this._splitEvent = event; + } + + public get OppositeEdge(): Edge { + if (!(this._splitEvent instanceof VertexSplitEvent)) + return this._splitEvent.OppositeEdge; + + return null; + } + + public get PreviousEdge(): Edge { + return this._splitEvent.Parent.PreviousEdge; + } + + public get NextEdge(): Edge { + return this._splitEvent.Parent.NextEdge; + } + + public get PreviousVertex(): Vertex { + return this._splitEvent.Parent.Previous as Vertex; + } + + public get NextVertex(): Vertex { + return this._splitEvent.Parent.Next as Vertex; + } + + public get CurrentVertex(): Vertex { + return this._splitEvent.Parent; + } + + public get ChainType(): ChainType { + return ChainType.Split; + } +} \ No newline at end of file diff --git a/src/lib/skeletons/Events/EdgeEvent.ts b/src/lib/skeletons/Events/EdgeEvent.ts new file mode 100644 index 00000000..c9d53446 --- /dev/null +++ b/src/lib/skeletons/Events/EdgeEvent.ts @@ -0,0 +1,27 @@ +import SkeletonEvent from "./SkeletonEvent"; +import Vertex from "../Circular/Vertex"; +import Vector2d from "../Primitives/Vector2d"; + +export default class EdgeEvent extends SkeletonEvent { + public readonly NextVertex: Vertex; + public readonly PreviousVertex: Vertex; + + public override get IsObsolete(): boolean { + return this.PreviousVertex.IsProcessed || this.NextVertex.IsProcessed; + } + + constructor(point: Vector2d, distance: number, previousVertex: Vertex, nextVertex: Vertex) { + super(point, distance); + + this.PreviousVertex = previousVertex; + this.NextVertex = nextVertex; + } + + public override ToString(): string { + return "EdgeEvent [V=" + this.V + ", PreviousVertex=" + + (this.PreviousVertex !== null ? this.PreviousVertex.Point.ToString() : "null") + + ", NextVertex=" + + (this.NextVertex !== null ? this.NextVertex.Point.ToString() : "null") + ", Distance=" + + this.Distance + "]"; + } +} \ No newline at end of file diff --git a/src/lib/skeletons/Events/MultiEdgeEvent.ts b/src/lib/skeletons/Events/MultiEdgeEvent.ts new file mode 100644 index 00000000..e990eb71 --- /dev/null +++ b/src/lib/skeletons/Events/MultiEdgeEvent.ts @@ -0,0 +1,17 @@ +import SkeletonEvent from "./SkeletonEvent"; +import Vector2d from "../Primitives/Vector2d"; +import EdgeChain from "./Chains/EdgeChain"; + +export default class MultiEdgeEvent extends SkeletonEvent { + public readonly Chain: EdgeChain; + + public override get IsObsolete(): boolean { + return false; + } + + constructor(point: Vector2d, distance: number, chain: EdgeChain) { + super(point, distance); + + this.Chain = chain; + } +} \ No newline at end of file diff --git a/src/lib/skeletons/Events/MultiSplitEvent.ts b/src/lib/skeletons/Events/MultiSplitEvent.ts new file mode 100644 index 00000000..48df17bf --- /dev/null +++ b/src/lib/skeletons/Events/MultiSplitEvent.ts @@ -0,0 +1,18 @@ +import SkeletonEvent from "./SkeletonEvent"; +import {List} from "../Utils"; +import IChain from "./Chains/IChain"; +import Vector2d from "../Primitives/Vector2d"; + +export default class MultiSplitEvent extends SkeletonEvent { + public readonly Chains: List; + + public override get IsObsolete(): boolean { + return false; + } + + constructor(point: Vector2d, distance: number, chains: List) { + super(point, distance); + + this.Chains = chains; + } +} \ No newline at end of file diff --git a/src/lib/skeletons/Events/PickEvent.ts b/src/lib/skeletons/Events/PickEvent.ts new file mode 100644 index 00000000..f27e502d --- /dev/null +++ b/src/lib/skeletons/Events/PickEvent.ts @@ -0,0 +1,17 @@ +import SkeletonEvent from "./SkeletonEvent"; +import Vector2d from "../Primitives/Vector2d"; +import EdgeChain from "./Chains/EdgeChain"; + +export default class PickEvent extends SkeletonEvent { + public readonly Chain: EdgeChain; + + public override get IsObsolete(): boolean { + return false; + } + + constructor(point: Vector2d, distance: number, chain: EdgeChain) { + super(point, distance); + + this.Chain = chain; + } +} \ No newline at end of file diff --git a/src/lib/skeletons/Events/SkeletonEvent.ts b/src/lib/skeletons/Events/SkeletonEvent.ts new file mode 100644 index 00000000..2f1add90 --- /dev/null +++ b/src/lib/skeletons/Events/SkeletonEvent.ts @@ -0,0 +1,22 @@ +import Vector2d from "../Primitives/Vector2d"; + +export default abstract class SkeletonEvent { + public V: Vector2d = null; + + public Distance: number; + + public abstract get IsObsolete(): boolean; + + protected constructor(point: Vector2d, distance: number) { + this.V = point; + this.Distance = distance; + } + + public ToString(): string { + return "IntersectEntry [V=" + this.V + ", Distance=" + this.Distance + "]"; + } + + public GetType(): string { + return this.constructor.name; + } +} \ No newline at end of file diff --git a/src/lib/skeletons/Events/SplitEvent.ts b/src/lib/skeletons/Events/SplitEvent.ts new file mode 100644 index 00000000..c7736dba --- /dev/null +++ b/src/lib/skeletons/Events/SplitEvent.ts @@ -0,0 +1,26 @@ +import SkeletonEvent from "./SkeletonEvent"; +import Edge from "../Circular/Edge"; +import Vertex from "../Circular/Vertex"; +import Vector2d from "../Primitives/Vector2d"; + +export default class SplitEvent extends SkeletonEvent { + public readonly OppositeEdge: Edge = null; + public readonly Parent: Vertex = null; + + constructor(point: Vector2d, distance: number, parent: Vertex, oppositeEdge: Edge) { + super(point, distance); + + this.Parent = parent; + this.OppositeEdge = oppositeEdge; + } + + public override get IsObsolete(): boolean { + return this.Parent.IsProcessed; + } + + + public override ToString(): string { + return "SplitEvent [V=" + this.V + ", Parent=" + (this.Parent !== null ? this.Parent.Point.ToString() : "null") + + ", Distance=" + this.Distance + "]"; + } +} \ No newline at end of file diff --git a/src/lib/skeletons/Events/VertexSplitEvent.ts b/src/lib/skeletons/Events/VertexSplitEvent.ts new file mode 100644 index 00000000..4ea89d71 --- /dev/null +++ b/src/lib/skeletons/Events/VertexSplitEvent.ts @@ -0,0 +1,15 @@ +import SplitEvent from "./SplitEvent"; +import Vector2d from "../Primitives/Vector2d"; +import Vertex from "../Circular/Vertex"; + +export default class VertexSplitEvent extends SplitEvent { + constructor(point: Vector2d, distance: number, parent: Vertex) { + super(point, distance, parent, null); + } + + public override ToString(): string { + return "VertexSplitEvent [V=" + this.V + ", Parent=" + + (this.Parent !== null ? this.Parent.Point.ToString() : "null") + + ", Distance=" + this.Distance + "]"; + } +} \ No newline at end of file diff --git a/src/lib/skeletons/LavUtil.ts b/src/lib/skeletons/LavUtil.ts new file mode 100644 index 00000000..d067dbbe --- /dev/null +++ b/src/lib/skeletons/LavUtil.ts @@ -0,0 +1,56 @@ +import Vertex from "./Circular/Vertex"; +import {List} from "./Utils"; +import CircularList from "./Circular/CircularList"; + +export default class LavUtil { + public static IsSameLav(v1: Vertex, v2: Vertex): boolean { + if (v1.List === null || v2.List === null) + return false; + return v1.List === v2.List; + } + + public static RemoveFromLav(vertex: Vertex) { + if (vertex === null || vertex.List === null) + return; + vertex.Remove(); + } + + public static CutLavPart(startVertex: Vertex, endVertex: Vertex): List { + const ret = new List(); + const size = startVertex.List.Size; + let next = startVertex; + + for (let i = 0; i < size; i++) { + const current = next; + next = current.Next as Vertex; + current.Remove(); + ret.Add(current); + + if (current === endVertex) + return ret; + } + + throw new Error("End vertex can't be found in start vertex lav"); + } + + public static MergeBeforeBaseVertex(base: Vertex, merged: Vertex) { + const size = merged.List.Size; + + for (let i = 0; i < size; i++) { + const nextMerged = merged.Next as Vertex; + nextMerged.Remove(); + + base.AddPrevious(nextMerged); + } + } + + public static MoveAllVertexToLavEnd(vertex: Vertex, newLaw: CircularList) { + const size = vertex.List.Size; + for (let i = 0; i < size; i++) { + const ver = vertex; + vertex = vertex.Next as Vertex; + ver.Remove(); + newLaw.AddLast(ver); + } + } +} \ No newline at end of file diff --git a/src/lib/skeletons/Path/FaceNode.ts b/src/lib/skeletons/Path/FaceNode.ts new file mode 100644 index 00000000..891704fa --- /dev/null +++ b/src/lib/skeletons/Path/FaceNode.ts @@ -0,0 +1,24 @@ +import PathQueueNode from "./PathQueueNode"; +import Vertex from "../Circular/Vertex"; +import FaceQueue from "./FaceQueue"; + +export class FaceNode extends PathQueueNode { + public readonly Vertex: Vertex = null; + + constructor(vertex: Vertex) { + super(); + this.Vertex = vertex; + } + + public get FaceQueue(): FaceQueue { + return this.List; + } + + public get IsQueueUnconnected(): boolean { + return this.FaceQueue.IsUnconnected; + } + + public QueueClose() { + this.FaceQueue.Close(); + } +} \ No newline at end of file diff --git a/src/lib/skeletons/Path/FaceQueue.ts b/src/lib/skeletons/Path/FaceQueue.ts new file mode 100644 index 00000000..ae6d7a11 --- /dev/null +++ b/src/lib/skeletons/Path/FaceQueue.ts @@ -0,0 +1,24 @@ +import PathQueue from "./PathQueue"; +import {FaceNode} from "./FaceNode"; +import PathQueueNode from "./PathQueueNode"; +import Edge from "../Circular/Edge"; + +export default class FaceQueue extends PathQueue { + public Edge: Edge = null; + public Closed: boolean = false; + + public get IsUnconnected(): boolean { + return this.Edge === null; + } + + public override AddPush(node: PathQueueNode, newNode: PathQueueNode) { + if (this.Closed) + throw new Error("Can't add node to closed FaceQueue"); + + super.AddPush(node, newNode); + } + + public Close() { + this.Closed = true; + } +} \ No newline at end of file diff --git a/src/lib/skeletons/Path/FaceQueueUtil.ts b/src/lib/skeletons/Path/FaceQueueUtil.ts new file mode 100644 index 00000000..d9313c65 --- /dev/null +++ b/src/lib/skeletons/Path/FaceQueueUtil.ts @@ -0,0 +1,39 @@ +import {FaceNode} from "./FaceNode"; + +export default class FaceQueueUtil { + public static ConnectQueues(firstFace: FaceNode, secondFace: FaceNode) { + if (firstFace.List === null) + throw new Error("firstFace.list cannot be null."); + if (secondFace.List === null) + throw new Error("secondFace.list cannot be null."); + + if (firstFace.List === secondFace.List) { + if (!firstFace.IsEnd || !secondFace.IsEnd) + throw new Error("try to connect the same list not on end nodes"); + + if (firstFace.IsQueueUnconnected || secondFace.IsQueueUnconnected) + throw new Error("can't close node queue not conected with edges"); + + firstFace.QueueClose(); + return; + } + + if (!firstFace.IsQueueUnconnected && !secondFace.IsQueueUnconnected) + throw new Error( + "can't connect two diffrent queues if each of them is connected to edge"); + + if (!firstFace.IsQueueUnconnected) { + const qLeft = secondFace.FaceQueue; + this.MoveNodes(firstFace, secondFace); + qLeft.Close(); + } else { + const qRight = firstFace.FaceQueue; + this.MoveNodes(secondFace, firstFace); + qRight.Close(); + } + } + + private static MoveNodes(firstFace: FaceNode, secondFace: FaceNode) { + firstFace.AddQueue(secondFace); + } +} \ No newline at end of file diff --git a/src/lib/skeletons/Path/PathQueue.ts b/src/lib/skeletons/Path/PathQueue.ts new file mode 100644 index 00000000..2e619599 --- /dev/null +++ b/src/lib/skeletons/Path/PathQueue.ts @@ -0,0 +1,103 @@ +import PathQueueNode from "./PathQueueNode"; + +export default class PathQueue> { + public Size: number = 0; + public First: PathQueueNode = null; + + public AddPush(node: PathQueueNode, newNode: PathQueueNode) { + if (newNode.List !== null) + throw new Error("Node is already assigned to different list!"); + + if (node.Next !== null && node.Previous !== null) + throw new Error("Can't push new node. Node is inside a Quere. " + + "New node can by added only at the end of queue."); + + newNode.List = this; + this.Size++; + + if (node.Next === null) { + newNode.Previous = node; + newNode.Next = null; + + node.Next = newNode; + } else { + newNode.Previous = null; + newNode.Next = node; + + node.Previous = newNode; + } + } + + public AddFirst(node: T) { + if (node.List !== null) + throw new Error("Node is already assigned to different list!"); + + if (this.First === null) { + this.First = node; + + node.List = this; + node.Next = null; + node.Previous = null; + + this.Size++; + } else + throw new Error("First element already exist!"); + } + + public Pop(node: PathQueueNode): PathQueueNode { + if (node.List !== this) + throw new Error("Node is not assigned to this list!"); + + if (this.Size <= 0) + throw new Error("List is empty can't remove!"); + + if (!node.IsEnd) + throw new Error("Can pop only from end of queue!"); + + node.List = null; + + let previous: PathQueueNode = null; + + if (this.Size === 1) + this.First = null; + else { + if (this.First === node) { + if (node.Next !== null) + this.First = node.Next; + else if (node.Previous !== null) + this.First = node.Previous; + else + throw new Error("Ups ?"); + } + if (node.Next !== null) { + node.Next.Previous = null; + previous = node.Next; + } else if (node.Previous !== null) { + node.Previous.Next = null; + previous = node.Previous; + } + } + + node.Previous = null; + node.Next = null; + + this.Size--; + + return previous; + } + + public* Iterate(): Generator { + let current: T = (this.First !== null ? this.First.FindEnd() : null); + let i = 0; + + while (current !== null) + { + yield current; + + if (++i === this.Size) + return; + + current = current.Next; + } + } +} \ No newline at end of file diff --git a/src/lib/skeletons/Path/PathQueueNode.ts b/src/lib/skeletons/Path/PathQueueNode.ts new file mode 100644 index 00000000..c1ec25de --- /dev/null +++ b/src/lib/skeletons/Path/PathQueueNode.ts @@ -0,0 +1,51 @@ +import PathQueue from "./PathQueue"; + +export default class PathQueueNode> { + public List: PathQueue = null; + public Next: PathQueueNode = null; + public Previous: PathQueueNode = null; + + public get IsEnd(): boolean { + return this.Next === null || this.Previous === null; + } + + public AddPush(node: PathQueueNode) { + this.List.AddPush(this, node); + } + + public AddQueue(queue: PathQueueNode): PathQueueNode { + if (this.List === queue.List) + return null; + + let currentQueue: PathQueueNode = this; + + let current = queue; + + while (current !== null) { + const next = current.Pop(); + + currentQueue.AddPush(current); + currentQueue = current; + + current = next; + } + + return currentQueue; + } + + public FindEnd(): PathQueueNode { + if (this.IsEnd) + return this; + + let current: PathQueueNode = this; + + while (current.Previous !== null) + current = current.Previous; + + return current; + } + + public Pop(): PathQueueNode { + return this.List.Pop(this); + } +} \ No newline at end of file diff --git a/src/lib/skeletons/Primitives/LineLinear2d.ts b/src/lib/skeletons/Primitives/LineLinear2d.ts new file mode 100644 index 00000000..fbefcc07 --- /dev/null +++ b/src/lib/skeletons/Primitives/LineLinear2d.ts @@ -0,0 +1,41 @@ +import Vector2d from "./Vector2d"; + +export default class LineLinear2d { + public A: number; + public B: number; + public C: number; + + constructor(pP1: Vector2d = Vector2d.Empty, pP2: Vector2d = Vector2d.Empty) { + this.A = pP1.Y - pP2.Y; + this.B = pP2.X - pP1.X; + this.C = pP1.X * pP2.Y - pP2.X * pP1.Y; + } + + public SetFromCoefficients(a: number, b: number, c: number): LineLinear2d { + this.A = a; + this.B = b; + this.C = c; + + return this; + } + + public Collide(pLine: LineLinear2d): Vector2d { + return LineLinear2d.Collide(this, pLine); + } + + public static Collide(pLine1: LineLinear2d, pLine2: LineLinear2d): Vector2d { + return LineLinear2d.CollideCoeff(pLine1.A, pLine1.B, pLine1.C, pLine2.A, pLine2.B, pLine2.C); + } + + public static CollideCoeff(A1: number, B1: number, C1: number, A2: number, B2: number, C2: number): Vector2d { + const WAB = A1 * B2 - A2 * B1; + const WBC = B1 * C2 - B2 * C1; + const WCA = C1 * A2 - C2 * A1; + + return WAB === 0 ? Vector2d.Empty : new Vector2d(WBC / WAB, WCA / WAB); + } + + public Contains(point: Vector2d): boolean { + return Math.abs((point.X * this.A + point.Y * this.B + this.C)) < Number.EPSILON; + } +} \ No newline at end of file diff --git a/src/lib/skeletons/Primitives/LineParametric2d.ts b/src/lib/skeletons/Primitives/LineParametric2d.ts new file mode 100644 index 00000000..ed70353f --- /dev/null +++ b/src/lib/skeletons/Primitives/LineParametric2d.ts @@ -0,0 +1,47 @@ +import Vector2d from "./Vector2d"; +import LineLinear2d from "./LineLinear2d"; +import PrimitiveUtils from "./PrimitiveUtils"; + +export default class LineParametric2d { + public static readonly Empty: LineParametric2d = new LineParametric2d(Vector2d.Empty, Vector2d.Empty); + + public A: Vector2d = null; + public U: Vector2d = null; + + constructor(pA: Vector2d, pU: Vector2d) { + this.A = pA; + this.U = pU; + } + + public CreateLinearForm(): LineLinear2d { + const x = this.A.X; + const y = this.A.Y; + + const B = -this.U.X; + const A = this.U.Y; + + const C = -(A * x + B * y); + + return new LineLinear2d().SetFromCoefficients(A, B, C); + } + + public static Collide(ray: LineParametric2d, line: LineLinear2d, epsilon: number): Vector2d { + const collide = LineLinear2d.Collide(ray.CreateLinearForm(), line); + if (collide.Equals(Vector2d.Empty)) { + return Vector2d.Empty; + } + + const collideVector = collide.Sub(ray.A); + return ray.U.Dot(collideVector) < epsilon ? Vector2d.Empty : collide; + } + + public IsOnLeftSite(point: Vector2d, epsilon: number): boolean { + const direction = point.Sub(this.A); + return PrimitiveUtils.OrthogonalRight(this.U).Dot(direction) < epsilon; + } + + public IsOnRightSite(point: Vector2d, epsilon: number): boolean { + const direction = point.Sub(this.A); + return PrimitiveUtils.OrthogonalRight(this.U).Dot(direction) > -epsilon; + } +} \ No newline at end of file diff --git a/src/lib/skeletons/Primitives/PrimitiveUtils.ts b/src/lib/skeletons/Primitives/PrimitiveUtils.ts new file mode 100644 index 00000000..0520026d --- /dev/null +++ b/src/lib/skeletons/Primitives/PrimitiveUtils.ts @@ -0,0 +1,241 @@ +import Vector2d from "./Vector2d"; +import LineParametric2d from "./LineParametric2d"; +import {List} from "../Utils"; + +class IntersectPoints { + public readonly Intersect: Vector2d = null; + public readonly IntersectEnd: Vector2d = null; + + constructor(intersect?: Vector2d, intersectEnd?: Vector2d) { + if (!intersect) { + intersect = Vector2d.Empty; + } + + if (!intersectEnd) { + intersectEnd = Vector2d.Empty; + } + + this.Intersect = intersect; + this.IntersectEnd = intersectEnd; + } +} + + +export default class PrimitiveUtils { + public static FromTo(begin: Vector2d, end: Vector2d): Vector2d { + return new Vector2d(end.X - begin.X, end.Y - begin.Y); + } + + public static OrthogonalLeft(v: Vector2d): Vector2d { + return new Vector2d(-v.Y, v.X); + } + + public static OrthogonalRight(v: Vector2d): Vector2d { + return new Vector2d(v.Y, -v.X); + } + + public static OrthogonalProjection(unitVector: Vector2d, vectorToProject: Vector2d): Vector2d { + const n = new Vector2d(unitVector.X, unitVector.Y).Normalized(); + + const px = vectorToProject.X; + const py = vectorToProject.Y; + + const ax = n.X; + const ay = n.Y; + + return new Vector2d(px * ax * ax + py * ax * ay, px * ax * ay + py * ay * ay); + } + + public static BisectorNormalized(norm1: Vector2d, norm2: Vector2d): Vector2d { + const e1v = PrimitiveUtils.OrthogonalLeft(norm1); + const e2v = PrimitiveUtils.OrthogonalLeft(norm2); + + if (norm1.Dot(norm2) > 0) + return e1v.Add(e2v); + + let ret = new Vector2d(norm1.X, norm1.Y); + ret.Negate(); + ret = ret.Add(norm2); + + if (e1v.Dot(norm2) < 0) + ret.Negate(); + + return ret; + } + + private static readonly SmallNum = 0.00000001; + + private static readonly Empty: IntersectPoints = new IntersectPoints(); + + public static IsPointOnRay(point: Vector2d, ray: LineParametric2d, epsilon: number): boolean { + const rayDirection = new Vector2d(ray.U.X, ray.U.Y).Normalized(); + + const pointVector = point.Sub(ray.A); + + let dot = rayDirection.Dot(pointVector); + + if (dot < epsilon) + return false; + + const x = rayDirection.X; + rayDirection.X = rayDirection.Y; + rayDirection.Y = -x; + + dot = rayDirection.Dot(pointVector); + + return -epsilon < dot && dot < epsilon; + } + + public static IntersectRays2D(r1: LineParametric2d, r2: LineParametric2d): IntersectPoints { + const s1p0 = r1.A; + const s1p1 = r1.A.Add(r1.U); + + const s2p0 = r2.A; + + const u = r1.U; + const v = r2.U; + + const w = s1p0.Sub(s2p0); + const d = PrimitiveUtils.Perp(u, v); + + if (Math.abs(d) < PrimitiveUtils.SmallNum) { + if (PrimitiveUtils.Perp(u, w) !== 0 || PrimitiveUtils.Perp(v, w) !== 0) + return PrimitiveUtils.Empty; + + const du = PrimitiveUtils.Dot(u, u); + const dv = PrimitiveUtils.Dot(v, v); + + if (du === 0 && dv === 0) { + if (s1p0.NotEquals(s2p0)) + return PrimitiveUtils.Empty; + + return new IntersectPoints(s1p0); + } + if (du === 0) { + if (!PrimitiveUtils.InCollinearRay(s1p0, s2p0, v)) + return PrimitiveUtils.Empty; + + return new IntersectPoints(s1p0); + } + if (dv === 0) { + if (!PrimitiveUtils.InCollinearRay(s2p0, s1p0, u)) + return PrimitiveUtils.Empty; + + return new IntersectPoints(s2p0); + } + + let t0, t1; + var w2 = s1p1.Sub(s2p0); + if (v.X !== 0) { + t0 = w.X / v.X; + t1 = w2.X / v.X; + } else { + t0 = w.Y / v.Y; + t1 = w2.Y / v.Y; + } + if (t0 > t1) { + const t = t0; + t0 = t1; + t1 = t; + } + if (t1 < 0) + return PrimitiveUtils.Empty; + + t0 = t0 < 0 ? 0 : t0; + + if (t0 === t1) { + let I0 = new Vector2d(v.X, v.Y); + I0 = I0.MultiplyScalar(t0); + I0 = I0.Add(s2p0); + + return new IntersectPoints(I0); + } + + let I_0 = new Vector2d(v.X, v.Y); + I_0 = I_0.MultiplyScalar(t0); + I_0 = I_0.Add(s2p0); + + let I1 = new Vector2d(v.X, v.Y); + I1 = I1.MultiplyScalar(t1); + I1 = I1.Add(s2p0); + + return new IntersectPoints(I_0, I1); + } + + const sI = PrimitiveUtils.Perp(v, w) / d; + if (sI < 0 /* || sI > 1 */) + return PrimitiveUtils.Empty; + + const tI = PrimitiveUtils.Perp(u, w) / d; + if (tI < 0 /* || tI > 1 */) + return PrimitiveUtils.Empty; + + let IO = new Vector2d(u.X, u.Y); + IO = IO.MultiplyScalar(sI); + IO = IO.Add(s1p0); + + return new IntersectPoints(IO); + } + + private static InCollinearRay(p: Vector2d, rayStart: Vector2d, rayDirection: Vector2d): boolean { + const collideVector = p.Sub(rayStart); + const dot = rayDirection.Dot(collideVector); + + return !(dot < 0); + } + + private static Dot(u: Vector2d, v: Vector2d): number { + return u.Dot(v); + } + + private static Perp(u: Vector2d, v: Vector2d): number { + return u.X * v.Y - u.Y * v.X; + } + + public static IsClockwisePolygon(polygon: List): boolean { + return PrimitiveUtils.Area(polygon) < 0; + } + + private static Area(polygon: List): number { + const n = polygon.Count; + let A = 0; + for (let p = n - 1, q = 0; q < n; p = q++) + A += polygon[p].X * polygon[q].Y - polygon[q].X * polygon[p].Y; + + return A * 0.5; + } + + public static MakeCounterClockwise(polygon: List): List { + if (PrimitiveUtils.IsClockwisePolygon(polygon)) + polygon.Reverse(); + + return polygon; + } + + public static IsPointInsidePolygon(point: Vector2d, points: List): boolean { + const numpoints = points.Count; + + if (numpoints < 3) + return false; + + let it = 0; + const first = points[it]; + let oddNodes = false; + + for (let i = 0; i < numpoints; i++) { + const node1 = points[it]; + it++; + const node2 = i === numpoints - 1 ? first : points[it]; + + const x = point.X; + const y = point.Y; + + if (node1.Y < y && node2.Y >= y || node2.Y < y && node1.Y >= y) { + if (node1.X + (y - node1.Y) / (node2.Y - node1.Y) * (node2.X - node1.X) < x) + oddNodes = !oddNodes; + } + } + + return oddNodes; + } +} \ No newline at end of file diff --git a/src/lib/skeletons/Primitives/PriorityQueue.ts b/src/lib/skeletons/Primitives/PriorityQueue.ts new file mode 100644 index 00000000..5010defa --- /dev/null +++ b/src/lib/skeletons/Primitives/PriorityQueue.ts @@ -0,0 +1,63 @@ +import {IComparer, List} from "../Utils"; + +export default class PriorityQueue { + private readonly _comparer: IComparer = null; + private readonly _heap: List = null; + + constructor(capacity: number, comparer: IComparer) { + this._heap = new List(capacity); + this._comparer = comparer; + } + + public Clear() { + this._heap.Clear(); + } + + public Add(item: T) { + let n = this._heap.Count; + this._heap.Add(item); + while (n !== 0) { + const p = Math.floor(n / 2); + if (this._comparer.Compare(this._heap[n], (this._heap[p])) >= 0) break; + const tmp: T = this._heap[n]; + this._heap[n] = this._heap[p]; + this._heap[p] = tmp; + n = p; + } + } + + get Count(): number { + return this._heap.Count; + } + + get Empty(): boolean { + return this._heap.Count === 0; + } + + public Peek(): T { + return !this._heap.Any() ? null : this._heap[0]; + } + + public Next(): T { + const val: T = this._heap[0]; + const nMax = this._heap.Count - 1; + this._heap[0] = this._heap[nMax]; + this._heap.RemoveAt(nMax); + + let p = 0; + while (true) { + let c = p * 2; + if (c >= nMax) break; + + if (c + 1 < nMax && this._comparer.Compare(this._heap[c + 1], this._heap[c]) < 0) c++; + + if (this._comparer.Compare(this._heap[p], (this._heap[c])) <= 0) break; + + const tmp: T = this._heap[p]; + this._heap[p] = this._heap[c]; + this._heap[c] = tmp; + p = c; + } + return val; + } +} \ No newline at end of file diff --git a/src/lib/skeletons/Primitives/Vector2d.ts b/src/lib/skeletons/Primitives/Vector2d.ts new file mode 100644 index 00000000..eb2359e1 --- /dev/null +++ b/src/lib/skeletons/Primitives/Vector2d.ts @@ -0,0 +1,61 @@ +export default class Vector2d { + public static Empty: Vector2d = new Vector2d(Number.MIN_VALUE, Number.MIN_VALUE); + + public X: number = 0; + public Y: number = 0; + + constructor(x: number, y: number) { + this.X = x; + this.Y = y; + } + + public Negate() { + this.X = -this.X; + this.Y = -this.Y; + } + + public DistanceTo(var1: Vector2d): number { + const var2 = this.X - var1.X; + const var4 = this.Y - var1.Y; + return Math.sqrt(var2 * var2 + var4 * var4); + } + + public Normalized(): Vector2d { + const var1 = 1 / Math.sqrt(this.X * this.X + this.Y * this.Y); + return new Vector2d(this.X * var1, this.Y * var1); + } + + public Dot(var1: Vector2d): number { + return this.X * var1.X + this.Y * var1.Y; + } + + public DistanceSquared(var1: Vector2d): number { + const var2 = this.X - var1.X; + const var4 = this.Y - var1.Y; + return var2 * var2 + var4 * var4; + } + + public Add(v: Vector2d): Vector2d { + return new Vector2d(this.X + v.X, this.Y + v.Y); + } + + public Sub(v: Vector2d): Vector2d { + return new Vector2d(this.X - v.X, this.Y - v.Y); + } + + public MultiplyScalar(scale: number): Vector2d { + return new Vector2d(this.X * scale, this.Y * scale); + } + + public Equals(v: Vector2d): boolean { + return this.X === v.X && this.Y === v.Y; + } + + public NotEquals(v: Vector2d): boolean { + return !this.Equals(v); + } + + public ToString(): string { + return `${this.X}, ${this.Y}`; + } +} \ No newline at end of file diff --git a/src/lib/skeletons/Skeleton.ts b/src/lib/skeletons/Skeleton.ts new file mode 100644 index 00000000..6f013afd --- /dev/null +++ b/src/lib/skeletons/Skeleton.ts @@ -0,0 +1,13 @@ +import Vector2d from "./Primitives/Vector2d"; +import EdgeResult from "./EdgeResult"; +import {Dictionary, List} from "./Utils"; + +export class Skeleton { + public readonly Edges: List = null; + public readonly Distances: Dictionary = null; + + constructor(edges: List, distances: Dictionary) { + this.Edges = edges; + this.Distances = distances; + } +} \ No newline at end of file diff --git a/src/lib/skeletons/SkeletonBuilder.ts b/src/lib/skeletons/SkeletonBuilder.ts new file mode 100644 index 00000000..01ddb95b --- /dev/null +++ b/src/lib/skeletons/SkeletonBuilder.ts @@ -0,0 +1,994 @@ +import {Skeleton} from "./Skeleton"; +import {HashSet, List, IComparer, Dictionary, GeoJSONMultipolygon} from "./Utils"; +import Vector2d from "./Primitives/Vector2d"; +import PriorityQueue from "./Primitives/PriorityQueue"; +import Edge from "./Circular/Edge"; +import Vertex from "./Circular/Vertex"; +import CircularList from "./Circular/CircularList"; +import FaceQueue from "./Path/FaceQueue"; +import SkeletonEvent from "./Events/SkeletonEvent"; +import FaceQueueUtil from "./Path/FaceQueueUtil"; +import LavUtil from "./LavUtil"; +import IChain from "./Events/Chains/IChain"; +import PrimitiveUtils from "./Primitives/PrimitiveUtils"; +import LineParametric2d from "./Primitives/LineParametric2d"; +import {FaceNode} from "./Path/FaceNode"; +import MultiEdgeEvent from "./Events/MultiEdgeEvent"; +import EdgeEvent from "./Events/EdgeEvent"; +import PickEvent from "./Events/PickEvent"; +import MultiSplitEvent from "./Events/MultiSplitEvent"; +import SingleEdgeChain from "./Events/Chains/SingleEdgeChain"; +import SplitChain from "./Events/Chains/SplitChain"; +import SplitEvent from "./Events/SplitEvent"; +import VertexSplitEvent from "./Events/VertexSplitEvent"; +import EdgeChain from "./Events/Chains/EdgeChain"; +import LineLinear2d from "./Primitives/LineLinear2d"; +import EdgeResult from "./EdgeResult"; +import ChainType from "./Events/Chains/ChainType"; + +export default class SkeletonBuilder { + private static readonly SplitEpsilon = 1e-10; + + public static BuildFromGeoJSON(multipolygon: GeoJSONMultipolygon): Skeleton { + const allEdges: List = new List(); + const allDistances: Dictionary = new Dictionary(); + + for (const polygon of multipolygon) { + if (polygon.length > 0) { + const outer = this.ListFromCoordinatesArray(polygon[0]); + const holes: List> = new List(); + + for (let i = 1; i < polygon.length; i++) { + holes.Add(this.ListFromCoordinatesArray(polygon[i])); + } + + const skeleton = this.Build(outer, holes); + + for (const edge of skeleton.Edges) { + allEdges.Add(edge); + } + + for (const [key, distance] of skeleton.Distances.entries()) { + allDistances.Add(key, distance); + } + } + } + + return new Skeleton(allEdges, allDistances); + } + + private static ListFromCoordinatesArray(arr: [number, number][]): List { + const list: List = new List(); + + for (const [x, y] of arr) { + list.Add(new Vector2d(x, y)); + } + + return list; + } + + public static Build(polygon: List, holes: List> = null): Skeleton { + polygon = this.InitPolygon(polygon); + holes = this.MakeClockwise(holes); + + const queue = new PriorityQueue(3, new SkeletonEventDistanseComparer()); + const sLav = new HashSet>(); + const faces = new List(); + const edges = new List(); + + this.InitSlav(polygon, sLav, edges, faces); + + if (holes !== null) { + for (const inner of holes) { + this.InitSlav(inner, sLav, edges, faces); + } + } + + this.InitEvents(sLav, queue, edges); + + let count = 0; + while (!queue.Empty) { + count = this.AssertMaxNumberOfInteraction(count); + const levelHeight = queue.Peek().Distance; + + for (const event of this.LoadAndGroupLevelEvents(queue)) { + if (event.IsObsolete) + continue; + + if (event instanceof EdgeEvent) + throw new Error("All edge@events should be converted to MultiEdgeEvents for given level"); + if (event instanceof SplitEvent) + throw new Error("All split events should be converted to MultiSplitEvents for given level"); + if (event instanceof MultiSplitEvent) + this.MultiSplitEvent(event, sLav, queue, edges); + else if (event instanceof PickEvent) + this.PickEvent(event); + else if (event instanceof MultiEdgeEvent) + this.MultiEdgeEvent(event, queue, edges); + else + throw new Error("Unknown event type: " + event.GetType()); + } + + this.ProcessTwoNodeLavs(sLav); + this.RemoveEventsUnderHeight(queue, levelHeight); + this.RemoveEmptyLav(sLav); + } + + return this.AddFacesToOutput(faces); + } + + private static InitPolygon(polygon: List): List { + if (polygon === null) + throw new Error("polygon can't be null"); + + if (polygon[0].Equals(polygon[polygon.Count - 1])) + throw new Error("polygon can't start and end with the same point"); + + return this.MakeCounterClockwise(polygon); + } + + private static ProcessTwoNodeLavs(sLav: HashSet>) { + for (const lav of sLav) { + if (lav.Size === 2) { + const first = lav.First(); + const last = first.Next as Vertex; + + FaceQueueUtil.ConnectQueues(first.LeftFace, last.RightFace); + FaceQueueUtil.ConnectQueues(first.RightFace, last.LeftFace); + + first.IsProcessed = true; + last.IsProcessed = true; + + LavUtil.RemoveFromLav(first); + LavUtil.RemoveFromLav(last); + } + } + } + + private static RemoveEmptyLav(sLav: HashSet>) { + sLav.RemoveWhere(circularList => circularList.Size === 0); + } + + private static MultiEdgeEvent(event: MultiEdgeEvent, queue: PriorityQueue, edges: List) { + const center = event.V; + const edgeList = event.Chain.EdgeList; + + const previousVertex = event.Chain.PreviousVertex; + previousVertex.IsProcessed = true; + + const nextVertex = event.Chain.NextVertex; + nextVertex.IsProcessed = true; + + const bisector = this.CalcBisector(center, previousVertex.PreviousEdge, nextVertex.NextEdge); + const edgeVertex = new Vertex(center, event.Distance, bisector, previousVertex.PreviousEdge, + nextVertex.NextEdge); + + this.AddFaceLeft(edgeVertex, previousVertex); + + this.AddFaceRight(edgeVertex, nextVertex); + + previousVertex.AddPrevious(edgeVertex); + + this.AddMultiBackFaces(edgeList, edgeVertex); + + this.ComputeEvents(edgeVertex, queue, edges); + } + + private static AddMultiBackFaces(edgeList: List, edgeVertex: Vertex) { + for (const edgeEvent of edgeList) { + const leftVertex = edgeEvent.PreviousVertex; + leftVertex.IsProcessed = true; + LavUtil.RemoveFromLav(leftVertex); + + const rightVertex = edgeEvent.NextVertex; + rightVertex.IsProcessed = true; + LavUtil.RemoveFromLav(rightVertex); + + this.AddFaceBack(edgeVertex, leftVertex, rightVertex); + } + } + + private static PickEvent(event: PickEvent) { + const center = event.V; + const edgeList = event.Chain.EdgeList; + + const vertex = new Vertex(center, event.Distance, LineParametric2d.Empty, null, null); + vertex.IsProcessed = true; + + this.AddMultiBackFaces(edgeList, vertex); + } + + private static MultiSplitEvent(event: MultiSplitEvent, sLav: HashSet>, queue: PriorityQueue, edges: List) { + const chains = event.Chains; + const center = event.V; + + this.CreateOppositeEdgeChains(sLav, chains, center); + + chains.Sort(new ChainComparer(center)); + + let lastFaceNode: FaceNode = null; + + let edgeListSize = chains.Count; + for (let i = 0; i < edgeListSize; i++) { + const chainBegin = chains[i]; + const chainEnd = chains[(i + 1) % edgeListSize]; + + const newVertex = this.CreateMultiSplitVertex(chainBegin.NextEdge, chainEnd.PreviousEdge, center, event.Distance); + + const beginNextVertex = chainBegin.NextVertex; + const endPreviousVertex = chainEnd.PreviousVertex; + + this.CorrectBisectorDirection(newVertex.Bisector, beginNextVertex, endPreviousVertex, chainBegin.NextEdge, chainEnd.PreviousEdge); + + if (LavUtil.IsSameLav(beginNextVertex, endPreviousVertex)) { + const lavPart = LavUtil.CutLavPart(beginNextVertex, endPreviousVertex); + + const lav = new CircularList(); + sLav.Add(lav); + lav.AddLast(newVertex); + for (const vertex of lavPart) + lav.AddLast(vertex); + } else { + LavUtil.MergeBeforeBaseVertex(beginNextVertex, endPreviousVertex); + endPreviousVertex.AddNext(newVertex); + } + + this.ComputeEvents(newVertex, queue, edges); + lastFaceNode = this.AddSplitFaces(lastFaceNode, chainBegin, chainEnd, newVertex); + } + + edgeListSize = chains.Count; + for (let i = 0; i < edgeListSize; i++) { + const chainBegin = chains[i]; + const chainEnd = chains[(i + 1) % edgeListSize]; + + LavUtil.RemoveFromLav(chainBegin.CurrentVertex); + LavUtil.RemoveFromLav(chainEnd.CurrentVertex); + + if (chainBegin.CurrentVertex !== null) + chainBegin.CurrentVertex.IsProcessed = true; + if (chainEnd.CurrentVertex !== null) + chainEnd.CurrentVertex.IsProcessed = true; + } + } + + private static CorrectBisectorDirection(bisector: LineParametric2d, beginNextVertex: Vertex, endPreviousVertex: Vertex, beginEdge: Edge, endEdge: Edge) { + const beginEdge2 = beginNextVertex.PreviousEdge; + const endEdge2 = endPreviousVertex.NextEdge; + + if (beginEdge !== beginEdge2 || endEdge !== endEdge2) + throw new Error(); + + if (beginEdge.Norm.Dot(endEdge.Norm) < -0.97) { + const n1 = PrimitiveUtils.FromTo(endPreviousVertex.Point, bisector.A).Normalized(); + const n2 = PrimitiveUtils.FromTo(bisector.A, beginNextVertex.Point).Normalized(); + const bisectorPrediction = this.CalcVectorBisector(n1, n2); + + if (bisector.U.Dot(bisectorPrediction) < 0) + bisector.U.Negate(); + } + } + + private static AddSplitFaces(lastFaceNode: FaceNode, chainBegin: IChain, chainEnd: IChain, newVertex: Vertex): FaceNode { + if (chainBegin instanceof SingleEdgeChain) { + if (lastFaceNode === null) { + const beginVertex = this.CreateOppositeEdgeVertex(newVertex); + + newVertex.RightFace = beginVertex.RightFace; + lastFaceNode = beginVertex.LeftFace; + } else { + if (newVertex.RightFace !== null) + throw new Error("newVertex.RightFace should be null"); + + newVertex.RightFace = lastFaceNode; + lastFaceNode = null; + } + } else { + const beginVertex = chainBegin.CurrentVertex; + this.AddFaceRight(newVertex, beginVertex); + } + + if (chainEnd instanceof SingleEdgeChain) { + if (lastFaceNode === null) { + const endVertex = this.CreateOppositeEdgeVertex(newVertex); + + newVertex.LeftFace = endVertex.LeftFace; + lastFaceNode = endVertex.LeftFace; + } else { + if (newVertex.LeftFace !== null) + throw new Error("newVertex.LeftFace should be null."); + newVertex.LeftFace = lastFaceNode; + + lastFaceNode = null; + } + } else { + const endVertex = chainEnd.CurrentVertex; + this.AddFaceLeft(newVertex, endVertex); + } + return lastFaceNode; + } + + private static CreateOppositeEdgeVertex(newVertex: Vertex): Vertex { + const vertex = new Vertex(newVertex.Point, newVertex.Distance, newVertex.Bisector, newVertex.PreviousEdge, newVertex.NextEdge); + + const fn = new FaceNode(vertex); + vertex.LeftFace = fn; + vertex.RightFace = fn; + + const rightFace = new FaceQueue(); + rightFace.AddFirst(fn); + + return vertex; + } + + private static CreateOppositeEdgeChains(sLav: HashSet>, chains: List, center: Vector2d) { + const oppositeEdges = new HashSet(); + + const oppositeEdgeChains = new List(); + const chainsForRemoval = new List(); + + for (const chain of chains) { + if (chain instanceof SplitChain) { + const splitChain = chain; + const oppositeEdge = splitChain.OppositeEdge; + + if (oppositeEdge !== null && !oppositeEdges.Contains(oppositeEdge)) { + const nextVertex = this.FindOppositeEdgeLav(sLav, oppositeEdge, center); + + if (nextVertex !== null) + oppositeEdgeChains.Add(new SingleEdgeChain(oppositeEdge, nextVertex)); + else { + this.FindOppositeEdgeLav(sLav, oppositeEdge, center); + chainsForRemoval.Add(chain); + } + oppositeEdges.Add(oppositeEdge); + } + } + } + + for (let chain of chainsForRemoval) + chains.Remove(chain); + + chains.AddRange(oppositeEdgeChains); + } + + private static CreateMultiSplitVertex(nextEdge: Edge, previousEdge: Edge, center: Vector2d, distance: number): Vertex { + const bisector = this.CalcBisector(center, previousEdge, nextEdge); + return new Vertex(center, distance, bisector, previousEdge, nextEdge); + } + + private static CreateChains(cluster: List): List { + const edgeCluster = new List(); + const splitCluster = new List(); + const vertexEventsParents = new HashSet(); + + for (const skeletonEvent of cluster) { + if (skeletonEvent instanceof EdgeEvent) + edgeCluster.Add(skeletonEvent); + else { + if (skeletonEvent instanceof VertexSplitEvent) { + + } else if (skeletonEvent instanceof SplitEvent) { + const splitEvent = skeletonEvent; + vertexEventsParents.Add(splitEvent.Parent); + splitCluster.Add(splitEvent); + } + } + } + + for (let skeletonEvent of cluster) { + if (skeletonEvent instanceof VertexSplitEvent) { + const vertexEvent = skeletonEvent; + if (!vertexEventsParents.Contains(vertexEvent.Parent)) { + vertexEventsParents.Add(vertexEvent.Parent); + splitCluster.Add(vertexEvent); + } + } + } + + const edgeChains = new List(); + + while (edgeCluster.Count > 0) + edgeChains.Add(new EdgeChain(this.CreateEdgeChain(edgeCluster))); + + const chains = new List(edgeChains.Count); + for (const edgeChain of edgeChains) + chains.Add(edgeChain); + + splitEventLoop: + while (splitCluster.Any()) { + const split = splitCluster[0]; + splitCluster.RemoveAt(0); + + for (const chain of edgeChains) { + if (this.IsInEdgeChain(split, chain)) + continue splitEventLoop; //goto splitEventLoop; + } + + chains.Add(new SplitChain(split)); + } + + return chains; + } + + private static IsInEdgeChain(split: SplitEvent, chain: EdgeChain): boolean { + const splitParent = split.Parent; + const edgeList = chain.EdgeList; + + return edgeList.Any(edgeEvent => edgeEvent.PreviousVertex === splitParent || edgeEvent.NextVertex === splitParent); + } + + private static CreateEdgeChain(edgeCluster: List): List { + const edgeList = new List(); + + edgeList.Add(edgeCluster[0]); + edgeCluster.RemoveAt(0); + + loop: + for (; ;) { + const beginVertex = edgeList[0].PreviousVertex; + const endVertex = edgeList[edgeList.Count - 1].NextVertex; + + for (let i = 0; i < edgeCluster.Count; i++) { + const edge = edgeCluster[i]; + if (edge.PreviousVertex === endVertex) { + edgeCluster.RemoveAt(i); + edgeList.Add(edge); + //goto loop; + continue loop; + + } + if (edge.NextVertex === beginVertex) { + edgeCluster.RemoveAt(i); + edgeList.Insert(0, edge); + //goto loop; + continue loop; + } + } + break; + } + + return edgeList; + } + + private static RemoveEventsUnderHeight(queue: PriorityQueue, levelHeight: number) { + while (!queue.Empty) { + if (queue.Peek().Distance > levelHeight + this.SplitEpsilon) + break; + queue.Next(); + } + } + + private static LoadAndGroupLevelEvents(queue: PriorityQueue): List { + const levelEvents = this.LoadLevelEvents(queue); + return this.GroupLevelEvents(levelEvents); + } + + private static GroupLevelEvents(levelEvents: List): List { + const ret = new List(); + + const parentGroup = new HashSet(); + + while (levelEvents.Count > 0) { + parentGroup.Clear(); + + const event = levelEvents[0]; + levelEvents.RemoveAt(0); + const eventCenter = event.V; + const distance = event.Distance; + + this.AddEventToGroup(parentGroup, event); + + const cluster = new List(); + cluster.Add(event); + + for (let j = 0; j < levelEvents.Count; j++) { + const test = levelEvents[j]; + + if (this.IsEventInGroup(parentGroup, test)) { + const item = levelEvents[j]; + levelEvents.RemoveAt(j); + cluster.Add(item); + this.AddEventToGroup(parentGroup, test); + j--; + } else if (eventCenter.DistanceTo(test.V) < this.SplitEpsilon) { + const item = levelEvents[j]; + levelEvents.RemoveAt(j); + cluster.Add(item); + this.AddEventToGroup(parentGroup, test); + j--; + } + } + + ret.Add(this.CreateLevelEvent(eventCenter, distance, cluster)); + } + return ret; + } + + private static IsEventInGroup(parentGroup: HashSet, event: SkeletonEvent): boolean { + if (event instanceof SplitEvent) + return parentGroup.Contains((event).Parent); + if (event instanceof EdgeEvent) + return parentGroup.Contains((event).PreviousVertex) + || parentGroup.Contains((event).NextVertex); + return false; + } + + private static AddEventToGroup(parentGroup: HashSet, event: SkeletonEvent) { + if (event instanceof SplitEvent) + parentGroup.Add((event).Parent); + else if (event instanceof EdgeEvent) { + parentGroup.Add((event).PreviousVertex); + parentGroup.Add((event).NextVertex); + } + } + + private static CreateLevelEvent(eventCenter: Vector2d, distance: number, eventCluster: List): SkeletonEvent { + const chains = this.CreateChains(eventCluster); + + if (chains.Count === 1) { + const chain = chains[0]; + if (chain.ChainType === ChainType.ClosedEdge) + return new PickEvent(eventCenter, distance, chain); + if (chain.ChainType === ChainType.Edge) + return new MultiEdgeEvent(eventCenter, distance, chain); + if (chain.ChainType === ChainType.Split) + return new MultiSplitEvent(eventCenter, distance, chains); + } + + if (chains.Any(chain => chain.ChainType === ChainType.ClosedEdge)) + throw new Error("Found closed chain of events for single point, but found more then one chain"); + return new MultiSplitEvent(eventCenter, distance, chains); + } + + private static LoadLevelEvents(queue: PriorityQueue): List { + const level = new List(); + let levelStart: SkeletonEvent; + + do { + levelStart = queue.Empty ? null : queue.Next(); + } + while (levelStart !== null && levelStart.IsObsolete); + + + if (levelStart === null || levelStart.IsObsolete) + return level; + + const levelStartHeight = levelStart.Distance; + + level.Add(levelStart); + + let event: SkeletonEvent; + while ((event = queue.Peek()) !== null && + Math.abs(event.Distance - levelStartHeight) < this.SplitEpsilon) { + const nextLevelEvent = queue.Next(); + if (!nextLevelEvent.IsObsolete) + level.Add(nextLevelEvent); + } + return level; + } + + private static AssertMaxNumberOfInteraction(count: number): number { + count++; + if (count > 10000) + throw new Error("Too many interaction: bug?"); + return count; + } + + private static MakeClockwise(holes: List>): List> { + if (holes === null) + return null; + + const ret = new List>(holes.Count); + for (const hole of holes) { + if (PrimitiveUtils.IsClockwisePolygon(hole)) + ret.Add(hole); + else { + hole.Reverse(); + ret.Add(hole); + } + } + return ret; + } + + private static MakeCounterClockwise(polygon: List): List { + return PrimitiveUtils.MakeCounterClockwise(polygon); + } + + private static InitSlav(polygon: List, sLav: HashSet>, edges: List, faces: List) { + const edgesList = new CircularList(); + + const size = polygon.Count; + for (let i = 0; i < size; i++) { + const j = (i + 1) % size; + edgesList.AddLast(new Edge(polygon[i], polygon[j])); + } + + for (const edge of edgesList.Iterate()) { + const nextEdge = edge.Next as Edge; + const bisector = this.CalcBisector(edge.End, edge, nextEdge); + + edge.BisectorNext = bisector; + nextEdge.BisectorPrevious = bisector; + edges.Add(edge); + } + + const lav = new CircularList(); + sLav.Add(lav); + + for (const edge of edgesList.Iterate()) { + const nextEdge = edge.Next as Edge; + const vertex = new Vertex(edge.End, 0, edge.BisectorNext, edge, nextEdge); + lav.AddLast(vertex); + } + + for (const vertex of lav.Iterate()) { + const next = vertex.Next as Vertex; + const rightFace = new FaceNode(vertex); + + const faceQueue = new FaceQueue(); + faceQueue.Edge = (vertex.NextEdge); + + faceQueue.AddFirst(rightFace); + faces.Add(faceQueue); + vertex.RightFace = rightFace; + + const leftFace = new FaceNode(next); + rightFace.AddPush(leftFace); + next.LeftFace = leftFace; + } + } + + private static AddFacesToOutput(faces: List): Skeleton { + const edgeOutputs = new List(); + const distances = new Dictionary(); + + for (const face of faces) { + if (face.Size > 0) { + const faceList = new List(); + + for (const fn of face.Iterate()) { + const point = fn.Vertex.Point; + + faceList.Add(point); + + if (!distances.ContainsKey(point)) + distances.Add(point, fn.Vertex.Distance); + } + + edgeOutputs.Add(new EdgeResult(face.Edge, faceList)); + } + } + return new Skeleton(edgeOutputs, distances); + } + + private static InitEvents(sLav: HashSet>, queue: PriorityQueue, edges: List) { + for (const lav of sLav) { + for (const vertex of lav.Iterate()) + this.ComputeSplitEvents(vertex, edges, queue, -1); + } + + for (const lav of sLav) { + for (const vertex of lav.Iterate()) { + const nextVertex = vertex.Next as Vertex; + this.ComputeEdgeEvents(vertex, nextVertex, queue); + } + } + } + + private static ComputeSplitEvents(vertex: Vertex, edges: List, queue: PriorityQueue, distanceSquared: number) { + const source = vertex.Point; + const oppositeEdges = this.CalcOppositeEdges(vertex, edges); + + for (const oppositeEdge of oppositeEdges) { + const point = oppositeEdge.Point; + + if (Math.abs(distanceSquared - (-1)) > this.SplitEpsilon) { + if (source.DistanceSquared(point) > distanceSquared + this.SplitEpsilon) { + continue; + } + } + + if (oppositeEdge.OppositePoint.NotEquals(Vector2d.Empty)) { + queue.Add(new VertexSplitEvent(point, oppositeEdge.Distance, vertex)); + continue; + } + queue.Add(new SplitEvent(point, oppositeEdge.Distance, vertex, oppositeEdge.OppositeEdge)); + } + } + + private static ComputeEvents(vertex: Vertex, queue: PriorityQueue, edges: List) { + const distanceSquared = this.ComputeCloserEdgeEvent(vertex, queue); + this.ComputeSplitEvents(vertex, edges, queue, distanceSquared); + } + + private static ComputeCloserEdgeEvent(vertex: Vertex, queue: PriorityQueue): number { + const nextVertex = vertex.Next as Vertex; + const previousVertex = vertex.Previous as Vertex; + + const point = vertex.Point; + + const point1 = this.ComputeIntersectionBisectors(vertex, nextVertex); + const point2 = this.ComputeIntersectionBisectors(previousVertex, vertex); + + if (point1.Equals(Vector2d.Empty) && point2.Equals(Vector2d.Empty)) + return -1; + + let distance1 = Number.MAX_VALUE; + let distance2 = Number.MAX_VALUE; + + if (point1.NotEquals(Vector2d.Empty)) + distance1 = point.DistanceSquared(point1); + if (point2.NotEquals(Vector2d.Empty)) + distance2 = point.DistanceSquared(point2); + + if (Math.abs(distance1 - this.SplitEpsilon) < distance2) + queue.Add(this.CreateEdgeEvent(point1, vertex, nextVertex)); + if (Math.abs(distance2 - this.SplitEpsilon) < distance1) + queue.Add(this.CreateEdgeEvent(point2, previousVertex, vertex)); + + return distance1 < distance2 ? distance1 : distance2; + } + + private static CreateEdgeEvent(point: Vector2d, previousVertex: Vertex, nextVertex: Vertex): SkeletonEvent { + return new EdgeEvent(point, this.CalcDistance(point, previousVertex.NextEdge), previousVertex, nextVertex); + } + + private static ComputeEdgeEvents(previousVertex: Vertex, nextVertex: Vertex, queue: PriorityQueue) { + const point = this.ComputeIntersectionBisectors(previousVertex, nextVertex); + if (point.NotEquals(Vector2d.Empty)) + queue.Add(this.CreateEdgeEvent(point, previousVertex, nextVertex)); + } + + private static CalcOppositeEdges(vertex: Vertex, edges: List): List { + const ret = new List(); + + for (const edgeEntry of edges) { + const edge = edgeEntry.LineLinear2d; + + if (this.EdgeBehindBisector(vertex.Bisector, edge)) + continue; + + const candidatePoint = this.CalcCandidatePointForSplit(vertex, edgeEntry); + if (candidatePoint !== null) + ret.Add(candidatePoint); + } + + ret.Sort(new SplitCandidateComparer()); + return ret; + } + + private static EdgeBehindBisector(bisector: LineParametric2d, edge: LineLinear2d): boolean { + return LineParametric2d.Collide(bisector, edge, this.SplitEpsilon).Equals(Vector2d.Empty); + } + + private static CalcCandidatePointForSplit(vertex: Vertex, edge: Edge): SplitCandidate { + const vertexEdge = this.ChoseLessParallelVertexEdge(vertex, edge); + if (vertexEdge === null) + return null; + + const vertexEdteNormNegate = vertexEdge.Norm; + const edgesBisector = this.CalcVectorBisector(vertexEdteNormNegate, edge.Norm); + const edgesCollide = vertexEdge.LineLinear2d.Collide(edge.LineLinear2d); + + if (edgesCollide.Equals(Vector2d.Empty)) + throw new Error("Ups this should not happen"); + + const edgesBisectorLine = new LineParametric2d(edgesCollide, edgesBisector).CreateLinearForm(); + + const candidatePoint = LineParametric2d.Collide(vertex.Bisector, edgesBisectorLine, this.SplitEpsilon); + + if (candidatePoint.Equals(Vector2d.Empty)) + return null; + + if (edge.BisectorPrevious.IsOnRightSite(candidatePoint, this.SplitEpsilon) + && edge.BisectorNext.IsOnLeftSite(candidatePoint, this.SplitEpsilon)) { + const distance = this.CalcDistance(candidatePoint, edge); + + if (edge.BisectorPrevious.IsOnLeftSite(candidatePoint, this.SplitEpsilon)) + return new SplitCandidate(candidatePoint, distance, null, edge.Begin); + if (edge.BisectorNext.IsOnRightSite(candidatePoint, this.SplitEpsilon)) + return new SplitCandidate(candidatePoint, distance, null, edge.Begin); + + return new SplitCandidate(candidatePoint, distance, edge, Vector2d.Empty); + } + + return null; + } + + private static ChoseLessParallelVertexEdge(vertex: Vertex, edge: Edge): Edge { + const edgeA = vertex.PreviousEdge; + const edgeB = vertex.NextEdge; + + let vertexEdge = edgeA; + + const edgeADot = Math.abs(edge.Norm.Dot(edgeA.Norm)); + const edgeBDot = Math.abs(edge.Norm.Dot(edgeB.Norm)); + + if (edgeADot + edgeBDot >= 2 - this.SplitEpsilon) + return null; + + if (edgeADot > edgeBDot) + vertexEdge = edgeB; + + return vertexEdge; + } + + private static ComputeIntersectionBisectors(vertexPrevious: Vertex, vertexNext: Vertex): Vector2d { + const bisectorPrevious = vertexPrevious.Bisector; + const bisectorNext = vertexNext.Bisector; + + const intersectRays2d = PrimitiveUtils.IntersectRays2D(bisectorPrevious, bisectorNext); + const intersect = intersectRays2d.Intersect; + + if (vertexPrevious.Point.Equals(intersect) || vertexNext.Point.Equals(intersect)) + return Vector2d.Empty; + + return intersect; + } + + private static FindOppositeEdgeLav(sLav: HashSet>, oppositeEdge: Edge, center: Vector2d): Vertex { + const edgeLavs = this.FindEdgeLavs(sLav, oppositeEdge, null); + return this.ChooseOppositeEdgeLav(edgeLavs, oppositeEdge, center); + } + + private static ChooseOppositeEdgeLav(edgeLavs: List, oppositeEdge: Edge, center: Vector2d): Vertex { + if (!edgeLavs.Any()) + return null; + + if (edgeLavs.Count === 1) + return edgeLavs[0]; + + const edgeStart = oppositeEdge.Begin; + const edgeNorm = oppositeEdge.Norm; + const centerVector = center.Sub(edgeStart); + const centerDot = edgeNorm.Dot(centerVector); + for (const end of edgeLavs) { + const begin = end.Previous as Vertex; + + const beginVector = begin.Point.Sub(edgeStart); + const endVector = end.Point.Sub(edgeStart); + + const beginDot = edgeNorm.Dot(beginVector); + const endDot = edgeNorm.Dot(endVector); + + if (beginDot < centerDot && centerDot < endDot || + beginDot > centerDot && centerDot > endDot) + return end; + } + + for (const end of edgeLavs) { + const size = end.List.Size; + const points = new List(size); + let next = end; + for (let i = 0; i < size; i++) { + points.Add(next.Point); + next = next.Next as Vertex; + } + if (PrimitiveUtils.IsPointInsidePolygon(center, points)) + return end; + } + throw new Error("Could not find lav for opposite edge, it could be correct but need some test data to check."); + } + + private static FindEdgeLavs(sLav: HashSet>, oppositeEdge: Edge, skippedLav: CircularList): List { + const edgeLavs = new List(); + for (const lav of sLav) { + if (lav === skippedLav) + continue; + + const vertexInLav = this.GetEdgeInLav(lav, oppositeEdge); + if (vertexInLav !== null) + edgeLavs.Add(vertexInLav); + } + return edgeLavs; + } + + private static GetEdgeInLav(lav: CircularList, oppositeEdge: Edge): Vertex { + for (const node of lav.Iterate()) + if (oppositeEdge === node.PreviousEdge || + oppositeEdge === node.Previous.Next) + return node; + + return null; + } + + private static AddFaceBack(newVertex: Vertex, va: Vertex, vb: Vertex) { + const fn = new FaceNode(newVertex); + va.RightFace.AddPush(fn); + FaceQueueUtil.ConnectQueues(fn, vb.LeftFace); + } + + private static AddFaceRight(newVertex: Vertex, vb: Vertex) { + const fn = new FaceNode(newVertex); + vb.RightFace.AddPush(fn); + newVertex.RightFace = fn; + } + + private static AddFaceLeft(newVertex: Vertex, va: Vertex) { + const fn = new FaceNode(newVertex); + va.LeftFace.AddPush(fn); + newVertex.LeftFace = fn; + } + + private static CalcDistance(intersect: Vector2d, currentEdge: Edge): number { + const edge = currentEdge.End.Sub(currentEdge.Begin); + const vector = intersect.Sub(currentEdge.Begin); + + const pointOnVector = PrimitiveUtils.OrthogonalProjection(edge, vector); + return vector.DistanceTo(pointOnVector); + } + + private static CalcBisector(p: Vector2d, e1: Edge, e2: Edge): LineParametric2d { + const norm1 = e1.Norm; + const norm2 = e2.Norm; + + const bisector = this.CalcVectorBisector(norm1, norm2); + return new LineParametric2d(p, bisector); + } + + private static CalcVectorBisector(norm1: Vector2d, norm2: Vector2d): Vector2d { + return PrimitiveUtils.BisectorNormalized(norm1, norm2); + } +} + +class SkeletonEventDistanseComparer implements IComparer { + public Compare(left: SkeletonEvent, right: SkeletonEvent): number { + if (left.Distance > right.Distance) + return 1; + if (left.Distance < right.Distance) + return -1; + + return 0; + } +} + +class ChainComparer implements IComparer { + private readonly _center: Vector2d; + + constructor(center: Vector2d) { + this._center = center; + } + + public Compare(x: IChain, y: IChain): number { + if (x === y) + return 0; + + const angle1 = ChainComparer.Angle(this._center, x.PreviousEdge.Begin); + const angle2 = ChainComparer.Angle(this._center, y.PreviousEdge.Begin); + + return angle1 > angle2 ? 1 : -1; + } + + private static Angle(p0: Vector2d, p1: Vector2d): number { + const dx = p1.X - p0.X; + const dy = p1.Y - p0.Y; + return Math.atan2(dy, dx); + } +} + +class SplitCandidateComparer implements IComparer { + public Compare(left: SplitCandidate, right: SplitCandidate): number { + if (left.Distance > right.Distance) + return 1; + if (left.Distance < right.Distance) + return -1; + + return 0; + } +} + +class SplitCandidate { + public readonly Distance: number; + public readonly OppositeEdge: Edge = null; + public readonly OppositePoint: Vector2d = null; + public readonly Point: Vector2d = null; + + constructor(point: Vector2d, distance: number, oppositeEdge: Edge, oppositePoint: Vector2d) { + this.Point = point; + this.Distance = distance; + this.OppositeEdge = oppositeEdge; + this.OppositePoint = oppositePoint; + } +} + diff --git a/src/lib/skeletons/Utils.ts b/src/lib/skeletons/Utils.ts new file mode 100644 index 00000000..cfd3ff8b --- /dev/null +++ b/src/lib/skeletons/Utils.ts @@ -0,0 +1,133 @@ +function insertInArray(array: Array, index: number, item: T): Array { + const items = Array.prototype.slice.call(arguments, 2); + + return [].concat(array.slice(0, index), items, array.slice(index)); +} + +export interface IComparable { + CompareTo(other: T): number; +} + +export interface IComparer { + Compare(a: T, b: T): number; +} + +export type GeoJSONMultipolygon = [number, number][][][]; + +export class List extends Array { + constructor(capacity = 0) { + super(); + } + + public Add(item: T) { + this.push(item); + } + + public Insert(index: number, item: T) { + const newArr = insertInArray(this, index, item); + + this.length = newArr.length; + + for(let i = 0; i < newArr.length; i++) { + this[i] = newArr[i]; + } + } + + public Reverse() { + this.reverse(); + } + + public Clear() { + this.length = 0; + } + + get Count(): number { + return this.length; + } + + public Any(filter?: (item: T) => boolean): boolean { + if (!filter) { + filter = T => true; + } + + for (const item of this) { + if (filter(item)) { + return true; + } + } + + return false; + } + + public RemoveAt(index: number) { + this.splice(index, 1); + } + + public Remove(itemToRemove: T) { + const newArr = this.filter(item => item !== itemToRemove); + + this.length = newArr.length; + + for(let i = 0; i < newArr.length; i++) { + this[i] = newArr[i]; + } + } + + public AddRange(list: List) { + for (const item of list) { + this.Add(item); + } + } + + public Sort(comparer: IComparer) { + this.sort(comparer.Compare.bind(comparer)); + } +} + +export class HashSet implements Iterable { + private Set: Set; + + constructor() { + this.Set = new Set(); + } + + public Add(item: T) { + this.Set.add(item); + } + + public Remove(item: T) { + this.Set.delete(item); + } + + public RemoveWhere(filter: (item: T) => boolean) { + for (const item of this.Set.values()) { + if (filter(item)) { + this.Set.delete(item); + } + } + } + + public Contains(item: T): boolean { + return this.Set.has(item); + } + + public Clear() { + this.Set.clear(); + } + + public* [Symbol.iterator](): Generator { + for (const item of this.Set.values()) { + yield item; + } + } +} + +export class Dictionary extends Map { + public ContainsKey(key: T1): boolean { + return this.has(key); + } + + public Add(key: T1, value: T2) { + return this.set(key, value); + } +} \ No newline at end of file diff --git a/src/lib/skeletons/index.ts b/src/lib/skeletons/index.ts new file mode 100644 index 00000000..b1f2cdb5 --- /dev/null +++ b/src/lib/skeletons/index.ts @@ -0,0 +1,12 @@ +// Types +export type { GeoJSONMultipolygon, List } from "./Utils"; +export type { Skeleton } from "./Skeleton"; + +// Values +export { default as Vector2d } from "./Primitives/Vector2d"; +export { default as SkeletonBuilder } from "./SkeletonBuilder"; +export { default as EdgeResult } from "./EdgeResult"; +export { default as Edge } from "./Circular/Edge"; +export { default as Vertex } from "./Circular/Vertex"; + + diff --git a/src/locales/ja.json b/src/locales/ja.json index 44f723fd..5aa83d3f 100644 --- a/src/locales/ja.json +++ b/src/locales/ja.json @@ -57,6 +57,7 @@ "modal.movement.flow.line.top.right": "高さ変更:上、右", "plan.menu.roof.cover.outline.edit.offset": "外壁の編集とオフセット", "plan.menu.roof.cover.roof.surface.alloc": "屋根面の割り当て", + "plan.menu.roof.cover.roof.surface.all.remove": "伏せ図全削除", "plan.menu.roof.cover.roof.shape.edit": "屋根形状編集", "plan.menu.roof.cover.auxiliary.line.drawing": "補助線の作成", "modal.cover.outline.drawing": "外壁線の作成", diff --git a/src/locales/ko.json b/src/locales/ko.json index 83ed3f2e..35caabfd 100644 --- a/src/locales/ko.json +++ b/src/locales/ko.json @@ -57,6 +57,7 @@ "modal.movement.flow.line.top.right": "높이변경 : 위, 오른쪽", "plan.menu.roof.cover.outline.edit.offset": "외벽선 편집 및 오프셋", "plan.menu.roof.cover.roof.surface.alloc": "지붕면 할당", + "plan.menu.roof.cover.roof.surface.all.remove": "배치면 전체 삭제", "plan.menu.roof.cover.roof.shape.edit": "지붕형상 편집", "plan.menu.roof.cover.auxiliary.line.drawing": "보조선 작성", "modal.cover.outline.drawing": "외벽선 작성", diff --git a/src/store/menuAtom.js b/src/store/menuAtom.js index fdb50461..007e7950 100644 --- a/src/store/menuAtom.js +++ b/src/store/menuAtom.js @@ -23,9 +23,19 @@ export const menusState = atom({ }, { type: 'outline', name: 'plan.menu.roof.cover', icon: 'con02', title: MENU.ROOF_COVERING.DEFAULT }, { type: 'surface', name: 'plan.menu.placement.surface', icon: 'con03', title: MENU.BATCH_CANVAS.DEFAULT }, - { type: 'module', name: 'plan.menu.module.circuit.setting', icon: 'con04', title: MENU.MODULE_CIRCUIT_SETTING.DEFAULT }, + { + type: 'module', + name: 'plan.menu.module.circuit.setting', + icon: 'con04', + title: MENU.MODULE_CIRCUIT_SETTING.DEFAULT, + }, { type: 'estimate', name: 'plan.menu.estimate', icon: 'con06', title: MENU.ESTIMATE.DEFAULT }, - { type: 'simulation', name: 'plan.menu.simulation', icon: 'con05', title: MENU.POWER_GENERATION_SIMULATION.DEFAULT }, + { + type: 'simulation', + name: 'plan.menu.simulation', + icon: 'con05', + title: MENU.POWER_GENERATION_SIMULATION.DEFAULT, + }, ], }) @@ -37,16 +47,17 @@ export const subMenusState = atom({ // 지붕덮개 { id: 0, name: 'plan.menu.roof.cover.outline.drawing', menu: MENU.ROOF_COVERING.EXTERIOR_WALL_LINE }, { id: 1, name: 'plan.menu.roof.cover.roof.shape.setting', menu: MENU.ROOF_COVERING.ROOF_SHAPE_SETTINGS }, - // { - // id: 2, - // name: 'plan.menu.roof.cover.roof.shape.passivity.setting', - // menu: MENU.ROOF_COVERING.ROOF_SHAPE_PASSIVITY_SETTINGS, - // }, + { + id: 2, + name: 'plan.menu.roof.cover.roof.shape.passivity.setting', + menu: MENU.ROOF_COVERING.ROOF_SHAPE_PASSIVITY_SETTINGS, + }, { id: 3, name: 'plan.menu.roof.cover.auxiliary.line.drawing', menu: MENU.ROOF_COVERING.HELP_LINE_DRAWING }, { id: 4, name: 'plan.menu.roof.cover.eaves.kerava.edit', menu: MENU.ROOF_COVERING.EAVES_KERAVA_EDIT }, { id: 5, name: 'plan.menu.roof.cover.movement.shape.updown', menu: MENU.ROOF_COVERING.MOVEMENT_SHAPE_UPDOWN }, { id: 6, name: 'plan.menu.roof.cover.outline.edit.offset', menu: MENU.ROOF_COVERING.OUTLINE_EDIT_OFFSET }, { id: 7, name: 'plan.menu.roof.cover.roof.surface.alloc', menu: MENU.ROOF_COVERING.ROOF_SHAPE_ALLOC }, + { id: 8, name: 'plan.menu.roof.cover.roof.surface.all.remove', menu: MENU.ROOF_COVERING.ALL_REMOVE }, ], surface: [ // 배치면 diff --git a/src/styles/calc.scss b/src/styles/calc.scss new file mode 100644 index 00000000..f3977c65 --- /dev/null +++ b/src/styles/calc.scss @@ -0,0 +1,156 @@ +// 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 +); + +// Mixins +@mixin button-styles { + padding: 0.125rem; + border-radius: 0.5rem; + font-weight: bold; + font-size: 0.625rem; + color: white; + border: none; + cursor: pointer; + transition: all 0.1s ease-in-out; + + &:active { + transform: scale(0.95); + box-shadow: 0 2px 4px rgba(0, 0, 0, 0.2); + } +} + +// Calculator Input Wrapper +.calculator-input-wrapper { + position: relative; + width: 100%; + display: inline-block; + + // Input Field + .calculator-input { + width: 100%; + padding: 0.5rem 1rem; + background-color: map-get($colors, 'dark-600'); + border: 1px solid map-get($colors, 'border'); + color: white; + font-weight: bold; + border-radius: 0.5rem; + text-align: right; + cursor: pointer; + font-size: 0.625rem; + box-sizing: border-box; + + &:focus { + outline: none; + border-color: map-get($colors, 'primary'); + box-shadow: 0 0 0 2px rgba(16, 185, 129, 0.2); + } + } + + // Keypad Container + .keypad-container { + position: absolute; + top: calc(100% + 2px); + left: 0; + right: 0; + 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); + padding: 0.5rem; + z-index: 1000; + animation: fadeIn 0.15s ease-out; + border: 1px solid map-get($colors, 'border'); + box-sizing: border-box; + + // Keypad Grid + .keypad-grid { + display: grid; + grid-template-columns: repeat(4, 1fr); + gap: 0.125rem; + + // Button Base + button { + @include button-styles; + } + + // Button Types + .btn-number { + background-color: map-get($colors, 'dark-500'); + + &:hover { + background-color: map-get($colors, 'dark-400'); + } + } + + .btn-operator { + background-color: map-get($colors, 'warning'); + + &:hover { + background-color: map-get($colors, 'warning-dark'); + } + } + + .btn-clear { + background-color: map-get($colors, 'danger'); + grid-column: span 2; + + &:hover { + background-color: map-get($colors, 'danger-dark'); + } + } + + .btn-delete { + background-color: map-get($colors, 'primary'); + grid-column: span 2; + + &:hover { + background-color: map-get($colors, 'primary-dark'); + } + } + + .btn-equals { + background-color: map-get($colors, 'warning'); + + &:hover { + background-color: map-get($colors, 'warning-dark'); + } + } + + .btn-zero { + grid-column: span 1; + } + } + } +} + +// Animations +@keyframes fadeIn { + from { + opacity: 0; + transform: translateY(-10px) scale(0.95); + } + to { + opacity: 1; + transform: translateY(0) scale(1); + } +} + +// Responsive Design +@media (max-width: 640px) { + .keypad-grid button { + padding: 0.5rem; + font-size: 0.875rem; + } +} \ No newline at end of file diff --git a/src/util/calc-utils.js b/src/util/calc-utils.js new file mode 100644 index 00000000..49e76721 --- /dev/null +++ b/src/util/calc-utils.js @@ -0,0 +1,172 @@ +export const createCalculator = (options = {}) => { + const state = { + currentOperand: '', + previousOperand: '', + operation: undefined, + shouldResetDisplay: false, + allowNegative: options.allowNegative ?? true, + allowDecimal: options.allowDecimal ?? true, + allowZero: options.allowZero ?? true, + decimalPlaces: options.decimalPlaces ?? null, + } + + // Expose state for debugging and direct access + const getState = () => ({ ...state }) + + const clear = () => { + state.currentOperand = '' + state.previousOperand = '' + state.operation = undefined + state.shouldResetDisplay = false + return state.currentOperand + } + + const deleteNumber = () => { + if (state.currentOperand.length <= 1) { + state.currentOperand = '0' + } else { + state.currentOperand = state.currentOperand.toString().slice(0, -1) + } + return state.currentOperand + } + + const appendNumber = (number) => { + if (number === '.' && !state.allowDecimal) return state.currentOperand + if (number === '.' && state.currentOperand.includes('.')) return state.currentOperand + + if (state.shouldResetDisplay) { + state.currentOperand = number.toString() + state.shouldResetDisplay = false + } else { + if (state.currentOperand === '0' && number !== '.') { + state.currentOperand = number.toString() + } else { + state.currentOperand = state.currentOperand.toString() + number.toString() + } + } + return state.currentOperand + } + + const chooseOperation = (operation) => { + if (operation === '-' && state.currentOperand === '0' && state.previousOperand === '' && state.allowNegative) { + state.currentOperand = '-' + return state.currentOperand + } + + if (state.currentOperand === '' || state.currentOperand === '-') return state.currentOperand + + // If there's a previous operation, compute it first + if (state.previousOperand !== '') { + compute() + } + + state.operation = operation + state.previousOperand = state.currentOperand + state.currentOperand = '' + return state.previousOperand + state.operation + } + + const compute = () => { + // If there's no operation, return the current value + if (!state.operation) return parseFloat(state.currentOperand || '0') + + // If there's no current operand but we have a previous one, use previous as current + if (state.currentOperand === '' && state.previousOperand !== '') { + state.currentOperand = state.previousOperand + } + + const prev = parseFloat(state.previousOperand) + const current = parseFloat(state.currentOperand) + + if (isNaN(prev) || isNaN(current)) return 0 + + let result + switch (state.operation) { + case '+': + result = prev + current + break + case '-': + result = prev - current + break + case '×': + result = prev * current + break + case '÷': + if (current === 0) { + state.currentOperand = 'Error' + return 0 + } + result = prev / current + break + default: + return parseFloat(state.currentOperand || '0') + } + + // Apply formatting and constraints + if (state.decimalPlaces !== null) { + result = Number(result.toFixed(state.decimalPlaces)) + } + + if (!state.allowDecimal) { + result = Math.round(result) + } + + if (!state.allowNegative && result < 0) { + result = 0 + } + + if (!state.allowZero && result === 0) { + result = 1 + } + + // Update state + state.currentOperand = result.toString() + state.previousOperand = '' + state.operation = undefined + state.shouldResetDisplay = true + + return result + } + + // Getter methods for the calculator state + const getCurrentOperand = () => state.currentOperand + const getPreviousOperand = () => state.previousOperand + const getOperation = () => state.operation + const getDisplayValue = () => { + if (state.operation && state.previousOperand) { + return `${state.previousOperand} ${state.operation} ${state.currentOperand || ''}`.trim() + } + return state.currentOperand + } + + return { + // Core calculator methods + clear, + delete: deleteNumber, // Alias for deleteNumber for compatibility + deleteNumber, + appendNumber, + chooseOperation, + compute, + + // State getters + getDisplayValue, + getCurrentOperand, + getPreviousOperand, + getOperation, + + // Direct state access (for debugging) + getState, + + // Direct property access (for compatibility with CalcInput.jsx) + get currentOperand() { return state.currentOperand }, + get previousOperand() { return state.previousOperand }, + get operation() { return state.operation }, + get shouldResetDisplay() { return state.shouldResetDisplay }, + + // Setter for direct property access (if needed) + set currentOperand(value) { state.currentOperand = value }, + set previousOperand(value) { state.previousOperand = value }, + set operation(value) { state.operation = value }, + set shouldResetDisplay(value) { state.shouldResetDisplay = value } + } +} diff --git a/tsconfig.json b/tsconfig.json new file mode 100644 index 00000000..2a8f023b --- /dev/null +++ b/tsconfig.json @@ -0,0 +1,39 @@ +{ + "compilerOptions": { + "target": "es2015", + "downlevelIteration": true, + "lib": [ + "dom", + "dom.iterable", + "esnext" + ], + "allowJs": true, + "skipLibCheck": true, + "strict": false, + "noEmit": true, + "incremental": true, + "module": "esnext", + "esModuleInterop": true, + "moduleResolution": "node", + "resolveJsonModule": true, + "isolatedModules": true, + "jsx": "preserve", + "plugins": [ + { + "name": "next" + } + ], + "paths": { + "@/*": ["./src/*"] + } + }, + "include": [ + "next-env.d.ts", + ".next/types/**/*.ts", + "**/*.ts", + "**/*.tsx" + ], + "exclude": [ + "node_modules" + ] +}
{data.type === 'check' ? ( @@ -181,6 +182,7 @@ const Placement = forwardRef((props, refs) => { )}