diff --git a/B07_DesignDetail/openwebcad/src/tools/copy-tool.ts b/B07_DesignDetail/openwebcad/src/tools/copy-tool.ts index 73b6811fb..25b7d8542 100644 --- a/B07_DesignDetail/openwebcad/src/tools/copy-tool.ts +++ b/B07_DesignDetail/openwebcad/src/tools/copy-tool.ts @@ -10,7 +10,8 @@ import { setShouldDrawHelpers, } from '../state'; import { Tool } from '../tools'; -import type { DrawEvent, MouseClickEvent, StateEvent, ToolContext } from './tool.types'; +import type { DrawEvent, PointInputEvent, StateEvent, ToolContext } from './tool.types'; +import { getPointFromEvent } from '../helpers/get-point-from-event'; import { assign, createMachine, sendTo } from 'xstate'; import { selectToolStateMachine } from './select-tool'; import type { Entity } from '../entities/Entity'; @@ -147,6 +148,10 @@ export const copyToolStateMachine = createMachine( actions: [CopyAction.RECORD_START_POINT, CopyAction.COPY_SELECTION_BEFORE_COPY], target: CopyState.WAITING_FOR_END_COPY_POINT, }, + ABSOLUTE_POINT_INPUT: { + actions: [CopyAction.RECORD_START_POINT, CopyAction.COPY_SELECTION_BEFORE_COPY], + target: CopyState.WAITING_FOR_END_COPY_POINT, + }, ESC: { actions: CopyAction.DESELECT_ENTITIES, target: CopyState.INIT, @@ -166,6 +171,18 @@ export const copyToolStateMachine = createMachine( actions: [CopyAction.COPY_SELECTION], target: CopyState.WAITING_FOR_END_COPY_POINT, }, + NUMBER_INPUT: { + actions: [CopyAction.COPY_SELECTION], + target: CopyState.WAITING_FOR_END_COPY_POINT, + }, + ABSOLUTE_POINT_INPUT: { + actions: [CopyAction.COPY_SELECTION], + target: CopyState.WAITING_FOR_END_COPY_POINT, + }, + RELATIVE_POINT_INPUT: { + actions: [CopyAction.COPY_SELECTION], + target: CopyState.WAITING_FOR_END_COPY_POINT, + }, ESC: { actions: CopyAction.DESELECT_ENTITIES, target: CopyState.INIT, @@ -191,10 +208,9 @@ export const copyToolStateMachine = createMachine( setShouldDrawHelpers(true); }, [CopyAction.RECORD_START_POINT]: assign(({ event }) => { - setAngleGuideOriginPoint((event as MouseClickEvent).worldMouseLocation); - return { - startPoint: (event as MouseClickEvent).worldMouseLocation, - }; + const startPoint = getPointFromEvent(null, event as PointInputEvent); + setAngleGuideOriginPoint(startPoint); + return { startPoint }; }), [CopyAction.COPY_SELECTION_BEFORE_COPY]: assign(({ context }) => { const selectedEntities = getSelectedEntities(); @@ -208,7 +224,9 @@ export const copyToolStateMachine = createMachine( return { startPoint: context.startPoint, // Make a copy of the selected entities before copying them, so we can restore them when the user cancels the copy action - originalSelectedEntities: compact(selectedEntities.map((entity) => cloneKeepingLook(entity))), + originalSelectedEntities: compact( + selectedEntities.map((entity) => cloneKeepingLook(entity)) + ), copiedEntities: selectedEntities, }; }), @@ -217,11 +235,13 @@ export const copyToolStateMachine = createMachine( throw new Error('[COPY] Calling draw temp copy line without a start point'); } - const endPointTemp = (event as DrawEvent).drawController.getWorldMouseLocation(); + const endPointTemp = getPointFromEvent(context.startPoint, event as DrawEvent); // Copy the entities to the new location // Draw all selected entities according to translation vector, so the user gets visual feedback of where the entities will be copied; - const movedEntities = context.originalSelectedEntities.map((entity) => cloneKeepingLook(entity)); + const movedEntities = context.originalSelectedEntities.map((entity) => + cloneKeepingLook(entity) + ); moveEntities( movedEntities, endPointTemp.x - context.startPoint.x, @@ -245,8 +265,11 @@ export const copyToolStateMachine = createMachine( } // Copy the entities one final time - const currentEndPoint = (event as MouseClickEvent).worldMouseLocation; - const copiedEntities = context.originalSelectedEntities.map((entity) => cloneKeepingLook(entity)); + // 클릭 · 거리 · 절대 · 상대 좌표(키보드) + const currentEndPoint = getPointFromEvent(context.startPoint, event as PointInputEvent); + const copiedEntities = context.originalSelectedEntities.map((entity) => + cloneKeepingLook(entity) + ); moveEntities( copiedEntities, currentEndPoint.x - context.startPoint.x, diff --git a/B07_DesignDetail/openwebcad/src/tools/move-tool.ts b/B07_DesignDetail/openwebcad/src/tools/move-tool.ts index ba19c7073..a2fe3cbfd 100644 --- a/B07_DesignDetail/openwebcad/src/tools/move-tool.ts +++ b/B07_DesignDetail/openwebcad/src/tools/move-tool.ts @@ -11,7 +11,8 @@ import { setShouldDrawHelpers, } from '../state'; import { Tool } from '../tools'; -import type { DrawEvent, MouseClickEvent, StateEvent, ToolContext } from './tool.types'; +import type { DrawEvent, PointInputEvent, StateEvent, ToolContext } from './tool.types'; +import { getPointFromEvent } from '../helpers/get-point-from-event'; import { assign, createMachine, sendTo } from 'xstate'; import { selectToolStateMachine } from './select-tool'; import type { Entity } from '../entities/Entity'; @@ -148,6 +149,10 @@ export const moveToolStateMachine = createMachine( actions: [MoveAction.RECORD_START_POINT, MoveAction.COPY_SELECTION_BEFORE_MOVE], target: MoveState.WAITING_FOR_END_MOVE_POINT, }, + ABSOLUTE_POINT_INPUT: { + actions: [MoveAction.RECORD_START_POINT, MoveAction.COPY_SELECTION_BEFORE_MOVE], + target: MoveState.WAITING_FOR_END_MOVE_POINT, + }, ESC: { actions: MoveAction.DESELECT_ENTITIES, target: MoveState.INIT, @@ -167,6 +172,18 @@ export const moveToolStateMachine = createMachine( actions: [MoveAction.MOVE_SELECTION, MoveAction.DESELECT_ENTITIES], target: MoveState.WAITING_FOR_SELECTION, }, + NUMBER_INPUT: { + actions: [MoveAction.MOVE_SELECTION, MoveAction.DESELECT_ENTITIES], + target: MoveState.WAITING_FOR_SELECTION, + }, + ABSOLUTE_POINT_INPUT: { + actions: [MoveAction.MOVE_SELECTION, MoveAction.DESELECT_ENTITIES], + target: MoveState.WAITING_FOR_SELECTION, + }, + RELATIVE_POINT_INPUT: { + actions: [MoveAction.MOVE_SELECTION, MoveAction.DESELECT_ENTITIES], + target: MoveState.WAITING_FOR_SELECTION, + }, ESC: { actions: MoveAction.RESTORE_ORIGINAL_ENTITIES, target: MoveState.INIT, @@ -192,10 +209,9 @@ export const moveToolStateMachine = createMachine( setShouldDrawHelpers(true); }, [MoveAction.RECORD_START_POINT]: assign(({ event }) => { - setAngleGuideOriginPoint((event as MouseClickEvent).worldMouseLocation); - return { - startPoint: (event as MouseClickEvent).worldMouseLocation, - }; + const startPoint = getPointFromEvent(null, event as PointInputEvent); + setAngleGuideOriginPoint(startPoint); + return { startPoint }; }), [MoveAction.COPY_SELECTION_BEFORE_MOVE]: assign(({ context }) => { const selectedEntities = getSelectedEntities(); @@ -211,7 +227,9 @@ export const moveToolStateMachine = createMachine( return { startPoint: context.startPoint, // Make a copy of the selected entities before moving them, so we can restore them when the user cancels the move action - originalSelectedEntities: compact(selectedEntities.map((entity) => cloneKeepingLook(entity, true))), + originalSelectedEntities: compact( + selectedEntities.map((entity) => cloneKeepingLook(entity, true)) + ), movedEntities: selectedEntities, }; }), @@ -220,11 +238,13 @@ export const moveToolStateMachine = createMachine( throw new Error('[MOVE] Calling draw temp move line without a start point'); } - const endPointTemp = (event as DrawEvent).drawController.getWorldMouseLocation(); + const endPointTemp = getPointFromEvent(context.startPoint, event as DrawEvent); // Move the entities to the new location // Draw all selected entities according to translation vector, so the user gets visual feedback of where the entities will be moved; - const movedEntities = context.originalSelectedEntities.map((entity) => cloneKeepingLook(entity, true)); + const movedEntities = context.originalSelectedEntities.map((entity) => + cloneKeepingLook(entity, true) + ); moveEntities( movedEntities, endPointTemp.x - context.startPoint.x, @@ -248,7 +268,8 @@ export const moveToolStateMachine = createMachine( } // Move the entities one final time - const currentEndPoint = (event as MouseClickEvent).worldMouseLocation; + // 클릭 · 거리 · 절대 · 상대 좌표(키보드) + const currentEndPoint = getPointFromEvent(context.startPoint, event as PointInputEvent); moveEntities( context.originalSelectedEntities, currentEndPoint.x - context.startPoint.x, diff --git a/B07_DesignDetail/openwebcad/src/tools/rotate-tool.ts b/B07_DesignDetail/openwebcad/src/tools/rotate-tool.ts index 46888d7a5..c2930e017 100644 --- a/B07_DesignDetail/openwebcad/src/tools/rotate-tool.ts +++ b/B07_DesignDetail/openwebcad/src/tools/rotate-tool.ts @@ -10,7 +10,15 @@ import { setShouldDrawHelpers, } from '../state'; import { Tool } from '../tools'; -import type { DrawEvent, MouseClickEvent, StateEvent, ToolContext } from './tool.types'; +import type { + DrawEvent, + MouseClickEvent, + NumberInputEvent, + PointInputEvent, + StateEvent, + ToolContext, +} from './tool.types'; +import { getPointFromEvent } from '../helpers/get-point-from-event'; import { assign, createMachine, sendTo } from 'xstate'; import { selectToolStateMachine } from './select-tool'; import type { Entity } from '../entities/Entity'; @@ -41,6 +49,7 @@ export enum RotateAction { COPY_SELECTION_BEFORE_ROTATE = 'COPY_SELECTION_BEFORE_ROTATE', DRAW_TEMP_ROTATE_ENTITIES = 'DRAW_TEMP_ROTATE_ENTITIES', ROTATE_SELECTION = 'ROTATE_SELECTION', + ROTATE_BY_ANGLE = 'ROTATE_BY_ANGLE', DESELECT_ENTITIES = 'DESELECT_ENTITIES', RESTORE_ORIGINAL_ENTITIES = 'RESTORE_ORIGINAL_ENTITIES', } @@ -147,6 +156,10 @@ export const rotateToolStateMachine = createMachine( actions: [RotateAction.RECORD_ROTATION_ORIGIN], target: RotateState.WAITING_FOR_ANGLE_START_POINT, }, + ABSOLUTE_POINT_INPUT: { + actions: [RotateAction.RECORD_ROTATION_ORIGIN], + target: RotateState.WAITING_FOR_ANGLE_START_POINT, + }, ESC: { actions: RotateAction.DESELECT_ENTITIES, target: RotateState.INIT, @@ -166,6 +179,15 @@ export const rotateToolStateMachine = createMachine( ], target: RotateState.WAITING_FOR_ANGLE_END_POINT, }, + NUMBER_INPUT: { + // 각도(도) 바로 — 반시계 + + actions: [ + RotateAction.COPY_SELECTION_BEFORE_ROTATE, + RotateAction.ROTATE_BY_ANGLE, + RotateAction.DESELECT_ENTITIES, + ], + target: RotateState.WAITING_FOR_SELECTION, + }, ESC: { actions: RotateAction.RESTORE_ORIGINAL_ENTITIES, target: RotateState.INIT, @@ -204,11 +226,9 @@ export const rotateToolStateMachine = createMachine( setShouldDrawHelpers(true); }, [RotateAction.RECORD_ROTATION_ORIGIN]: assign(({ context, event }): RotateContext => { - setAngleGuideOriginPoint((event as MouseClickEvent).worldMouseLocation); - return { - ...context, - rotationOrigin: (event as MouseClickEvent).worldMouseLocation, - }; + const rotationOrigin = getPointFromEvent(null, event as PointInputEvent); + setAngleGuideOriginPoint(rotationOrigin); + return { ...context, rotationOrigin }; }), [RotateAction.RECORD_ROTATION_ANGLE_START_POINT]: assign( ({ context, event }): RotateContext => { @@ -232,7 +252,9 @@ export const rotateToolStateMachine = createMachine( return { ...context, // Make a copy of the selected entities before rotating them, so we can restore them when the user cancels the rotate action - originalSelectedEntities: compact(selectedEntities.map((entity) => cloneKeepingLook(entity, true))), + originalSelectedEntities: compact( + selectedEntities.map((entity) => cloneKeepingLook(entity, true)) + ), }; }), [RotateAction.DRAW_TEMP_ROTATE_ENTITIES]: ({ context, event }) => { @@ -279,6 +301,19 @@ export const rotateToolStateMachine = createMachine( setGhostHelperEntities([]); setSelectedEntityIds([]); }, + [RotateAction.ROTATE_BY_ANGLE]: ({ context, event }) => { + if (!context.rotationOrigin) { + throw new Error('[ROTATE] Calling rotate by angle without a rotation origin'); + } + const radians = ((event as NumberInputEvent).value * Math.PI) / 180; + const rotatedEntities = compact( + context.originalSelectedEntities.map((entity) => cloneKeepingLook(entity, true)) + ); + for (const entity of rotatedEntities) entity.rotate(context.rotationOrigin, radians); + addEntities(rotatedEntities, true); + setGhostHelperEntities([]); + setSelectedEntityIds([]); + }, [RotateAction.DESELECT_ENTITIES]: assign(({ context }): RotateContext => { setGhostHelperEntities([]); setSelectedEntityIds([]); diff --git a/resources/tester/cad/keyboard-chain.test.ts b/resources/tester/cad/keyboard-chain.test.ts index be66f34d4..af5d1a5d6 100644 --- a/resources/tester/cad/keyboard-chain.test.ts +++ b/resources/tester/cad/keyboard-chain.test.ts @@ -148,3 +148,52 @@ describe("331-1 각도 고정 → 길이", () => { expect(getActiveToolActor()?.getSnapshot()?.context.values).toHaveLength(2); }); }); + +/** 선 (x1,y1)-(x2,y2) 하나를 키보드로 그리고 고름 */ +const drawAndSelect = (x1: number, y1: number, x2: number, y2: number) => { + type(controller, "l"); + type(controller, `${x1},${y1}`); + type(controller, `${x2},${y2}`); + key(controller, "Escape"); + key(controller, "Escape"); + setSelectedEntityIds(getEntities().map((entity) => entity.id)); +}; + +describe("331-2 이동 · 복사 · 회전 키보드 값", () => { + it("MOVE · 0,0 · @10,0 → 오른쪽 10", () => { + drawAndSelect(0, 0, 20, 0); + type(controller, "move"); + type(controller, "0,0"); + type(controller, "@10,0"); + const drawn = lines(); + expect(drawn).toHaveLength(1); + expectXy(drawn[0].start, 10, 0); + expectXy(drawn[0].end, 30, 0); + }); + + it("COPY · 0,0 · <0 · 5 → 원본 + 오른쪽 5", () => { + drawAndSelect(0, 0, 20, 0); + type(controller, "copy"); + type(controller, "0,0"); + type(controller, "<0"); + type(controller, "5"); + const starts = lines() + .map((segment) => segment.start.x) + .sort((a, b) => a - b); + expect(starts).toHaveLength(2); + expect(starts[0]).toBeCloseTo(0, 3); + expect(starts[1]).toBeCloseTo(5, 3); + }); + + it("ROTATE · 0,0 · 90 → 반시계 90°", () => { + drawAndSelect(10, 0, 20, 0); + type(controller, "rotate"); + type(controller, "0,0"); + type(controller, "90"); + const drawn = lines(); + expect(drawn).toHaveLength(1); + expectXy(drawn[0].start, 0, 10); + expectXy(drawn[0].end, 0, 20); + expect(toast.error).not.toHaveBeenCalled(); + }); +});