From f912a8474edc3e31f80a9a16be17b8159b1c79f5 Mon Sep 17 00:00:00 2001 From: ysCha Date: Fri, 12 Sep 2025 16:45:53 +0900 Subject: [PATCH 1/3] fetching error: TypeError: Cannot read properties of undefined (reading 'planNo') at eval (useRoofAllocationSetting.js:179:26) at async fetchBasicSettings (useRoofAllocationSetting.js:112:7) --- .../ContextRoofAllocationSetting.jsx | 7 +++-- .../roofAllocation/RoofAllocationSetting.jsx | 6 ++--- .../roofcover/useRoofAllocationSetting.js | 26 +++++++++++++------ 3 files changed, 24 insertions(+), 15 deletions(-) diff --git a/src/components/floor-plan/modal/roofAllocation/ContextRoofAllocationSetting.jsx b/src/components/floor-plan/modal/roofAllocation/ContextRoofAllocationSetting.jsx index 2ce64be7..78597844 100644 --- a/src/components/floor-plan/modal/roofAllocation/ContextRoofAllocationSetting.jsx +++ b/src/components/floor-plan/modal/roofAllocation/ContextRoofAllocationSetting.jsx @@ -86,7 +86,7 @@ export default function ContextRoofAllocationSetting(props) { return (
- +
{pitchText} diff --git a/src/components/floor-plan/modal/roofAllocation/RoofAllocationSetting.jsx b/src/components/floor-plan/modal/roofAllocation/RoofAllocationSetting.jsx index 32364844..0e0e09ee 100644 --- a/src/components/floor-plan/modal/roofAllocation/RoofAllocationSetting.jsx +++ b/src/components/floor-plan/modal/roofAllocation/RoofAllocationSetting.jsx @@ -86,7 +86,7 @@ export default function RoofAllocationSetting(props) { return (
- +
@@ -212,7 +212,7 @@ export default function RoofAllocationSetting(props) { e.target.value = normalizeDecimalLimit(e.target.value, 2) handleChangePitch(e, index) }} - value={currentAngleType === 'slope' ? roof.pitch : roof.angle} + value={currentAngleType === 'slope' ? (roof.pitch ?? '') : (roof.angle ?? '')} />
{pitchText} diff --git a/src/hooks/roofcover/useRoofAllocationSetting.js b/src/hooks/roofcover/useRoofAllocationSetting.js index 3fef0ee9..e6d7a825 100644 --- a/src/hooks/roofcover/useRoofAllocationSetting.js +++ b/src/hooks/roofcover/useRoofAllocationSetting.js @@ -174,22 +174,32 @@ export function useRoofAllocationSetting(id) { }) } + const firstRes = Array.isArray(res) && res.length > 0 ? res[0] : null + setBasicSetting({ ...basicSetting, - planNo: res[0].planNo, - roofSizeSet: res[0].roofSizeSet, - roofAngleSet: res[0].roofAngleSet, + planNo: firstRes?.planNo ?? planNo, + roofSizeSet: firstRes?.roofSizeSet ?? 0, + roofAngleSet: firstRes?.roofAngleSet ?? 0, roofsData: roofsArray, selectedRoofMaterial: selectRoofs.find((roof) => roof.selected), }) setBasicInfo({ - planNo: '' + res[0].planNo, - roofSizeSet: '' + res[0].roofSizeSet, - roofAngleSet: '' + res[0].roofAngleSet, + planNo: '' + (firstRes?.planNo ?? planNo), + roofSizeSet: '' + (firstRes?.roofSizeSet ?? 0), + roofAngleSet: '' + (firstRes?.roofAngleSet ?? 0), }) - //데이터 동기화 - setCurrentRoofList(selectRoofs) + // 데이터 동기화: 렌더링용 필드 기본값 보정 + const normalizedRoofs = selectRoofs.map((roof) => ({ + ...roof, + width: roof.width ?? '', + length: roof.length ?? '', + hajebichi: roof.hajebichi ?? '', + pitch: roof.pitch ?? '', + angle: roof.angle ?? '', + })) + setCurrentRoofList(normalizedRoofs) }) } catch (error) { console.error('Data fetching error:', error) From 36d16069e08ee24d72c8b2e29f669439deefccec Mon Sep 17 00:00:00 2001 From: Cha Date: Sun, 14 Sep 2025 01:09:03 +0900 Subject: [PATCH 2/3] skeleton-utils --- src/util/skeleton-utils.js | 229 +++++++++++++++++++++++++++++++++++++ 1 file changed, 229 insertions(+) create mode 100644 src/util/skeleton-utils.js diff --git a/src/util/skeleton-utils.js b/src/util/skeleton-utils.js new file mode 100644 index 00000000..6ec643a4 --- /dev/null +++ b/src/util/skeleton-utils.js @@ -0,0 +1,229 @@ +/** + * @file skeleton-utils.js + * @description 지붕 폴리곤의 스켈레톤(중심선)을 생성하고 Fabric.js 캔버스에 그리는 유틸리티 함수들을 포함합니다. + */ + +import SkeletonBuilder from '@/lib/skeletons/SkeletonBuilder'; +import { fabric } from 'fabric'; +import { LINE_TYPE } from '@/common/common'; +import { QLine } from '@/components/fabric/QLine'; +import { calcLinePlaneSize } from '@/util/qpolygon-utils'; + +/** + * 지붕 폴리곤의 좌표를 스켈레톤 생성에 적합한 형태로 전처리합니다. + * - 연속된 중복 좌표를 제거합니다. + * - 폴리곤이 닫힌 형태가 되도록 마지막 좌표를 확인하고 필요시 제거합니다. + * - 좌표를 시계 방향으로 정렬합니다. + * @param {Array} initialPoints - 초기 폴리곤 좌표 배열 (e.g., [{x: 10, y: 10}, ...]) + * @returns {Array>} 전처리된 좌표 배열 (e.g., [[10, 10], ...]) + */ +const preprocessPolygonCoordinates = (initialPoints) => { + // fabric.Point 객체를 [x, y] 배열로 변환 + let coordinates = initialPoints.map(point => [point.x, point.y]); + + // 연속된 중복 좌표 제거 + coordinates = coordinates.filter((coord, index) => { + if (index === 0) return true; + const prev = coordinates[index - 1]; + return !(coord[0] === prev[0] && coord[1] === prev[1]); + }); + + // 폴리곤의 첫 점과 마지막 점이 동일하면 마지막 점을 제거하여 닫힌 구조 보장 + if (coordinates.length > 1 && + coordinates[0][0] === coordinates[coordinates.length - 1][0] && + coordinates[0][1] === coordinates[coordinates.length - 1][1]) { + coordinates.pop(); + } + + // SkeletonBuilder 라이브러리는 시계 방향 순서의 좌표를 요구하므로 배열을 뒤집습니다. + coordinates.reverse(); + + return coordinates; +}; + +/** + * 생성된 스켈레톤 엣지들로부터 중복되지 않는 고유한 선분 목록을 추출합니다. + * 각 엣지는 작은 폴리곤으로 구성되며, 인접한 엣지들은 선분을 공유하므로 중복 제거가 필요합니다. + * @param {Array} skeletonEdges - SkeletonBuilder로부터 반환된 엣지 배열 + * @returns {Array} 고유한 선분 객체의 배열 (e.g., [{x1, y1, x2, y2, edgeIndex}, ...]) + */ +const extractUniqueLinesFromEdges = (skeletonEdges) => { + const uniqueLines = new Set(); + const linesToDraw = []; + + skeletonEdges.forEach((edge, edgeIndex) => { + // 엣지 데이터가 유효한 폴리곤인지 확인 + if (!edge || !edge.Polygon || edge.Polygon.length < 2) { + console.warn(`Edge ${edgeIndex} has invalid polygon data:`, edge.Polygon); + return; + } + + // 폴리곤의 각 변을 선분으로 변환 + for (let i = 0; i < edge.Polygon.length; i++) { + const p1 = edge.Polygon[i]; + const p2 = edge.Polygon[(i + 1) % edge.Polygon.length]; // 다음 점 (마지막 점은 첫 점과 연결) + + // 선분의 시작점과 끝점을 일관된 순서로 정렬하여 정규화된 문자열 키 생성 + // 이를 통해 동일한 선분(방향만 다른 경우 포함)을 식별하고 중복을 방지 + const normalizedLineKey = p1.X < p2.X || (p1.X === p2.X && p1.Y < p2.Y) + ? `${p1.X},${p1.Y}-${p2.X},${p2.Y}` + : `${p2.X},${p2.Y}-${p1.X},${p1.Y}`; + + // Set에 정규화된 키가 없으면 새로운 선분으로 간주하고 추가 + if (!uniqueLines.has(normalizedLineKey)) { + uniqueLines.add(normalizedLineKey); + linesToDraw.push({ + x1: p1.X, y1: p1.Y, + x2: p2.X, y2: p2.Y, + edgeIndex + }); + } + } + }); + + return linesToDraw; +}; + +/** + * 지붕 폴리곤의 스켈레톤(중심선)을 생성하고 캔버스에 그립니다. + * @param {string} roofId - 대상 지붕 객체의 ID + * @param {fabric.Canvas} canvas - Fabric.js 캔버스 객체 + * @param {string} textMode - 텍스트 표시 모드 + */ +export const drawSkeletonRidgeRoof = (roofId, canvas, textMode) => { + try { + const roof = canvas?.getObjects().find((object) => object.id === roofId); + if (!roof) { + console.error(`Roof with id "${roofId}" not found.`); + return; + } + + // 1. 기존 스켈레톤 라인 제거 + const existingSkeletonLines = canvas.getObjects().filter(obj => + obj.parentId === roofId && obj.attributes?.type === 'skeleton' + ); + existingSkeletonLines.forEach(line => canvas.remove(line)); + + // 2. 지붕 폴리곤 좌표 전처리 + const coordinates = preprocessPolygonCoordinates(roof.points); + if (coordinates.length < 3) { + console.warn("Polygon has less than 3 unique points. Cannot generate skeleton."); + return; + } + + // 3. 스켈레톤 생성 + const multiPolygon = [[coordinates]]; // GeoJSON MultiPolygon 형식 + const skeleton = SkeletonBuilder.BuildFromGeoJSON(multiPolygon); + + if (!skeleton || !skeleton.Edges || skeleton.Edges.length === 0) { + console.log('No valid skeleton edges found for this roof.'); + return; + } + + // 4. 스켈레톤 엣지에서 고유 선분 추출 + const linesToDraw = extractUniqueLinesFromEdges(skeleton.Edges); + + // 5. 캔버스에 스켈레톤 라인 렌더링 + const skeletonLines = []; + const outerLines = pointsToLines(coordinates); + + linesToDraw.forEach((line, index) => { + // 외곽선과 겹치는 스켈레톤 라인은 그리지 않음 + const isOverlapping = outerLines.some(outerLine => linesOverlap(line, outerLine)); + if (isOverlapping) { + console.log(`Skeleton line (edge ${line.edgeIndex}) is overlapping with a baseLine. It will not be drawn.`); + return; + } + + const isDiagonal = Math.abs(line.x1 - line.x2) > 1e-6 && Math.abs(line.y1 - line.y2) > 1e-6; + + const skeletonLine = new QLine([line.x1, line.y1, line.x2, line.y2], { + parentId: roofId, + stroke: '#ff0000', // 스켈레톤은 빨간색으로 표시 + strokeWidth: 2, + strokeDashArray: [3, 3], // 점선으로 표시 + name: isDiagonal ? LINE_TYPE.SUBLINE.HIP : LINE_TYPE.SUBLINE.RIDGE, + fontSize: roof.fontSize || 12, + textMode: textMode, + attributes: { + roofId: roofId, + type: 'skeleton', // 스켈레톤 타입 식별자 + skeletonIndex: line.edgeIndex, + lineIndex: index, + planeSize: calcLinePlaneSize(line), + actualSize: calcLinePlaneSize(line), // 실제 크기는 필요시 별도 계산 로직 추가 + }, + }); + + skeletonLine.startPoint = { x: line.x1, y: line.y1 }; + skeletonLine.endPoint = { x: line.x2, y: line.y2 }; + + skeletonLines.push(skeletonLine); + canvas.add(skeletonLine); + }); + + // 6. roof 객체에 스켈레톤 라인 정보 업데이트 + roof.innerLines = [...(roof.innerLines || []), ...skeletonLines]; + skeletonLines.forEach(line => line.bringToFront()); + + canvas.renderAll(); + console.log(`Successfully drew ${linesToDraw.length} unique skeleton lines from ${skeleton.Edges.length} polygons.`); + + } catch (error) { + console.error('An error occurred while generating the skeleton:', error); + } +}; + +/** + * 두 선분이 동일 선상에 있으면서 서로 겹치는지 확인합니다. + * @param {object} line1 - 첫 번째 선분 객체 { x1, y1, x2, y2 } + * @param {object} line2 - 두 번째 선분 객체 { x1, y1, x2, y2 } + * @param {number} [epsilon=1e-6] - 부동 소수점 계산 오차 허용 범위 + * @returns {boolean} 동일 선상에서 겹치면 true, 아니면 false + */ +function linesOverlap(line1, line2, epsilon = 1e-6) { + // 1. 세 점의 면적 계산을 통해 동일 선상에 있는지 확인 (면적이 0에 가까우면 동일 선상) + const area1 = (line1.x2 - line1.x1) * (line2.y1 - line1.y1) - (line2.x1 - line1.x1) * (line1.y2 - line1.y1); + const area2 = (line1.x2 - line1.x1) * (line2.y2 - line1.y1) - (line2.x2 - line1.x1) * (line1.y2 - line1.y1); + + if (Math.abs(area1) > epsilon || Math.abs(area2) > epsilon) { + return false; // 동일 선상에 없음 + } + + // 2. 동일 선상에 있을 경우, 두 선분의 범위(x, y)가 겹치는지 확인 + const xOverlap = Math.max(line1.x1, line1.x2) >= Math.min(line2.x1, line2.x2) && + Math.max(line2.x1, line2.x2) >= Math.min(line1.x1, line1.x2); + + const yOverlap = Math.max(line1.y1, line1.y2) >= Math.min(line2.y1, line2.y2) && + Math.max(line2.y1, line2.y2) >= Math.min(line1.y1, line1.y2); + + return xOverlap && yOverlap; +} + +/** + * 점들의 배열을 닫힌 폴리곤을 구성하는 선분 객체의 배열로 변환합니다. + * @param {Array>} points - [x, y] 형태의 점 좌표 배열 + * @returns {Array<{x1: number, y1: number, x2: number, y2: number}>} 선분 객체 배열 + */ +function pointsToLines(points) { + if (!points || points.length < 2) { + return []; + } + + const lines = []; + const numPoints = points.length; + + for (let i = 0; i < numPoints; i++) { + const startPoint = points[i]; + const endPoint = points[(i + 1) % numPoints]; // 마지막 점은 첫 번째 점과 연결 + + lines.push({ + x1: startPoint[0], + y1: startPoint[1], + x2: endPoint[0], + y2: endPoint[1], + }); + } + + return lines; +} From 7d9b6d5225ed3f297cb5ce5d91ae700c4d278593 Mon Sep 17 00:00:00 2001 From: "hyojun.choi" Date: Mon, 15 Sep 2025 14:55:04 +0900 Subject: [PATCH 3/3] =?UTF-8?q?object=20=ED=9A=8C=EC=A0=84=20=EA=B8=B0?= =?UTF-8?q?=EB=8A=A5=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/common/common.js | 2 + src/components/fabric/QPolygon.js | 6 + .../floor-plan/modal/object/SizeSetting.jsx | 10 +- src/hooks/surface/useSurfaceShapeBatch.js | 236 +++++++++++++----- src/hooks/useContextMenu.js | 17 +- src/locales/ja.json | 1 + src/locales/ko.json | 1 + 7 files changed, 203 insertions(+), 70 deletions(-) diff --git a/src/common/common.js b/src/common/common.js index 9d15e988..757dc0e8 100644 --- a/src/common/common.js +++ b/src/common/common.js @@ -216,6 +216,8 @@ export const SAVE_KEY = [ 'isMultipleOf45', 'from', 'originColor', + 'originWidth', + 'originHeight', ] export const OBJECT_PROTOTYPE = [fabric.Line.prototype, fabric.Polygon.prototype, fabric.Triangle.prototype, fabric.Group.prototype] diff --git a/src/components/fabric/QPolygon.js b/src/components/fabric/QPolygon.js index b50cdaa3..73bd6fd9 100644 --- a/src/components/fabric/QPolygon.js +++ b/src/components/fabric/QPolygon.js @@ -87,6 +87,10 @@ export const QPolygon = fabric.util.createClass(fabric.Polygon, { this.initLines() this.init() this.setShape() + const originWidth = this.originWidth ?? this.width + const originHeight = this.originHeight ?? this.height + this.originWidth = this.angle === 90 || this.angle === 270 ? originHeight : originWidth + this.originHeight = this.angle === 90 || this.angle === 270 ? originWidth : originHeight }, setShape() { @@ -126,11 +130,13 @@ export const QPolygon = fabric.util.createClass(fabric.Polygon, { this.on('moving', () => { this.initLines() this.addLengthText() + this.setCoords() }) this.on('modified', (e) => { this.initLines() this.addLengthText() + this.setCoords() }) this.on('selected', () => { diff --git a/src/components/floor-plan/modal/object/SizeSetting.jsx b/src/components/floor-plan/modal/object/SizeSetting.jsx index c5873006..b4a7d3e7 100644 --- a/src/components/floor-plan/modal/object/SizeSetting.jsx +++ b/src/components/floor-plan/modal/object/SizeSetting.jsx @@ -12,7 +12,7 @@ import { useSurfaceShapeBatch } from '@/hooks/surface/useSurfaceShapeBatch' export default function SizeSetting(props) { const contextPopupPosition = useRecoilValue(contextPopupPositionState) - const [settingTarget, setSettingTarget] = useState(1) + const [settingTarget, setSettingTarget] = useState(props.side || 1) const { id, pos = contextPopupPosition, target } = props const { getMessage } = useMessage() const { closePopup } = usePopup() @@ -47,11 +47,11 @@ export default function SizeSetting(props) {
- + mm
- + mm
@@ -60,11 +60,11 @@ export default function SizeSetting(props) {
- + mm
- + mm
diff --git a/src/hooks/surface/useSurfaceShapeBatch.js b/src/hooks/surface/useSurfaceShapeBatch.js index 874526f8..1d0445d8 100644 --- a/src/hooks/surface/useSurfaceShapeBatch.js +++ b/src/hooks/surface/useSurfaceShapeBatch.js @@ -1,9 +1,9 @@ 'use client' import { useRecoilValue, useResetRecoilState } from 'recoil' -import { canvasSettingState, canvasState, currentCanvasPlanState, globalPitchState } from '@/store/canvasAtom' +import { canvasSettingState, canvasState, currentCanvasPlanState, currentObjectState, globalPitchState } from '@/store/canvasAtom' import { LINE_TYPE, MENU, POLYGON_TYPE } from '@/common/common' -import { getIntersectionPoint, toFixedWithoutRounding } from '@/util/canvas-util' +import { getIntersectionPoint } from '@/util/canvas-util' import { degreesToRadians } from '@turf/turf' import { QPolygon } from '@/components/fabric/QPolygon' import { useSwal } from '@/hooks/useSwal' @@ -21,10 +21,13 @@ import { placementShapeDrawingPointsState } from '@/store/placementShapeDrawingA import { getBackGroundImage } from '@/lib/imageActions' import { useCanvasSetting } from '@/hooks/option/useCanvasSetting' import { useText } from '@/hooks/useText' +import SizeSetting from '@/components/floor-plan/modal/object/SizeSetting' +import { v4 as uuidv4 } from 'uuid' +import { useState } from 'react' export function useSurfaceShapeBatch({ isHidden, setIsHidden }) { const { getMessage } = useMessage() - const { drawDirectionArrow, addPolygon, addLengthText } = usePolygon() + const { drawDirectionArrow, addPolygon, addLengthText, setPolygonLinesActualSize } = usePolygon() const lengthTextFont = useRecoilValue(fontSelector('lengthText')) const resetOuterLinePoints = useResetRecoilState(outerLinePointsState) const resetPlacementShapeDrawingPoints = useResetRecoilState(placementShapeDrawingPointsState) @@ -36,11 +39,13 @@ export function useSurfaceShapeBatch({ isHidden, setIsHidden }) { const { swalFire } = useSwal() const { addCanvasMouseEventListener, initEvent } = useEvent() // const { addCanvasMouseEventListener, initEvent } = useContext(EventContext) - const { addPopup, closePopup } = usePopup() + const { addPopup, closePopup, closeAll } = usePopup() const { setSurfaceShapePattern } = useRoofFn() const { changeCorridorDimensionText } = useText() const currentCanvasPlan = useRecoilValue(currentCanvasPlanState) const { fetchSettings } = useCanvasSetting(false) + const currentObject = useRecoilValue(currentObjectState) + const [popupId, setPopupId] = useState(uuidv4()) const applySurfaceShape = (surfaceRefs, selectedType, id) => { let length1, length2, length3, length4, length5 @@ -879,6 +884,7 @@ export function useSurfaceShapeBatch({ isHidden, setIsHidden }) { drawDirectionArrow(roof) changeCorridorDimensionText() addLengthText(roof) + roof.setCoords() initEvent() canvas.renderAll() }) @@ -916,71 +922,138 @@ export function useSurfaceShapeBatch({ isHidden, setIsHidden }) { } const resizeSurfaceShapeBatch = (side, target, width, height) => { - const originTarget = { ...target } + if (!target || target.type !== 'QPolygon') return - const objectWidth = target.width - const objectHeight = target.height - const changeWidth = width / 10 / objectWidth - const changeHeight = height / 10 / objectHeight - let sideX = 'left' - let sideY = 'top' + width = width / 10 + height = height / 10 - //그룹 중심점 변경 - if (side === 2) { - sideX = 'right' - sideY = 'top' - } else if (side === 3) { - sideX = 'left' - sideY = 'bottom' - } else if (side === 4) { - sideX = 'right' - sideY = 'bottom' + // 현재 QPolygon의 점들 가져오기 (변형 적용된 실제 좌표) + const currentPoints = target.getCurrentPoints() || [] + const angle = target.angle % 360 + if (currentPoints.length === 0) return + + // 현재 바운딩 박스 계산 + let minX = Math.min(...currentPoints.map((p) => p.x)) + let maxX = Math.max(...currentPoints.map((p) => p.x)) + let minY = Math.min(...currentPoints.map((p) => p.y)) + let maxY = Math.max(...currentPoints.map((p) => p.y)) + + let currentWidth = maxX - minX + let currentHeight = maxY - minY + + // 회전에 관계없이 단순한 앵커 포인트 계산 + let anchorX, anchorY + switch (side) { + case 1: // left-top + anchorX = minX + anchorY = minY + break + case 2: // right-top + anchorX = maxX + anchorY = minY + break + case 3: // left-bottom + anchorX = minX + anchorY = maxY + break + case 4: // right-bottom + anchorX = maxX + anchorY = maxY + break + default: + return } - //변경 전 좌표 - const newCoords = target.getPointByOrigin(sideX, sideY) + // 목표 크기 (회전에 관계없이 동일하게 적용) + let targetWidth = width + let targetHeight = height - target.set({ - originX: sideX, - originY: sideY, - left: newCoords.x, - top: newCoords.y, + // 새로운 점들 계산 - 앵커 포인트는 고정, 나머지는 비례적으로 확장 + // 각도와 side에 따라 확장 방향 결정 + const newPoints = currentPoints.map((point) => { + // 앵커 포인트 기준으로 새로운 위치 계산 + // side와 각도에 관계없이 일관된 방식으로 처리 + + // 앵커 포인트에서 각 점까지의 절대 거리 + const deltaX = point.x - anchorX + const deltaY = point.y - anchorY + + // 새로운 크기에 맞춰 비례적으로 확장 + const newDeltaX = (deltaX / currentWidth) * targetWidth + const newDeltaY = (deltaY / currentHeight) * targetHeight + + const newX = anchorX + newDeltaX + const newY = anchorY + newDeltaY + + return { + x: newX, // 소수점 1자리로 반올림 + y: newY, + } }) - target.scaleX = changeWidth - target.scaleY = changeHeight - - const currentPoints = target.getCurrentPoints() - - target.set({ + // 기존 객체의 속성들을 복사 (scale은 1로 고정) + const originalOptions = { + stroke: target.stroke, + strokeWidth: target.strokeWidth, + fill: target.fill, + opacity: target.opacity, + visible: target.visible, + selectable: target.selectable, + evented: target.evented, + hoverCursor: target.hoverCursor, + moveCursor: target.moveCursor, + lockMovementX: target.lockMovementX, + lockMovementY: target.lockMovementY, + lockRotation: target.lockRotation, + lockScalingX: target.lockScalingX, + lockScalingY: target.lockScalingY, + lockUniScaling: target.lockUniScaling, + name: target.name, + uuid: target.uuid, + roofType: target.roofType, + roofMaterial: target.roofMaterial, + azimuth: target.azimuth, + // tilt: target.tilt, + // angle: target.angle, + // scale은 항상 1로 고정 scaleX: 1, scaleY: 1, - width: toFixedWithoutRounding(width / 10, 1), - height: toFixedWithoutRounding(height / 10, 1), - }) - //크기 변경후 좌표를 재 적용 - const changedCoords = target.getPointByOrigin(originTarget.originX, originTarget.originY) - - target.set({ - originX: originTarget.originX, - originY: originTarget.originY, - left: changedCoords.x, - top: changedCoords.y, - }) - canvas.renderAll() - - //면형상 리사이즈시에만 - target.fire('polygonMoved') - target.points = currentPoints - target.fire('modified') - - setSurfaceShapePattern(target, roofDisplay.column, false, target.roofMaterial, true) - - if (target.direction) { - drawDirectionArrow(target) + lines: target.lines, + // 기타 모든 사용자 정의 속성들 + ...Object.fromEntries( + Object.entries(target).filter( + ([key, value]) => + !['type', 'left', 'top', 'width', 'height', 'scaleX', 'scaleY', 'points', 'lines', 'texts', 'canvas', 'angle', 'tilt'].includes(key) && + typeof value !== 'function', + ), + ), } - target.setCoords() - canvas.renderAll() + + // 기존 QPolygon 제거 + canvas.remove(target) + + // 새로운 QPolygon 생성 (scale은 1로 고정됨) + const newPolygon = new QPolygon(newPoints, originalOptions, canvas) + + newPolygon.set({ + originWidth: width, + originHeight: height, + }) + + // 캔버스에 추가 + canvas.add(newPolygon) + + // 선택 상태 유지 + canvas.setActiveObject(newPolygon) + + newPolygon.fire('modified') + setSurfaceShapePattern(newPolygon, null, null, newPolygon.roofMaterial) + drawDirectionArrow(newPolygon) + newPolygon.setCoords() + changeSurfaceLineType(newPolygon) + canvas?.renderAll() + closeAll() + addPopup(popupId, 1, ) } const changeSurfaceLinePropertyEvent = () => { @@ -1364,6 +1437,50 @@ export function useSurfaceShapeBatch({ isHidden, setIsHidden }) { return orderedPoints } + const rotateSurfaceShapeBatch = () => { + if (currentObject) { + // 기존 관련 객체들 제거 + const relatedObjects = canvas + .getObjects() + .filter( + (obj) => + obj.parentId === currentObject.id || + (obj.name === 'lengthText' && obj.parentId === currentObject.id) || + (obj.name === 'arrow' && obj.parentId === currentObject.id), + ) + relatedObjects.forEach((obj) => canvas.remove(obj)) + + // 현재 회전값에 90도 추가 + const currentAngle = currentObject.angle || 0 + const newAngle = (currentAngle + 90) % 360 + const originWidth = currentObject.originWidth + const originHeight = currentObject.originHeight + // 회전 적용 (width/height 교체 제거로 도형 깨짐 방지) + currentObject.rotate(newAngle) + + // QPolygon 내부 구조 재구성 (선이 깨지는 문제 해결) + if (currentObject.type === 'QPolygon' && currentObject.lines) { + currentObject.initLines() + } + + currentObject.set({ + originWidth: originHeight, + originHeight: originWidth, + }) + + currentObject.setCoords() + currentObject.fire('modified') + + // 화살표와 선 다시 그리기 + drawDirectionArrow(currentObject) + setTimeout(() => { + setPolygonLinesActualSize(currentObject) + changeSurfaceLineType(currentObject) + }, 200) + canvas.renderAll() + } + } + return { applySurfaceShape, deleteAllSurfacesAndObjects, @@ -1373,5 +1490,6 @@ export function useSurfaceShapeBatch({ isHidden, setIsHidden }) { changeSurfaceLineProperty, changeSurfaceLinePropertyReset, changeSurfaceLineType, + rotateSurfaceShapeBatch, } } diff --git a/src/hooks/useContextMenu.js b/src/hooks/useContextMenu.js index fe6d800e..4580f3ee 100644 --- a/src/hooks/useContextMenu.js +++ b/src/hooks/useContextMenu.js @@ -1,7 +1,6 @@ import { useRecoilState, useRecoilValue, useSetRecoilState } from 'recoil' import { canvasSettingState, canvasState, currentMenuState, currentObjectState } from '@/store/canvasAtom' import { useEffect, useState } from 'react' -import { MENU, POLYGON_TYPE } from '@/common/common' import AuxiliarySize from '@/components/floor-plan/modal/auxiliary/AuxiliarySize' import { usePopup } from '@/hooks/usePopup' import { v4 as uuidv4 } from 'uuid' @@ -12,11 +11,9 @@ import { gridColorState } from '@/store/gridAtom' import { contextPopupPositionState, contextPopupState } from '@/store/popupAtom' import AuxiliaryEdit from '@/components/floor-plan/modal/auxiliary/AuxiliaryEdit' import SizeSetting from '@/components/floor-plan/modal/object/SizeSetting' -import RoofMaterialSetting from '@/components/floor-plan/modal/object/RoofMaterialSetting' import DormerOffset from '@/components/floor-plan/modal/object/DormerOffset' import FontSetting from '@/components/common/font/FontSetting' import RoofAllocationSetting from '@/components/floor-plan/modal/roofAllocation/RoofAllocationSetting' -import LinePropertySetting from '@/components/floor-plan/modal/lineProperty/LinePropertySetting' import FlowDirectionSetting from '@/components/floor-plan/modal/flowDirection/FlowDirectionSetting' import { useCommonUtils } from './common/useCommonUtils' @@ -29,7 +26,6 @@ import ColumnRemove from '@/components/floor-plan/modal/module/column/ColumnRemo import ColumnInsert from '@/components/floor-plan/modal/module/column/ColumnInsert' import RowRemove from '@/components/floor-plan/modal/module/row/RowRemove' import RowInsert from '@/components/floor-plan/modal/module/row/RowInsert' -import CircuitNumberEdit from '@/components/floor-plan/modal/module/CircuitNumberEdit' import { useObjectBatch } from '@/hooks/object/useObjectBatch' import { useSurfaceShapeBatch } from '@/hooks/surface/useSurfaceShapeBatch' import { fontSelector, globalFontAtom } from '@/store/fontAtom' @@ -45,6 +41,8 @@ import PlacementSurfaceLineProperty from '@/components/floor-plan/modal/placemen import { selectedMenuState } from '@/store/menuAtom' import { useTrestle } from './module/useTrestle' import { useCircuitTrestle } from './useCirCuitTrestle' +import { usePolygon } from '@/hooks/usePolygon' +import { useText } from '@/hooks/useText' export function useContextMenu() { const canvas = useRecoilValue(canvasState) @@ -64,7 +62,7 @@ export function useContextMenu() { const [column, setColumn] = useState(null) const { handleZoomClear } = useCanvasEvent() const { moveObjectBatch, copyObjectBatch } = useObjectBatch({}) - const { moveSurfaceShapeBatch } = useSurfaceShapeBatch({}) + const { moveSurfaceShapeBatch, rotateSurfaceShapeBatch } = useSurfaceShapeBatch({}) const [globalFont, setGlobalFont] = useRecoilState(globalFontAtom) const { addLine, removeLine } = useLine() const { removeGrid } = useGrid() @@ -73,10 +71,12 @@ export function useContextMenu() { const { settingsData, setSettingsDataSave } = useCanvasSetting(false) const { swalFire } = useSwal() const { alignModule, modulesRemove, moduleRoofRemove } = useModule() - const { removeRoofMaterial, removeAllRoofMaterial, moveRoofMaterial, removeOuterLines } = useRoofFn() + const { removeRoofMaterial, removeAllRoofMaterial, moveRoofMaterial, removeOuterLines, setSurfaceShapePattern } = useRoofFn() const selectedMenu = useRecoilValue(selectedMenuState) const { isAllComplete, clear: resetModule } = useTrestle() const { isExistCircuit } = useCircuitTrestle() + const { changeCorridorDimensionText } = useText() + const { setPolygonLinesActualSize, drawDirectionArrow } = usePolygon() const currentMenuSetting = () => { switch (selectedMenu) { case 'outline': @@ -170,6 +170,11 @@ export function useContextMenu() { name: getMessage('contextmenu.size.edit'), component: , }, + { + id: 'rotate', + name: `${getMessage('contextmenu.rotate')}`, + fn: () => rotateSurfaceShapeBatch(), + }, { id: 'roofMaterialRemove', shortcut: ['d', 'D'], diff --git a/src/locales/ja.json b/src/locales/ja.json index ac682266..2999728f 100644 --- a/src/locales/ja.json +++ b/src/locales/ja.json @@ -446,6 +446,7 @@ "contextmenu.remove": "削除", "contextmenu.remove.all": "完全削除", "contextmenu.move": "移動", + "contextmenu.rotate": "回転", "contextmenu.copy": "コピー", "contextmenu.edit": "編集", "contextmenu.module.vertical.align": "モジュールの垂直方向の中央揃え", diff --git a/src/locales/ko.json b/src/locales/ko.json index 0559b28e..4f6601cd 100644 --- a/src/locales/ko.json +++ b/src/locales/ko.json @@ -446,6 +446,7 @@ "contextmenu.remove": "삭제", "contextmenu.remove.all": "전체 삭제", "contextmenu.move": "이동", + "contextmenu.rotate": "회전", "contextmenu.copy": "복사", "contextmenu.edit": "편집", "contextmenu.module.vertical.align": "모듈 세로 가운데 정렬",