diff --git a/B07_DesignDetail/openwebcad/src/tools/scale-tool.test.ts b/B07_DesignDetail/openwebcad/src/tools/scale-tool.test.ts new file mode 100644 index 000000000..ee52ef7ae --- /dev/null +++ b/B07_DesignDetail/openwebcad/src/tools/scale-tool.test.ts @@ -0,0 +1,89 @@ +import { Point } from '@flatten-js/core'; +import { beforeEach, describe, expect, it } from 'vitest'; +import { createActor } from 'xstate'; +import { LineEntity } from '../entities/LineEntity.ts'; +import { + getEntities, + resetUndoBaseline, + setEntities, + setSelectedEntityIds, + undo, +} from '../state.ts'; +import { ScaleState, scaleToolStateMachine } from './scale-tool.ts'; +import { ActorEvent } from './tool.types.ts'; + +const ends = (index: number) => { + const line = getEntities()[index] as LineEntity; + const a = line.getStartPoint(); + const b = line.getEndPoint(); + return [a.x, a.y, b.x, b.y]; +}; + +/** 기준점 (2,0) 클릭까지 — 선 A(0,0)-(10,0) 고름 · 선 B(0,5)-(4,5) 는 그대로 둘 것 */ +function startAtBasePoint() { + const a = new LineEntity('layer1', new Point(0, 0), new Point(10, 0)); + const b = new LineEntity('layer1', new Point(0, 5), new Point(4, 5)); + setEntities([a, b]); + resetUndoBaseline(); + setSelectedEntityIds([a.id]); + const actor = createActor(scaleToolStateMachine).start(); + expect(actor.getSnapshot().value).toBe(ScaleState.WAITING_FOR_BASE_VECTOR_START_POINT); + actor.send({ + type: ActorEvent.MOUSE_CLICK, + worldMouseLocation: new Point(2, 0), + screenMouseLocation: new Point(0, 0), + holdingCtrl: false, + holdingShift: false, + }); + expect(actor.getSnapshot().value).toBe(ScaleState.WAITING_FOR_BASE_VECTOR_END_POINT); + return { actor, a, b }; +} + +const typeNumber = (actor: ReturnType['actor'], value: number) => + actor.send({ type: ActorEvent.NUMBER_INPUT, value, worldMouseLocation: new Point(99, 99) }); + +describe('축척 — 기준점 + 숫자 배율 (226-1)', () => { + beforeEach(() => setSelectedEntityIds([])); + + it('배율 2 = 기준점 고정 두 배 · 다른 객체 · 순서 그대로', () => { + const { actor, a, b } = startAtBasePoint(); + typeNumber(actor, 2); + // (0,0)→(-2,0) · (10,0)→(18,0) — 기준점 (2,0) 은 그대로 + expect(ends(0)).toEqual([-2, 0, 18, 0]); + expect(getEntities()[0].id).toBe(a.id); + expect(getEntities()[1]).toBe(b); + expect(ends(1)).toEqual([0, 5, 4, 5]); + // 끝나면 선택 풀고 다음 고르기로 + expect(actor.getSnapshot().value).toBe(ScaleState.WAITING_FOR_SELECTION); + }); + + it('배율 0.5 = 절반', () => { + const { actor } = startAtBasePoint(); + typeNumber(actor, 0.5); + expect(ends(0)).toEqual([1, 0, 6, 0]); + }); + + it('되돌리기 한 번에 원래 자리', () => { + const { actor } = startAtBasePoint(); + typeNumber(actor, 2); + undo(); + expect(ends(0)).toEqual([0, 0, 10, 0]); + expect(getEntities()).toHaveLength(2); + }); + + it('Esc = 취소 — 객체 그대로', () => { + const { actor, a, b } = startAtBasePoint(); + actor.send({ type: ActorEvent.ESC }); + expect(getEntities()).toEqual([a, b]); + expect(ends(0)).toEqual([0, 0, 10, 0]); + typeNumber(actor, 2); + expect(ends(0)).toEqual([0, 0, 10, 0]); + }); + + it('배율 0 은 받지 않음', () => { + const { actor } = startAtBasePoint(); + typeNumber(actor, 0); + expect(ends(0)).toEqual([0, 0, 10, 0]); + expect(actor.getSnapshot().value).toBe(ScaleState.WAITING_FOR_BASE_VECTOR_END_POINT); + }); +}); diff --git a/B07_DesignDetail/openwebcad/src/tools/scale-tool.ts b/B07_DesignDetail/openwebcad/src/tools/scale-tool.ts index 660a885b2..c73e27c6e 100644 --- a/B07_DesignDetail/openwebcad/src/tools/scale-tool.ts +++ b/B07_DesignDetail/openwebcad/src/tools/scale-tool.ts @@ -2,15 +2,23 @@ import type { Point } from '@flatten-js/core'; import { addEntities, deleteEntities, + getEntities, getSelectedEntities, getSelectedEntityIds, setAngleGuideOriginPoint, + setEntities, setGhostHelperEntities, setSelectedEntityIds, setShouldDrawHelpers, } from '../state'; import { Tool } from '../tools'; -import type { DrawEvent, MouseClickEvent, StateEvent, ToolContext } from './tool.types'; +import type { + DrawEvent, + MouseClickEvent, + NumberInputEvent, + StateEvent, + ToolContext, +} from './tool.types'; import { assign, createMachine, sendTo } from 'xstate'; import { selectToolStateMachine } from './select-tool'; import type { Entity } from '../entities/Entity'; @@ -42,6 +50,7 @@ export enum ScaleAction { COPY_SELECTION_BEFORE_SCALE = 'COPY_SELECTION_BEFORE_SCALE', DRAW_TEMP_SCALE_ENTITIES = 'DRAW_TEMP_SCALE_ENTITIES', SCALE_SELECTION = 'SCALE_SELECTION', + SCALE_SELECTION_BY_FACTOR = 'SCALE_SELECTION_BY_FACTOR', DESELECT_ENTITIES = 'DESELECT_ENTITIES', RESTORE_ORIGINAL_ENTITIES = 'RESTORE_ORIGINAL_ENTITIES', } @@ -98,7 +107,7 @@ export const scaleToolStateMachine = createMachine( [ScaleState.WAITING_FOR_SELECTION]: { description: 'Select what you want to scale', meta: { - instructions: 'Select what you want to scale, then ENTER', + instructions: '축척할 객체를 선택한 뒤 ENTER.', }, invoke: { id: 'selectToolInsideTheScaleTool', @@ -142,7 +151,7 @@ export const scaleToolStateMachine = createMachine( [ScaleState.WAITING_FOR_BASE_VECTOR_START_POINT]: { description: 'Select the origin of the scale operation', meta: { - instructions: 'Select the origin of the scale operation', + instructions: '기준점을 지정하십시오. (Esc 취소)', }, always: { actions: ScaleAction.ENABLE_HELPERS, @@ -161,9 +170,17 @@ export const scaleToolStateMachine = createMachine( [ScaleState.WAITING_FOR_BASE_VECTOR_END_POINT]: { description: 'Select the end of the base scale line', meta: { - instructions: 'Select the end of the base scale line', + instructions: + '배율을 입력하고 ENTER — 또는 끌어서 맞출 기준선 끝을 클릭하십시오. (Esc 취소)', }, on: { + // 오토캐드 SCALE — 기준점 뒤 숫자 배율(2 = 두 배 · 0.5 = 절반) + NUMBER_INPUT: { + guard: ({ event }) => + (event as NumberInputEvent).value > 0 && getSelectedEntityIds().length > 0, + actions: [ScaleAction.SCALE_SELECTION_BY_FACTOR, ScaleAction.DESELECT_ENTITIES], + target: ScaleState.INIT, + }, MOUSE_CLICK: { actions: [ ScaleAction.RECORD_BASE_VECTOR_END_POINT, @@ -180,7 +197,7 @@ export const scaleToolStateMachine = createMachine( [ScaleState.WAITING_FOR_SCALE_VECTOR_END_POINT]: { description: 'Select the end of the scale line', meta: { - instructions: 'Select the end of the scale line', + instructions: '새 길이의 끝을 클릭하십시오. (Esc 취소)', }, on: { DRAW: { @@ -235,7 +252,9 @@ export const scaleToolStateMachine = createMachine( return { ...context, // Make a copy of the selected entities before scaling them, so we can restore them when the user cancels the scale action - originalSelectedEntities: compact(selectedEntities.map((entity) => cloneKeepingLook(entity, true))), + originalSelectedEntities: compact( + selectedEntities.map((entity) => cloneKeepingLook(entity, true)) + ), }; }), [ScaleAction.DRAW_TEMP_SCALE_ENTITIES]: ({ context, event }) => { @@ -282,6 +301,24 @@ export const scaleToolStateMachine = createMachine( setGhostHelperEntities([]); setSelectedEntityIds([]); }, + [ScaleAction.SCALE_SELECTION_BY_FACTOR]: ({ context, event }) => { + const origin = context.baseVectorStartPoint; + if (!origin) { + throw new Error('[SCALE] Calling scale by factor without a base point'); + } + const factor = (event as NumberInputEvent).value; + const selectedIds = new Set(getSelectedEntityIds()); + // 제자리 교체 — 복사본을 키워 같은 순서에 넣고 되돌리기 한 칸으로 남긴다 + setEntities( + getEntities().map((entity) => { + if (!selectedIds.has(entity.id)) return entity; + const scaled = cloneKeepingLook(entity, true); + scaled.scale(origin, factor); + return scaled; + }), + true + ); + }, [ScaleAction.DESELECT_ENTITIES]: assign(() => { setGhostHelperEntities([]); setSelectedEntityIds([]);