feat(CAD): 337-5 라 옮기기 무리 1 단계 — MOVE · ROTATE · SCALE 오토캐드 물음 차례

- MOVE: 기준점 [변위(D)] <변위> · 두 번째 점 <첫 번째 점을 변위로 사용> · 변위 기억(MOVE.DISP) · 한 번 뒤 끝 · Esc 제자리 끝
- ROTATE · SCALE: 공용 틀(pivot-tool) — 누름 한 번 = 값 · 복사(C) · 참조(R) → 새 값 [점(P)] · 각 · 비율 기억 · 0 이하 축척 거절
- 공용 고르기 상태(transform.helpers) — PICKFIRST · 빈 고르기 Enter = 끝 · OPTION.* 넘김 · 원본은 확정까지 도면에 그대로(N1 · N2)
- 시험 transform-acad(15) · scale-tool 단위 시험 새 상태 이름

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TteK8mfYZetk5P3bEZkyVZ
This commit is contained in:
2026-10-16 00:17:16 +09:00
co-authored by Claude Opus 5.5
parent b82ea6edcf
commit d35bdfa69d
9 changed files with 1020 additions and 1019 deletions
+165 -290
View File
@@ -1,306 +1,181 @@
/**
* MOVE(337 라 · 설계 1-3) — 「객체 선택:」 → 「기준점 지정 또는 [변위(D)] <변위>:」 →
* 「두 번째 점 지정 또는 <첫 번째 점을 변위로 사용>:」 · D → 「변위 지정 <0.0000, 0.0000>:」.
* 한 번 뒤 끝 · Esc = 제자리 끝 · 변위 기억(MOVE.DISP). STRETCH 도 이 틀(`createDisplaceTool`).
*/
import type { Point } from '@flatten-js/core';
import {
addEntities,
deleteEntities,
getActiveLayerId,
getSelectedEntities,
getSelectedEntityIds,
setAngleGuideOriginPoint,
setGhostHelperEntities,
setSelectedEntityIds,
setShouldDrawHelpers,
} from '../state';
import { Tool } from '../tools';
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 { assign, createMachine } from 'xstate';
import { OPT, type PromptSpec, remember } from '../commands/prompt';
import type { Entity } from '../entities/Entity';
import { compact } from 'es-toolkit';
import { cloneKeepingLook, moveEntities } from './move-tool.helpers';
import { LineEntity } from '../entities/LineEntity';
import { GUIDE_LINE_COLOR, GUIDE_LINE_STYLE, GUIDE_LINE_WIDTH } from '../App.consts';
export interface MoveContext extends ToolContext {
startPoint: Point | null;
originalSelectedEntities: Entity[];
movedEntities: Entity[];
lastDrawLocation: Point | null;
}
import { getPointFromEvent } from '../helpers/get-point-from-event';
import { setAngleGuideOriginPoint, setGhostHelperEntities, setShouldDrawHelpers } from '../state';
import { Tool } from '../tools';
import {
type DrawEvent,
END_STATE,
type PointInputEvent,
type StateEvent,
type ToolContext,
} from './tool.types';
import {
type PickedContext,
changedCopies,
endState,
guideLine,
replaceInPlace,
selectionState,
startState,
} from './transform.helpers';
export enum MoveState {
INIT = 'INIT',
CHECK_SELECTION = 'CHECK_SELECTION',
WAITING_FOR_SELECTION = 'WAITING_FOR_SELECTION',
WAITING_FOR_START_MOVE_POINT = 'WAITING_FOR_START_MOVE_POINT',
WAITING_FOR_END_MOVE_POINT = 'WAITING_FOR_END_MOVE_POINT',
WAITING_FOR_BASE_POINT = 'WAITING_FOR_BASE_POINT',
WAITING_FOR_SECOND_POINT = 'WAITING_FOR_SECOND_POINT',
WAITING_FOR_DISPLACEMENT = 'WAITING_FOR_DISPLACEMENT',
}
export enum MoveAction {
INIT_MOVE_TOOL = 'INIT_MOVE_TOOL',
ENABLE_HELPERS = 'ENABLE_HELPERS',
RECORD_START_POINT = 'RECORD_START_POINT',
COPY_SELECTION_BEFORE_MOVE = 'COPY_SELECTION_BEFORE_MOVE',
DRAW_TEMP_MOVE_ENTITIES = 'DRAW_TEMP_MOVE_ENTITIES',
MOVE_SELECTION = 'MOVE_SELECTION',
DESELECT_ENTITIES = 'DESELECT_ENTITIES',
RESTORE_ORIGINAL_ENTITIES = 'RESTORE_ORIGINAL_ENTITIES',
export interface DisplaceContext extends ToolContext, PickedContext {
base: Point | null;
/** 고르기 중 마지막 걸침 창(STRETCH) */
window: [Point, Point] | null;
}
/**
* Move tool state machine
* This state machine is responsible for moving entities around the canvas
* It uses the select tool state machine to select entities to move
* When the user presses enter, the selected entities are marked for moving
* The user can then select a start point
* When the user moves their mouse, the selected entities will be updated to move according to the vector: startpoint => mouse location
* When the user clicks again, the end point is selected and the entities are moved to the new location
*/
export const moveToolStateMachine = createMachine(
{
types: {} as {
context: MoveContext;
events: StateEvent;
},
context: {
startPoint: null,
originalSelectedEntities: [],
movedEntities: [],
lastDrawLocation: null,
type: Tool.MOVE,
},
initial: MoveState.INIT,
states: {
[MoveState.INIT]: {
description: 'Initializing the move tool',
always: {
actions: MoveAction.INIT_MOVE_TOOL,
target: MoveState.CHECK_SELECTION,
},
},
[MoveState.CHECK_SELECTION]: {
description: 'Check if there is something selected',
always: [
{
guard: () => {
return getSelectedEntityIds().length > 0;
},
target: MoveState.WAITING_FOR_START_MOVE_POINT,
},
{
guard: () => {
return getSelectedEntityIds().length === 0;
},
target: MoveState.WAITING_FOR_SELECTION,
},
],
},
[MoveState.WAITING_FOR_SELECTION]: {
description: 'Select what you want to move',
meta: {
instructions: '이동할 객체 선택 뒤 Enter',
},
invoke: {
id: 'selectToolInsideTheMoveTool',
src: selectToolStateMachine,
onDone: {
actions: assign(() => {
return {
startPoint: null,
};
}),
target: MoveState.CHECK_SELECTION,
},
},
on: {
MOUSE_CLICK: {
// Forward the event to the select tool
actions: sendTo('selectToolInsideTheMoveTool', ({ event }) => {
return event;
}),
},
ESC: {
actions: [MoveAction.DESELECT_ENTITIES, MoveAction.INIT_MOVE_TOOL],
},
ENTER: {
// Forward the event to the select tool
actions: sendTo('selectToolInsideTheMoveTool', ({ event }) => {
return event;
}),
},
DRAW: {
// Forward the event to the select tool
actions: sendTo('selectToolInsideTheMoveTool', ({ event }) => {
return event;
}),
},
},
},
[MoveState.WAITING_FOR_START_MOVE_POINT]: {
description: '기준점 지정 (x,y):',
meta: {
instructions: '기준점 지정 (x,y):',
},
always: {
actions: MoveAction.ENABLE_HELPERS,
},
on: {
MOUSE_CLICK: {
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,
},
},
},
[MoveState.WAITING_FOR_END_MOVE_POINT]: {
description: '둘째 점 지정 (길이 · <각도 · @x,y):',
meta: {
instructions: '둘째 점 지정 (길이 · <각도 · @x,y):',
},
on: {
DRAW: {
actions: [MoveAction.DRAW_TEMP_MOVE_ENTITIES],
},
MOUSE_CLICK: {
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,
},
},
},
},
},
{
actions: {
[MoveAction.INIT_MOVE_TOOL]: assign(() => {
setShouldDrawHelpers(false);
setGhostHelperEntities([]);
setAngleGuideOriginPoint(null);
return {
startPoint: null,
originalSelectedEntities: [],
movedEntities: [],
lastDrawLocation: null,
};
}),
[MoveAction.ENABLE_HELPERS]: () => {
setShouldDrawHelpers(true);
},
[MoveAction.RECORD_START_POINT]: assign(({ event }) => {
const startPoint = getPointFromEvent(null, event as PointInputEvent);
setAngleGuideOriginPoint(startPoint);
return { startPoint };
}),
[MoveAction.COPY_SELECTION_BEFORE_MOVE]: assign(({ context }) => {
const selectedEntities = getSelectedEntities();
export interface DisplaceConfig {
tool: Tool;
/** 변위 기억 열쇠 */
remember: string;
/** 변위만큼 바꾼 사본(id 그대로 · 안 바뀐 것은 빼도 됨) */
change: (context: DisplaceContext, dx: number, dy: number) => Entity[];
/** 고르기 누름 엿봄(걸침 창 기억) */
watch?: unknown;
/** 시작 기록 줄 */
intro?: string;
}
// Move the selected entities to the ghost helper entities, so they are drawn on the canvas, but do not interact with the snap points / angle guides
setGhostHelperEntities(selectedEntities);
// Remove the selected entities from the regular entity list, so they do not get used for determining snap points / angle guides
deleteEntities(selectedEntities, false);
type Ctx = { context: DisplaceContext; event: StateEvent };
// TODO keep a copy of the original entities in the entities list, but set their line color to grey, so the user can see where the entities were before being moved and the original entities also are used for snap points / angle guides
export const BASE_PROMPT: PromptSpec = {
text: '기준점 지정',
options: [OPT.DISPLACEMENT],
default: { kind: 'option', key: OPT.DISPLACEMENT.key },
};
export const SECOND_PROMPT: PromptSpec = {
// 옵션 없이 「또는 <…>」 — 오토캐드 줄 그대로
text: '두 번째 점 지정 또는',
default: { kind: 'enter', label: '첫 번째 점을 변위로 사용' },
};
setSelectedEntityIds([]);
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))
),
movedEntities: selectedEntities,
};
}),
[MoveAction.DRAW_TEMP_MOVE_ENTITIES]: ({ context, event }) => {
if (!context.startPoint) {
throw new Error('[MOVE] Calling draw temp move line without a start point');
export function createDisplaceTool(config: DisplaceConfig) {
const S = MoveState;
const DISP_PROMPT: PromptSpec = {
text: '변위 지정',
default: { kind: 'value', remember: config.remember, initial: { x: 0, y: 0 } },
};
const commit = (context: DisplaceContext, dx: number, dy: number) => {
replaceInPlace(config.change(context, dx, dy));
remember(config.remember, { x: dx, y: dy });
};
const toward = ({ context, event }: Ctx) =>
getPointFromEvent(context.base, event as PointInputEvent);
const commitSecond = (args: Ctx) => {
const base = args.context.base as Point;
const dest = toward(args);
commit(args.context, dest.x - base.x, dest.y - base.y);
};
// 첫 점 좌표가 곧 변위(빈 Enter) · D 변위 물음의 점
const commitPointAsVector = (point: Point, context: DisplaceContext) =>
commit(context, point.x, point.y);
const done = (actions: unknown) => ({ actions, target: END_STATE });
const pointEvents = (actions: unknown) => ({
MOUSE_CLICK: done(actions),
ABSOLUTE_POINT_INPUT: done(actions),
RELATIVE_POINT_INPUT: done(actions),
NUMBER_INPUT: done(actions),
});
// biome-ignore lint/suspicious/noExplicitAny: 공용 상태 조각(여러 도구 공통)을 섞어 만든다
const states: Record<string, any> = {
[S.INIT]: {
...startState(S.WAITING_FOR_BASE_POINT),
exit: () => {
if (config.intro) {
const intro = config.intro;
void import('../commands/run-command').then(({ logLine }) => logLine(intro));
}
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)
);
moveEntities(
movedEntities,
endPointTemp.x - context.startPoint.x,
endPointTemp.y - context.startPoint.y
);
// // Draw a dashed line between the start move point and the current mouse location
const activeMoveLine = new LineEntity(
getActiveLayerId(),
context.startPoint as Point,
endPointTemp
);
activeMoveLine.lineColor = GUIDE_LINE_COLOR;
activeMoveLine.lineWidth = GUIDE_LINE_WIDTH;
activeMoveLine.lineDash = GUIDE_LINE_STYLE;
setGhostHelperEntities([activeMoveLine, ...movedEntities]);
},
[MoveAction.MOVE_SELECTION]: ({ context, event }) => {
if (!context.startPoint) {
throw new Error('[MOVE] Calling move selection without a start point');
}
// Move the entities one final time
// 클릭 · 거리 · 절대 · 상대 좌표(키보드)
const currentEndPoint = getPointFromEvent(context.startPoint, event as PointInputEvent);
moveEntities(
context.originalSelectedEntities,
currentEndPoint.x - context.startPoint.x,
currentEndPoint.y - context.startPoint.y
);
// Switch the moved entities back from the ghost helper entities to the real entities
addEntities(context.originalSelectedEntities, true);
setGhostHelperEntities([]);
setSelectedEntityIds([]);
},
[MoveAction.DESELECT_ENTITIES]: assign(() => {
setGhostHelperEntities([]);
setSelectedEntityIds([]);
return {
startPoint: null,
originalSelectedEntities: [],
lastDrawLocation: null,
};
}),
[MoveAction.RESTORE_ORIGINAL_ENTITIES]: assign(({ context }) => {
addEntities(context.originalSelectedEntities, false); // This should already be the last state of the undo stack
setGhostHelperEntities([]);
setSelectedEntityIds([]);
return {
startPoint: null,
originalSelectedEntities: [],
lastDrawLocation: null,
};
}),
...selectToolStateMachine.implementations.actions,
},
}
);
[S.WAITING_FOR_SELECTION]: selectionState(S.WAITING_FOR_BASE_POINT, config.watch),
[S.WAITING_FOR_BASE_POINT]: {
meta: { kind: 'point', prompt: BASE_PROMPT },
entry: () => setShouldDrawHelpers(true),
on: {
MOUSE_CLICK: {
actions: assign(({ event }) => {
const base = getPointFromEvent(null, event as PointInputEvent);
setAngleGuideOriginPoint(base);
return { base };
}),
target: S.WAITING_FOR_SECOND_POINT,
},
ABSOLUTE_POINT_INPUT: {
actions: assign(({ event }) => {
const base = getPointFromEvent(null, event as PointInputEvent);
setAngleGuideOriginPoint(base);
return { base };
}),
target: S.WAITING_FOR_SECOND_POINT,
},
'OPTION.DISPLACEMENT': { target: S.WAITING_FOR_DISPLACEMENT },
ESC: { target: END_STATE },
},
},
[S.WAITING_FOR_SECOND_POINT]: {
meta: { kind: 'point', prompt: SECOND_PROMPT },
on: {
DRAW: {
actions: (args: Ctx) => {
const base = args.context.base as Point;
const dest = getPointFromEvent(base, args.event as DrawEvent);
setGhostHelperEntities([
guideLine(base, dest),
...config.change(args.context, dest.x - base.x, dest.y - base.y),
]);
},
},
...pointEvents(commitSecond),
ENTER: done(({ context }: Ctx) => commitPointAsVector(context.base as Point, context)),
ESC: { target: END_STATE },
},
},
[S.WAITING_FOR_DISPLACEMENT]: {
meta: { kind: 'point', prompt: DISP_PROMPT },
entry: () => setAngleGuideOriginPoint(null),
on: {
MOUSE_CLICK: done(({ context, event }: Ctx) =>
commitPointAsVector(getPointFromEvent(null, event as PointInputEvent), context)
),
ABSOLUTE_POINT_INPUT: done(({ context, event }: Ctx) =>
commitPointAsVector(getPointFromEvent(null, event as PointInputEvent), context)
),
ESC: { target: END_STATE },
},
},
[END_STATE]: endState,
};
return createMachine({
types: {} as { context: DisplaceContext; events: StateEvent },
context: { originals: [], base: null, window: null, type: config.tool },
initial: S.INIT,
states,
});
}
export const moveToolStateMachine = createDisplaceTool({
tool: Tool.MOVE,
remember: 'MOVE.DISP',
change: (context, dx, dy) => changedCopies(context.originals, (entity) => entity.move(dx, dy)),
});