Merge remote-tracking branch 'origin/sub_laptop_7' into sub_laptop_4
This commit is contained in:
@@ -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)),
|
||||
});
|
||||
|
||||
@@ -0,0 +1,345 @@
|
||||
/**
|
||||
* ROTATE · SCALE 공용 틀(337 라 · 설계 1-3) — 「객체 선택:」 → 「기준점 지정:」 →
|
||||
* 「<값> 지정 또는 [복사(C)/참조(R)] <기본>:」(누름 = 기준점에서 그 점까지 잰 값 · 숫자 = 값 · 빈 Enter = 기억 값) ·
|
||||
* C = 사본 켬/끔 · R → 「참조 … <기본>:」(숫자 · 두 점) → 「새 … 지정 또는 [점(P)] <기본>:」(숫자 · 누름 · P 두 점).
|
||||
* 한 번 뒤 끝 · Esc = 제자리 끝 · 원본은 확정까지 도면에 그대로.
|
||||
*/
|
||||
import type { Point } from '@flatten-js/core';
|
||||
import { assign, createMachine } from 'xstate';
|
||||
import { OPT, type OptionSpec, type PromptSpec, remember } from '../commands/prompt';
|
||||
import type { Entity } from '../entities/Entity';
|
||||
import { getPointFromEvent } from '../helpers/get-point-from-event';
|
||||
import { setAngleGuideOriginPoint, setGhostHelperEntities, setShouldDrawHelpers } from '../state';
|
||||
import type { Tool } from '../tools';
|
||||
import {
|
||||
type DrawEvent,
|
||||
END_STATE,
|
||||
type NumberInputEvent,
|
||||
type PointInputEvent,
|
||||
type StateEvent,
|
||||
type ToolContext,
|
||||
} from './tool.types';
|
||||
import {
|
||||
type PickedContext,
|
||||
addCopies,
|
||||
changedCopies,
|
||||
endState,
|
||||
guideLine,
|
||||
replaceInPlace,
|
||||
selectionState,
|
||||
showGhosts,
|
||||
startState,
|
||||
} from './transform.helpers';
|
||||
|
||||
export enum PivotState {
|
||||
INIT = 'INIT',
|
||||
WAITING_FOR_SELECTION = 'WAITING_FOR_SELECTION',
|
||||
WAITING_FOR_BASE_POINT = 'WAITING_FOR_BASE_POINT',
|
||||
WAITING_FOR_VALUE = 'WAITING_FOR_VALUE',
|
||||
WAITING_FOR_REFERENCE = 'WAITING_FOR_REFERENCE',
|
||||
WAITING_FOR_REFERENCE_SECOND_POINT = 'WAITING_FOR_REFERENCE_SECOND_POINT',
|
||||
WAITING_FOR_NEW_VALUE = 'WAITING_FOR_NEW_VALUE',
|
||||
WAITING_FOR_NEW_FIRST_POINT = 'WAITING_FOR_NEW_FIRST_POINT',
|
||||
WAITING_FOR_NEW_SECOND_POINT = 'WAITING_FOR_NEW_SECOND_POINT',
|
||||
}
|
||||
|
||||
export interface PivotContext extends ToolContext, PickedContext {
|
||||
base: Point | null;
|
||||
/** 복사(C) — 원본 두고 사본을 바꿈 */
|
||||
copy: boolean;
|
||||
/** 참조 값(R) */
|
||||
reference: number | null;
|
||||
/** 참조 · 새 값 두 점의 첫 점 */
|
||||
first: Point | null;
|
||||
}
|
||||
|
||||
interface ValuePrompt {
|
||||
text: string;
|
||||
remember: string;
|
||||
initial: number;
|
||||
}
|
||||
|
||||
export interface PivotConfig {
|
||||
tool: Tool;
|
||||
format: 'ang' | 'len';
|
||||
value: ValuePrompt;
|
||||
reference: ValuePrompt;
|
||||
next: ValuePrompt;
|
||||
/** 값 · 참조 · 새 값 숫자 검사 */
|
||||
number?: PromptSpec['number'];
|
||||
reject?: string;
|
||||
/** C 켬 · 끔 알림 */
|
||||
copyNotice: [string, string];
|
||||
/** 못 쓰는 값 알림 */
|
||||
invalidNotice: string;
|
||||
/** 두 점으로 잰 값(각 · 거리) */
|
||||
measure: (a: Point, b: Point) => number;
|
||||
/** 새 값 · 참조 → 값(못 쓰면 null) */
|
||||
combine: (next: number, reference: number) => number | null;
|
||||
valid: (value: number) => boolean;
|
||||
apply: (entity: Entity, base: Point, value: number) => void;
|
||||
}
|
||||
|
||||
const POINTS: OptionSpec = { key: 'POINTS', kw: 'P', en: 'POINTS', label: '점' };
|
||||
|
||||
type Ctx = { context: PivotContext; event: StateEvent };
|
||||
|
||||
const noticeLater = (text: string) => {
|
||||
void import('../inputController/prompt-input').then(({ notice }) => notice(text));
|
||||
};
|
||||
|
||||
export function createPivotTool(config: PivotConfig) {
|
||||
const S = PivotState;
|
||||
const valuePrompt = (spec: ValuePrompt, options?: OptionSpec[]): PromptSpec => ({
|
||||
text: spec.text,
|
||||
...(options ? { options } : {}),
|
||||
default: {
|
||||
kind: 'value',
|
||||
remember: spec.remember,
|
||||
initial: spec.initial,
|
||||
format: config.format,
|
||||
},
|
||||
...(config.number ? { number: config.number } : {}),
|
||||
...(config.reject ? { reject: config.reject } : {}),
|
||||
});
|
||||
const PROMPT = {
|
||||
base: { text: '기준점 지정' },
|
||||
value: valuePrompt(config.value, [OPT.COPY, OPT.REFERENCE]),
|
||||
reference: valuePrompt(config.reference),
|
||||
second: { text: '두 번째 점 지정' },
|
||||
next: valuePrompt(config.next, [POINTS]),
|
||||
first: { text: '첫 번째 점 지정' },
|
||||
} satisfies Record<string, PromptSpec>;
|
||||
|
||||
const pointOf = (from: Point | null, event: StateEvent) =>
|
||||
getPointFromEvent(from, event as PointInputEvent);
|
||||
const ok = (value: number | null): value is number =>
|
||||
value !== null && Number.isFinite(value) && config.valid(value);
|
||||
|
||||
const transform = (context: PivotContext, value: number) => (entity: Entity) =>
|
||||
config.apply(entity, context.base as Point, value);
|
||||
const commit = (context: PivotContext, value: number) => {
|
||||
if (context.copy) addCopies(context.originals, transform(context, value));
|
||||
else replaceInPlace(changedCopies(context.originals, transform(context, value)));
|
||||
remember(config.value.remember, value);
|
||||
};
|
||||
const preview = (context: PivotContext, value: number | null, from: Point, cursor: Point) => {
|
||||
if (ok(value))
|
||||
showGhosts(context.originals, transform(context, value), guideLine(from, cursor));
|
||||
else setGhostHelperEntities([guideLine(from, cursor)]);
|
||||
};
|
||||
|
||||
/** 값을 셈해 맞으면 확정 · 끝 · 아니면 알림 뒤 그 물음 */
|
||||
const settle = (compute: (args: Ctx) => number | null) => [
|
||||
{
|
||||
guard: (args: Ctx) => ok(compute(args)),
|
||||
actions: (args: Ctx) => commit(args.context, compute(args) as number),
|
||||
target: END_STATE,
|
||||
},
|
||||
{ actions: () => noticeLater(config.invalidNotice) },
|
||||
];
|
||||
const onPoints = (handler: unknown) => ({
|
||||
MOUSE_CLICK: handler,
|
||||
ABSOLUTE_POINT_INPUT: handler,
|
||||
RELATIVE_POINT_INPUT: handler,
|
||||
NUMBER_INPUT: handler,
|
||||
});
|
||||
const numberOr = (args: Ctx, point: (p: Point) => number) =>
|
||||
args.event.type === 'NUMBER_INPUT'
|
||||
? (args.event as NumberInputEvent).value
|
||||
: point(pointOf(args.context.base, args.event));
|
||||
const nextValue = (args: Ctx, raw: number) => {
|
||||
remember(config.next.remember, raw);
|
||||
return config.combine(raw, args.context.reference ?? 0);
|
||||
};
|
||||
const cursorOf = (event: StateEvent, from: Point) => getPointFromEvent(from, event as DrawEvent);
|
||||
const esc = { ESC: { target: END_STATE } };
|
||||
|
||||
// biome-ignore lint/suspicious/noExplicitAny: 공용 상태 조각(여러 도구 공통)을 섞어 만든다
|
||||
const states: Record<string, any> = {
|
||||
[S.INIT]: startState(S.WAITING_FOR_BASE_POINT),
|
||||
[S.WAITING_FOR_SELECTION]: selectionState(S.WAITING_FOR_BASE_POINT),
|
||||
[S.WAITING_FOR_BASE_POINT]: {
|
||||
meta: { kind: 'point', prompt: PROMPT.base },
|
||||
entry: () => setShouldDrawHelpers(true),
|
||||
on: {
|
||||
MOUSE_CLICK: {
|
||||
actions: assign(({ event }: Ctx) => {
|
||||
const base = pointOf(null, event);
|
||||
setAngleGuideOriginPoint(base);
|
||||
return { base };
|
||||
}),
|
||||
target: S.WAITING_FOR_VALUE,
|
||||
},
|
||||
ABSOLUTE_POINT_INPUT: {
|
||||
actions: assign(({ event }: Ctx) => {
|
||||
const base = pointOf(null, event);
|
||||
setAngleGuideOriginPoint(base);
|
||||
return { base };
|
||||
}),
|
||||
target: S.WAITING_FOR_VALUE,
|
||||
},
|
||||
...esc,
|
||||
},
|
||||
},
|
||||
[S.WAITING_FOR_VALUE]: {
|
||||
meta: { kind: 'point', prompt: PROMPT.value },
|
||||
on: {
|
||||
DRAW: {
|
||||
actions: ({ context, event }: Ctx) => {
|
||||
const base = context.base as Point;
|
||||
const cursor = cursorOf(event, base);
|
||||
preview(context, config.measure(base, cursor), base, cursor);
|
||||
},
|
||||
},
|
||||
...onPoints(
|
||||
settle((args) => numberOr(args, (p) => config.measure(args.context.base as Point, p)))
|
||||
),
|
||||
'OPTION.COPY': {
|
||||
actions: [
|
||||
assign(({ context }: Ctx) => ({ copy: !context.copy })),
|
||||
({ context }: Ctx) => noticeLater(config.copyNotice[context.copy ? 0 : 1]),
|
||||
],
|
||||
},
|
||||
'OPTION.REFERENCE': { target: S.WAITING_FOR_REFERENCE },
|
||||
...esc,
|
||||
},
|
||||
},
|
||||
[S.WAITING_FOR_REFERENCE]: {
|
||||
meta: { kind: 'point', prompt: PROMPT.reference },
|
||||
entry: () => setGhostHelperEntities([]),
|
||||
on: {
|
||||
NUMBER_INPUT: {
|
||||
actions: assign(({ event }: Ctx) => {
|
||||
const reference = (event as NumberInputEvent).value;
|
||||
remember(config.reference.remember, reference);
|
||||
return { reference };
|
||||
}),
|
||||
target: S.WAITING_FOR_NEW_VALUE,
|
||||
},
|
||||
...Object.fromEntries(
|
||||
['MOUSE_CLICK', 'ABSOLUTE_POINT_INPUT', 'RELATIVE_POINT_INPUT'].map((type) => [
|
||||
type,
|
||||
{
|
||||
actions: assign(({ context, event }: Ctx) => {
|
||||
const first = pointOf(context.base, event);
|
||||
setAngleGuideOriginPoint(first);
|
||||
return { first };
|
||||
}),
|
||||
target: S.WAITING_FOR_REFERENCE_SECOND_POINT,
|
||||
},
|
||||
])
|
||||
),
|
||||
...esc,
|
||||
},
|
||||
},
|
||||
[S.WAITING_FOR_REFERENCE_SECOND_POINT]: {
|
||||
meta: { kind: 'point', prompt: PROMPT.second },
|
||||
on: {
|
||||
DRAW: {
|
||||
actions: ({ context, event }: Ctx) => {
|
||||
const first = context.first as Point;
|
||||
setGhostHelperEntities([guideLine(first, cursorOf(event, first))]);
|
||||
},
|
||||
},
|
||||
...onPoints({
|
||||
actions: assign(({ context, event }: Ctx) => {
|
||||
const first = context.first as Point;
|
||||
const reference = config.measure(first, pointOf(first, event));
|
||||
remember(config.reference.remember, reference);
|
||||
setAngleGuideOriginPoint(context.base);
|
||||
return { reference, first: null };
|
||||
}),
|
||||
target: S.WAITING_FOR_NEW_VALUE,
|
||||
}),
|
||||
...esc,
|
||||
},
|
||||
},
|
||||
[S.WAITING_FOR_NEW_VALUE]: {
|
||||
meta: { kind: 'point', prompt: PROMPT.next },
|
||||
on: {
|
||||
DRAW: {
|
||||
actions: ({ context, event }: Ctx) => {
|
||||
const base = context.base as Point;
|
||||
const cursor = cursorOf(event, base);
|
||||
preview(
|
||||
context,
|
||||
config.combine(config.measure(base, cursor), context.reference ?? 0),
|
||||
base,
|
||||
cursor
|
||||
);
|
||||
},
|
||||
},
|
||||
...onPoints(
|
||||
settle((args) =>
|
||||
nextValue(
|
||||
args,
|
||||
numberOr(args, (p) => config.measure(args.context.base as Point, p))
|
||||
)
|
||||
)
|
||||
),
|
||||
'OPTION.POINTS': { target: S.WAITING_FOR_NEW_FIRST_POINT },
|
||||
...esc,
|
||||
},
|
||||
},
|
||||
[S.WAITING_FOR_NEW_FIRST_POINT]: {
|
||||
meta: { kind: 'point', prompt: PROMPT.first },
|
||||
entry: () => setGhostHelperEntities([]),
|
||||
on: {
|
||||
...Object.fromEntries(
|
||||
['MOUSE_CLICK', 'ABSOLUTE_POINT_INPUT', 'RELATIVE_POINT_INPUT'].map((type) => [
|
||||
type,
|
||||
{
|
||||
actions: assign(({ context, event }: Ctx) => {
|
||||
const first = pointOf(context.base, event);
|
||||
setAngleGuideOriginPoint(first);
|
||||
return { first };
|
||||
}),
|
||||
target: S.WAITING_FOR_NEW_SECOND_POINT,
|
||||
},
|
||||
])
|
||||
),
|
||||
...esc,
|
||||
},
|
||||
},
|
||||
[S.WAITING_FOR_NEW_SECOND_POINT]: {
|
||||
meta: { kind: 'point', prompt: PROMPT.second },
|
||||
on: {
|
||||
DRAW: {
|
||||
actions: ({ context, event }: Ctx) => {
|
||||
const first = context.first as Point;
|
||||
const cursor = cursorOf(event, first);
|
||||
preview(
|
||||
context,
|
||||
config.combine(config.measure(first, cursor), context.reference ?? 0),
|
||||
first,
|
||||
cursor
|
||||
);
|
||||
},
|
||||
},
|
||||
...onPoints(
|
||||
settle((args) => {
|
||||
const first = args.context.first as Point;
|
||||
return nextValue(args, config.measure(first, pointOf(first, args.event)));
|
||||
})
|
||||
),
|
||||
...esc,
|
||||
},
|
||||
},
|
||||
[END_STATE]: endState,
|
||||
};
|
||||
|
||||
return createMachine({
|
||||
types: {} as { context: PivotContext; events: StateEvent },
|
||||
context: {
|
||||
originals: [],
|
||||
base: null,
|
||||
copy: false,
|
||||
reference: null,
|
||||
first: null,
|
||||
type: config.tool,
|
||||
},
|
||||
initial: S.INIT,
|
||||
states,
|
||||
});
|
||||
}
|
||||
@@ -1,22 +0,0 @@
|
||||
import { Line, type Point } from '@flatten-js/core';
|
||||
import type { Entity } from '../entities/Entity';
|
||||
|
||||
/**
|
||||
* Rotate entities round a base point by a certain angle
|
||||
* @param entities
|
||||
* @param rotateOrigin
|
||||
* @param startAnglePoint
|
||||
* @param endAnglePoint
|
||||
*/
|
||||
export function rotateEntities(
|
||||
entities: Entity[],
|
||||
rotateOrigin: Point,
|
||||
startAnglePoint: Point,
|
||||
endAnglePoint: Point
|
||||
) {
|
||||
const rotationAngle =
|
||||
new Line(rotateOrigin, endAnglePoint).slope - new Line(rotateOrigin, startAnglePoint).slope;
|
||||
for (const entity of entities) {
|
||||
entity.rotate(rotateOrigin, rotationAngle);
|
||||
}
|
||||
}
|
||||
@@ -1,341 +1,25 @@
|
||||
import type { Point } from '@flatten-js/core';
|
||||
import {
|
||||
addEntities,
|
||||
deleteEntities,
|
||||
getSelectedEntities,
|
||||
getSelectedEntityIds,
|
||||
setAngleGuideOriginPoint,
|
||||
setGhostHelperEntities,
|
||||
setSelectedEntityIds,
|
||||
setShouldDrawHelpers,
|
||||
} from '../state';
|
||||
import { Tool } from '../tools';
|
||||
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';
|
||||
import { compact } from 'es-toolkit';
|
||||
import { rotateEntities } from './rotate-tool.helpers';
|
||||
import { cloneKeepingLook } from './move-tool.helpers';
|
||||
|
||||
export interface RotateContext extends ToolContext {
|
||||
rotationOrigin: Point | null;
|
||||
angleStartPoint: Point | null;
|
||||
originalSelectedEntities: Entity[];
|
||||
}
|
||||
|
||||
export enum RotateState {
|
||||
INIT = 'INIT',
|
||||
CHECK_SELECTION = 'CHECK_SELECTION',
|
||||
WAITING_FOR_SELECTION = 'WAITING_FOR_SELECTION',
|
||||
WAITING_FOR_ROTATION_ORIGIN = 'WAITING_FOR_ROTATION_ORIGIN',
|
||||
WAITING_FOR_ANGLE_START_POINT = 'WAITING_FOR_ANGLE_START_POINT',
|
||||
WAITING_FOR_ANGLE_END_POINT = 'WAITING_FOR_ANGLE_END_POINT',
|
||||
}
|
||||
|
||||
export enum RotateAction {
|
||||
INIT_ROTATE_TOOL = 'INIT_ROTATE_TOOL',
|
||||
ENABLE_HELPERS = 'ENABLE_HELPERS',
|
||||
RECORD_ROTATION_ORIGIN = 'RECORD_ROTATION_ORIGIN',
|
||||
RECORD_ROTATION_ANGLE_START_POINT = 'RECORD_ROTATION_ANGLE_START_POINT',
|
||||
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',
|
||||
}
|
||||
|
||||
/**
|
||||
* Rotate tool state machine
|
||||
* This state machine is responsible for rotating entities around a point (rotation origin) by a certain angle
|
||||
* It uses the select tool state machine to select entities to rotate
|
||||
* When the user presses enter, the selected entities are marked for rotating
|
||||
* The user can then select a rotation origin point
|
||||
* The user can then select the start point of the rotation angle
|
||||
* When the user moves their mouse, the selected entities will be updated to rotate according to the angle: start angle point => rotation origin => mouse location
|
||||
* When the user clicks again, the angle is locked in and the entities are rotated around the rotation origin
|
||||
* ROTATE(337 라 · 설계 1-3) — 「기준점 지정:」 → 「회전 각도 지정 또는 [복사(C)/참조(R)] <0>:」 ·
|
||||
* 누름 한 번 = 기준점에서 그 점 방향 각 · 숫자 = 반시계 + · R → 「참조 각도를 지정 <0>:」(숫자 · 두 점 · `@` = 기준점) →
|
||||
* 「새 각도 지정 또는 [점(P)] <0>:」 · 각 기억(ROTATE.ANGLE).
|
||||
*/
|
||||
export const rotateToolStateMachine = createMachine(
|
||||
{
|
||||
types: {} as {
|
||||
context: RotateContext;
|
||||
events: StateEvent;
|
||||
},
|
||||
context: {
|
||||
rotationOrigin: null,
|
||||
angleStartPoint: null,
|
||||
originalSelectedEntities: [],
|
||||
type: Tool.ROTATE,
|
||||
},
|
||||
initial: RotateState.INIT,
|
||||
states: {
|
||||
[RotateState.INIT]: {
|
||||
description: 'Initializing the rotate tool',
|
||||
always: {
|
||||
actions: RotateAction.INIT_ROTATE_TOOL,
|
||||
target: RotateState.CHECK_SELECTION,
|
||||
},
|
||||
},
|
||||
[RotateState.CHECK_SELECTION]: {
|
||||
description: 'Check if there is something selected',
|
||||
always: [
|
||||
{
|
||||
guard: () => {
|
||||
return getSelectedEntityIds().length > 0;
|
||||
},
|
||||
target: RotateState.WAITING_FOR_ROTATION_ORIGIN,
|
||||
},
|
||||
{
|
||||
guard: () => {
|
||||
return getSelectedEntityIds().length === 0;
|
||||
},
|
||||
target: RotateState.WAITING_FOR_SELECTION,
|
||||
},
|
||||
],
|
||||
},
|
||||
[RotateState.WAITING_FOR_SELECTION]: {
|
||||
description: 'Select what you want to rotate',
|
||||
meta: {
|
||||
instructions: '회전할 객체 선택 뒤 Enter',
|
||||
},
|
||||
invoke: {
|
||||
id: 'selectToolInsideTheRotateTool',
|
||||
src: selectToolStateMachine,
|
||||
onDone: {
|
||||
actions: assign(({ context }) => {
|
||||
return {
|
||||
...context,
|
||||
};
|
||||
}),
|
||||
target: RotateState.CHECK_SELECTION,
|
||||
},
|
||||
},
|
||||
on: {
|
||||
MOUSE_CLICK: {
|
||||
// Forward the event to the select tool
|
||||
actions: sendTo('selectToolInsideTheRotateTool', ({ event }) => {
|
||||
return event;
|
||||
}),
|
||||
},
|
||||
ESC: {
|
||||
actions: [RotateAction.DESELECT_ENTITIES, RotateAction.INIT_ROTATE_TOOL],
|
||||
},
|
||||
ENTER: {
|
||||
// Forward the event to the select tool
|
||||
actions: sendTo('selectToolInsideTheRotateTool', ({ event }) => {
|
||||
return event;
|
||||
}),
|
||||
},
|
||||
DRAW: {
|
||||
// Forward the event to the select tool
|
||||
actions: sendTo('selectToolInsideTheRotateTool', ({ event }) => {
|
||||
return event;
|
||||
}),
|
||||
},
|
||||
},
|
||||
},
|
||||
[RotateState.WAITING_FOR_ROTATION_ORIGIN]: {
|
||||
description: '기준점 지정 (x,y):',
|
||||
meta: {
|
||||
instructions: '기준점 지정 (x,y):',
|
||||
},
|
||||
always: {
|
||||
actions: RotateAction.ENABLE_HELPERS,
|
||||
},
|
||||
on: {
|
||||
MOUSE_CLICK: {
|
||||
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,
|
||||
},
|
||||
},
|
||||
},
|
||||
[RotateState.WAITING_FOR_ANGLE_START_POINT]: {
|
||||
description: '회전 각도 입력 또는 기준선 끝점 지정:',
|
||||
meta: {
|
||||
instructions: '회전 각도 입력 또는 기준선 끝점 지정:',
|
||||
},
|
||||
on: {
|
||||
MOUSE_CLICK: {
|
||||
actions: [
|
||||
RotateAction.RECORD_ROTATION_ANGLE_START_POINT,
|
||||
RotateAction.COPY_SELECTION_BEFORE_ROTATE,
|
||||
],
|
||||
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,
|
||||
},
|
||||
},
|
||||
},
|
||||
[RotateState.WAITING_FOR_ANGLE_END_POINT]: {
|
||||
description: '회전 방향 점 지정:',
|
||||
meta: {
|
||||
instructions: '회전 방향 점 지정:',
|
||||
},
|
||||
on: {
|
||||
DRAW: {
|
||||
actions: [RotateAction.DRAW_TEMP_ROTATE_ENTITIES],
|
||||
},
|
||||
MOUSE_CLICK: {
|
||||
actions: [RotateAction.ROTATE_SELECTION, RotateAction.DESELECT_ENTITIES],
|
||||
target: RotateState.WAITING_FOR_SELECTION,
|
||||
},
|
||||
ESC: {
|
||||
actions: RotateAction.RESTORE_ORIGINAL_ENTITIES,
|
||||
target: RotateState.INIT,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
actions: {
|
||||
[RotateAction.INIT_ROTATE_TOOL]: () => {
|
||||
setShouldDrawHelpers(false);
|
||||
setGhostHelperEntities([]);
|
||||
setAngleGuideOriginPoint(null);
|
||||
},
|
||||
[RotateAction.ENABLE_HELPERS]: () => {
|
||||
setShouldDrawHelpers(true);
|
||||
},
|
||||
[RotateAction.RECORD_ROTATION_ORIGIN]: assign(({ context, event }): RotateContext => {
|
||||
const rotationOrigin = getPointFromEvent(null, event as PointInputEvent);
|
||||
setAngleGuideOriginPoint(rotationOrigin);
|
||||
return { ...context, rotationOrigin };
|
||||
}),
|
||||
[RotateAction.RECORD_ROTATION_ANGLE_START_POINT]: assign(
|
||||
({ context, event }): RotateContext => {
|
||||
return {
|
||||
...context,
|
||||
angleStartPoint: (event as MouseClickEvent).worldMouseLocation,
|
||||
};
|
||||
}
|
||||
),
|
||||
[RotateAction.COPY_SELECTION_BEFORE_ROTATE]: assign(({ context }): RotateContext => {
|
||||
const selectedEntities = getSelectedEntities();
|
||||
import { Tool } from '../tools';
|
||||
import { PivotState, createPivotTool } from './pivot-tool';
|
||||
import { angleOf, toRadians } from './transform.helpers';
|
||||
|
||||
// Rotate 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);
|
||||
// Re-rotate the selected entities from the regular entity list, so they do not get used for determining snap points / angle guides
|
||||
deleteEntities(selectedEntities, false);
|
||||
export { PivotState as RotateState };
|
||||
|
||||
// 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 rotated and the original entities also are used for snap points / angle guides
|
||||
|
||||
setSelectedEntityIds([]);
|
||||
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))
|
||||
),
|
||||
};
|
||||
}),
|
||||
[RotateAction.DRAW_TEMP_ROTATE_ENTITIES]: ({ context, event }) => {
|
||||
if (!context.rotationOrigin || !context.angleStartPoint) {
|
||||
throw new Error(
|
||||
'[ROTATE] Calling draw temp rotate entities without a base start point or base end point'
|
||||
);
|
||||
}
|
||||
|
||||
const angleEndpoint = (event as DrawEvent).drawController.getWorldMouseLocation();
|
||||
|
||||
// Draw all selected entities according to rotate vector, so the user gets visual feedback of where the entities will be end up after rotating
|
||||
const rotatedEntities = compact(
|
||||
context.originalSelectedEntities.map((entity) => cloneKeepingLook(entity, true))
|
||||
);
|
||||
rotateEntities(
|
||||
rotatedEntities,
|
||||
context.rotationOrigin,
|
||||
context.angleStartPoint,
|
||||
angleEndpoint
|
||||
);
|
||||
|
||||
setGhostHelperEntities(rotatedEntities);
|
||||
},
|
||||
[RotateAction.ROTATE_SELECTION]: ({ context, event }) => {
|
||||
if (!context.rotationOrigin || !context.angleStartPoint) {
|
||||
throw new Error('[ROTATE] Calling rotate selection without some rotate vector endpoints');
|
||||
}
|
||||
const angleEndpoint = (event as MouseClickEvent).worldMouseLocation;
|
||||
|
||||
// Rotate the entities one final time
|
||||
const rotatedEntities = compact(
|
||||
context.originalSelectedEntities.map((entity) => cloneKeepingLook(entity, true))
|
||||
);
|
||||
rotateEntities(
|
||||
rotatedEntities,
|
||||
context.rotationOrigin,
|
||||
context.angleStartPoint,
|
||||
angleEndpoint
|
||||
);
|
||||
|
||||
// Switch the rotated entities back from the ghost helper entities to the real entities
|
||||
addEntities(rotatedEntities, true);
|
||||
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([]);
|
||||
return {
|
||||
...context,
|
||||
rotationOrigin: null,
|
||||
angleStartPoint: null,
|
||||
originalSelectedEntities: [],
|
||||
};
|
||||
}),
|
||||
[RotateAction.RESTORE_ORIGINAL_ENTITIES]: assign(({ context }): RotateContext => {
|
||||
addEntities(context.originalSelectedEntities, false); // This should already be the last state on the undo stack
|
||||
setGhostHelperEntities([]);
|
||||
setSelectedEntityIds([]);
|
||||
return {
|
||||
...context,
|
||||
rotationOrigin: null,
|
||||
angleStartPoint: null,
|
||||
originalSelectedEntities: [],
|
||||
};
|
||||
}),
|
||||
...selectToolStateMachine.implementations.actions,
|
||||
},
|
||||
}
|
||||
);
|
||||
export const rotateToolStateMachine = createPivotTool({
|
||||
tool: Tool.ROTATE,
|
||||
format: 'ang',
|
||||
value: { text: '회전 각도 지정', remember: 'ROTATE.ANGLE', initial: 0 },
|
||||
reference: { text: '참조 각도를 지정', remember: 'ROTATE.REF', initial: 0 },
|
||||
next: { text: '새 각도 지정', remember: 'ROTATE.NEW', initial: 0 },
|
||||
reject: '유효한 숫자 각도, 두 번째 점 또는 옵션 키워드를 요구함.',
|
||||
copyNotice: ['선택한 객체의 사본을 회전함.', '선택한 객체를 회전함.'],
|
||||
invalidNotice: '유효한 숫자 각도, 두 번째 점 또는 옵션 키워드를 요구함.',
|
||||
measure: angleOf,
|
||||
combine: (next, reference) => next - reference,
|
||||
valid: () => true,
|
||||
apply: (entity, base, degrees) => entity.rotate(base, toRadians(degrees)),
|
||||
});
|
||||
|
||||
@@ -1,24 +0,0 @@
|
||||
import type { Point } from '@flatten-js/core';
|
||||
import type { Entity } from '../entities/Entity';
|
||||
import { pointDistance } from '../helpers/distance-between-points';
|
||||
|
||||
/**
|
||||
* Scale entities by base vector to destination scale vector
|
||||
* @param entities
|
||||
* @param baseVectorStartPoint
|
||||
* @param baseVectorEndPoint
|
||||
* @param scaleVectorEndPoint
|
||||
*/
|
||||
export function scaleEntities(
|
||||
entities: Entity[],
|
||||
baseVectorStartPoint: Point,
|
||||
baseVectorEndPoint: Point,
|
||||
scaleVectorEndPoint: Point
|
||||
) {
|
||||
const scaleFactor =
|
||||
pointDistance(baseVectorStartPoint, scaleVectorEndPoint) /
|
||||
pointDistance(baseVectorStartPoint, baseVectorEndPoint);
|
||||
for (const entity of entities) {
|
||||
entity.scale(baseVectorStartPoint, scaleFactor);
|
||||
}
|
||||
}
|
||||
@@ -27,7 +27,7 @@ function startAtBasePoint() {
|
||||
resetUndoBaseline();
|
||||
setSelectedEntityIds([a.id]);
|
||||
const actor = createActor(scaleToolStateMachine).start();
|
||||
expect(actor.getSnapshot().value).toBe(ScaleState.WAITING_FOR_BASE_VECTOR_START_POINT);
|
||||
expect(actor.getSnapshot().value).toBe(ScaleState.WAITING_FOR_BASE_POINT);
|
||||
actor.send({
|
||||
type: ActorEvent.MOUSE_CLICK,
|
||||
worldMouseLocation: new Point(2, 0),
|
||||
@@ -35,7 +35,7 @@ function startAtBasePoint() {
|
||||
holdingCtrl: false,
|
||||
holdingShift: false,
|
||||
});
|
||||
expect(actor.getSnapshot().value).toBe(ScaleState.WAITING_FOR_BASE_VECTOR_END_POINT);
|
||||
expect(actor.getSnapshot().value).toBe(ScaleState.WAITING_FOR_VALUE);
|
||||
return { actor, a, b };
|
||||
}
|
||||
|
||||
@@ -53,8 +53,8 @@ describe('축척 — 기준점 + 숫자 배율 (226-1)', () => {
|
||||
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);
|
||||
// 한 번 뒤 끝(오토캐드) — 쉬는 상태로
|
||||
expect(actor.getSnapshot().status).toBe('done');
|
||||
});
|
||||
|
||||
it('배율 0.5 = 절반', () => {
|
||||
@@ -84,6 +84,6 @@ describe('축척 — 기준점 + 숫자 배율 (226-1)', () => {
|
||||
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);
|
||||
expect(actor.getSnapshot().value).toBe(ScaleState.WAITING_FOR_VALUE);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,345 +1,25 @@
|
||||
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,
|
||||
NumberInputEvent,
|
||||
StateEvent,
|
||||
ToolContext,
|
||||
} from './tool.types';
|
||||
import { assign, createMachine, sendTo } from 'xstate';
|
||||
import { selectToolStateMachine } from './select-tool';
|
||||
import type { Entity } from '../entities/Entity';
|
||||
import { compact } from 'es-toolkit';
|
||||
import { scaleEntities } from './scale-tool.helpers';
|
||||
import { cloneKeepingLook } from './move-tool.helpers';
|
||||
|
||||
export interface ScaleContext extends ToolContext {
|
||||
baseVectorStartPoint: Point | null;
|
||||
baseVectorEndPoint: Point | null;
|
||||
scaleVectorEndPoint: Point | null;
|
||||
originalSelectedEntities: Entity[];
|
||||
}
|
||||
|
||||
export enum ScaleState {
|
||||
INIT = 'INIT',
|
||||
CHECK_SELECTION = 'CHECK_SELECTION',
|
||||
WAITING_FOR_SELECTION = 'WAITING_FOR_SELECTION',
|
||||
WAITING_FOR_BASE_VECTOR_START_POINT = 'WAITING_FOR_BASE_VECTOR_START_POINT',
|
||||
WAITING_FOR_BASE_VECTOR_END_POINT = 'WAITING_FOR_BASE_VECTOR_END_POINT',
|
||||
WAITING_FOR_SCALE_VECTOR_END_POINT = 'WAITING_FOR_SCALE_VECTOR_END_POINT',
|
||||
}
|
||||
|
||||
export enum ScaleAction {
|
||||
INIT_SCALE_TOOL = 'INIT_SCALE_TOOL',
|
||||
ENABLE_HELPERS = 'ENABLE_HELPERS',
|
||||
RECORD_BASE_VECTOR_START_POINT = 'RECORD_BASE_VECTOR_START_POINT',
|
||||
RECORD_BASE_VECTOR_END_POINT = 'RECORD_BASE_VECTOR_END_POINT',
|
||||
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',
|
||||
}
|
||||
|
||||
/**
|
||||
* Scale tool state machine
|
||||
* This state machine is responsible for scaling entities from a base vector to a certain scale vector
|
||||
* It uses the select tool state machine to select entities to scale
|
||||
* When the user presses enter, the selected entities are marked for scaling
|
||||
* The user can then select a base scale vector start point
|
||||
* The user can then select the base scale vector end point
|
||||
* When the user moves their mouse, the selected entities will be updated to scale according to the vector: base vector => scale vector from start point to mouse location
|
||||
* When the user clicks again, the scale vector end point is selected and the entities are scaled according to the scale vector
|
||||
* SCALE(337 라 · 설계 1-3) — 「기준점 지정:」 → 「축척 비율 지정 또는 [복사(C)/참조(R)] <1.0000>:」 ·
|
||||
* 누름 = 기준점~점 거리 · 숫자 > 0 · R → 「참조 길이 지정 <1.0000>:」(숫자 · 두 점) → 「새 길이 지정 또는 [점(P)] <1.0000>:」 ·
|
||||
* 0 이하 거절 · 비율 기억(SCALE.FACTOR).
|
||||
*/
|
||||
export const scaleToolStateMachine = createMachine(
|
||||
{
|
||||
types: {} as {
|
||||
context: ScaleContext;
|
||||
events: StateEvent;
|
||||
},
|
||||
context: {
|
||||
baseVectorStartPoint: null,
|
||||
baseVectorEndPoint: null,
|
||||
scaleVectorEndPoint: null,
|
||||
originalSelectedEntities: [],
|
||||
type: Tool.SCALE,
|
||||
},
|
||||
initial: ScaleState.INIT,
|
||||
states: {
|
||||
[ScaleState.INIT]: {
|
||||
description: 'Initializing the scale tool',
|
||||
always: {
|
||||
actions: ScaleAction.INIT_SCALE_TOOL,
|
||||
target: ScaleState.CHECK_SELECTION,
|
||||
},
|
||||
},
|
||||
[ScaleState.CHECK_SELECTION]: {
|
||||
description: 'Check if there is something selected',
|
||||
always: [
|
||||
{
|
||||
guard: () => {
|
||||
return getSelectedEntityIds().length > 0;
|
||||
},
|
||||
target: ScaleState.WAITING_FOR_BASE_VECTOR_START_POINT,
|
||||
},
|
||||
{
|
||||
guard: () => {
|
||||
return getSelectedEntityIds().length === 0;
|
||||
},
|
||||
target: ScaleState.WAITING_FOR_SELECTION,
|
||||
},
|
||||
],
|
||||
},
|
||||
[ScaleState.WAITING_FOR_SELECTION]: {
|
||||
description: 'Select what you want to scale',
|
||||
meta: {
|
||||
instructions: '축척할 객체를 선택한 뒤 ENTER.',
|
||||
},
|
||||
invoke: {
|
||||
id: 'selectToolInsideTheScaleTool',
|
||||
src: selectToolStateMachine,
|
||||
onDone: {
|
||||
actions: assign(() => {
|
||||
return {
|
||||
baseVectorStartPoint: null,
|
||||
baseVectorEndPoint: null,
|
||||
scaleVectorEndPoint: null,
|
||||
originalSelectedEntities: [],
|
||||
};
|
||||
}),
|
||||
target: ScaleState.CHECK_SELECTION,
|
||||
},
|
||||
},
|
||||
on: {
|
||||
MOUSE_CLICK: {
|
||||
// Forward the event to the select tool
|
||||
actions: sendTo('selectToolInsideTheScaleTool', ({ event }) => {
|
||||
return event;
|
||||
}),
|
||||
},
|
||||
ESC: {
|
||||
actions: [ScaleAction.DESELECT_ENTITIES, ScaleAction.INIT_SCALE_TOOL],
|
||||
},
|
||||
ENTER: {
|
||||
// Forward the event to the select tool
|
||||
actions: sendTo('selectToolInsideTheScaleTool', ({ event }) => {
|
||||
return event;
|
||||
}),
|
||||
},
|
||||
DRAW: {
|
||||
// Forward the event to the select tool
|
||||
actions: sendTo('selectToolInsideTheScaleTool', ({ event }) => {
|
||||
return event;
|
||||
}),
|
||||
},
|
||||
},
|
||||
},
|
||||
[ScaleState.WAITING_FOR_BASE_VECTOR_START_POINT]: {
|
||||
description: 'Select the origin of the scale operation',
|
||||
meta: {
|
||||
instructions: '기준점을 지정하십시오. (Esc 취소)',
|
||||
},
|
||||
always: {
|
||||
actions: ScaleAction.ENABLE_HELPERS,
|
||||
},
|
||||
on: {
|
||||
MOUSE_CLICK: {
|
||||
actions: [ScaleAction.RECORD_BASE_VECTOR_START_POINT],
|
||||
target: ScaleState.WAITING_FOR_BASE_VECTOR_END_POINT,
|
||||
},
|
||||
ESC: {
|
||||
actions: ScaleAction.DESELECT_ENTITIES,
|
||||
target: ScaleState.INIT,
|
||||
},
|
||||
},
|
||||
},
|
||||
[ScaleState.WAITING_FOR_BASE_VECTOR_END_POINT]: {
|
||||
description: 'Select the end of the base scale line',
|
||||
meta: {
|
||||
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,
|
||||
ScaleAction.COPY_SELECTION_BEFORE_SCALE,
|
||||
],
|
||||
target: ScaleState.WAITING_FOR_SCALE_VECTOR_END_POINT,
|
||||
},
|
||||
ESC: {
|
||||
actions: ScaleAction.RESTORE_ORIGINAL_ENTITIES,
|
||||
target: ScaleState.INIT,
|
||||
},
|
||||
},
|
||||
},
|
||||
[ScaleState.WAITING_FOR_SCALE_VECTOR_END_POINT]: {
|
||||
description: 'Select the end of the scale line',
|
||||
meta: {
|
||||
instructions: '새 길이의 끝을 클릭하십시오. (Esc 취소)',
|
||||
},
|
||||
on: {
|
||||
DRAW: {
|
||||
actions: [ScaleAction.DRAW_TEMP_SCALE_ENTITIES],
|
||||
},
|
||||
MOUSE_CLICK: {
|
||||
actions: [ScaleAction.SCALE_SELECTION, ScaleAction.DESELECT_ENTITIES],
|
||||
target: ScaleState.WAITING_FOR_SELECTION,
|
||||
},
|
||||
ESC: {
|
||||
actions: ScaleAction.RESTORE_ORIGINAL_ENTITIES,
|
||||
target: ScaleState.INIT,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
actions: {
|
||||
[ScaleAction.INIT_SCALE_TOOL]: () => {
|
||||
setShouldDrawHelpers(false);
|
||||
setGhostHelperEntities([]);
|
||||
setAngleGuideOriginPoint(null);
|
||||
},
|
||||
[ScaleAction.ENABLE_HELPERS]: () => {
|
||||
setShouldDrawHelpers(true);
|
||||
},
|
||||
[ScaleAction.RECORD_BASE_VECTOR_START_POINT]: assign(({ context, event }) => {
|
||||
setAngleGuideOriginPoint((event as MouseClickEvent).worldMouseLocation);
|
||||
return {
|
||||
...context,
|
||||
baseVectorStartPoint: (event as MouseClickEvent).worldMouseLocation,
|
||||
};
|
||||
}),
|
||||
[ScaleAction.RECORD_BASE_VECTOR_END_POINT]: assign(({ context, event }) => {
|
||||
return {
|
||||
...context,
|
||||
baseVectorEndPoint: (event as MouseClickEvent).worldMouseLocation,
|
||||
};
|
||||
}),
|
||||
[ScaleAction.COPY_SELECTION_BEFORE_SCALE]: assign(({ context }) => {
|
||||
const selectedEntities = getSelectedEntities();
|
||||
import { Tool } from '../tools';
|
||||
import { PivotState, createPivotTool } from './pivot-tool';
|
||||
import { distanceOf } from './transform.helpers';
|
||||
|
||||
// Scale 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);
|
||||
// Rescale the selected entities from the regular entity list, so they do not get used for determining snap points / angle guides
|
||||
deleteEntities(selectedEntities, false);
|
||||
export { PivotState as ScaleState };
|
||||
|
||||
// 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 scaled and the original entities also are used for snap points / angle guides
|
||||
|
||||
setSelectedEntityIds([]);
|
||||
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))
|
||||
),
|
||||
};
|
||||
}),
|
||||
[ScaleAction.DRAW_TEMP_SCALE_ENTITIES]: ({ context, event }) => {
|
||||
if (!context.baseVectorStartPoint || !context.baseVectorEndPoint) {
|
||||
throw new Error(
|
||||
'[SCALE] Calling draw temp scale entities without a base start point or base end point'
|
||||
);
|
||||
}
|
||||
|
||||
const scaleVectorEndPointTemp = (event as DrawEvent).drawController.getWorldMouseLocation();
|
||||
|
||||
// Draw all selected entities according to scale vector, so the user gets visual feedback of where the entities will be end up after scaling
|
||||
const scaledEntities = compact(
|
||||
context.originalSelectedEntities.map((entity) => cloneKeepingLook(entity, true))
|
||||
);
|
||||
scaleEntities(
|
||||
scaledEntities,
|
||||
context.baseVectorStartPoint,
|
||||
context.baseVectorEndPoint,
|
||||
scaleVectorEndPointTemp
|
||||
);
|
||||
|
||||
setGhostHelperEntities(scaledEntities);
|
||||
},
|
||||
[ScaleAction.SCALE_SELECTION]: ({ context, event }) => {
|
||||
if (!context.baseVectorStartPoint || !context.baseVectorEndPoint) {
|
||||
throw new Error('[SCALE] Calling scale selection without some scale vector endpoints');
|
||||
}
|
||||
const scaleVectorEndPoint = (event as MouseClickEvent).worldMouseLocation;
|
||||
|
||||
// Scale the entities one final time
|
||||
const scaledEntities = compact(
|
||||
context.originalSelectedEntities.map((entity) => cloneKeepingLook(entity, true))
|
||||
);
|
||||
scaleEntities(
|
||||
scaledEntities,
|
||||
context.baseVectorStartPoint,
|
||||
context.baseVectorEndPoint,
|
||||
scaleVectorEndPoint
|
||||
);
|
||||
|
||||
// Switch the scaled entities back from the ghost helper entities to the real entities
|
||||
addEntities(scaledEntities, true);
|
||||
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([]);
|
||||
return {
|
||||
startPoint: null,
|
||||
originalSelectedEntities: [],
|
||||
lastDrawLocation: null,
|
||||
};
|
||||
}),
|
||||
[ScaleAction.RESTORE_ORIGINAL_ENTITIES]: assign(({ context }) => {
|
||||
addEntities(context.originalSelectedEntities, false); // This should already be the last state on the undo stack
|
||||
setGhostHelperEntities([]);
|
||||
setGhostHelperEntities([]);
|
||||
setSelectedEntityIds([]);
|
||||
return {
|
||||
startPoint: null,
|
||||
originalSelectedEntities: [],
|
||||
lastDrawLocation: null,
|
||||
};
|
||||
}),
|
||||
...selectToolStateMachine.implementations.actions,
|
||||
},
|
||||
}
|
||||
);
|
||||
export const scaleToolStateMachine = createPivotTool({
|
||||
tool: Tool.SCALE,
|
||||
format: 'len',
|
||||
value: { text: '축척 비율 지정', remember: 'SCALE.FACTOR', initial: 1 },
|
||||
reference: { text: '참조 길이 지정', remember: 'SCALE.REF', initial: 1 },
|
||||
next: { text: '새 길이 지정', remember: 'SCALE.NEW', initial: 1 },
|
||||
number: { positive: true },
|
||||
copyNotice: ['선택한 객체의 사본을 축척함.', '선택한 객체를 축척함.'],
|
||||
invalidNotice: '값은 양수이고 0이 아니어야 함.',
|
||||
measure: distanceOf,
|
||||
combine: (next, reference) => (reference > 0 ? next / reference : null),
|
||||
valid: (factor) => factor > 0,
|
||||
apply: (entity, base, factor) => entity.scale(base, factor),
|
||||
});
|
||||
|
||||
@@ -0,0 +1,145 @@
|
||||
/**
|
||||
* 옮기기 무리 공용(337 라 · 설계 1-3 · 2-14) — 시작(PICKFIRST) · 고르기 물음 · 끝 · 제자리 바꿔 끼움 · 사본 · 그림자 · 각 · 거리.
|
||||
* 원본은 확정 때까지 도면에 그대로 둠 — Esc · 딴 명령 · 되돌림(N1)에도 제자리 · 두 벌 없음.
|
||||
*/
|
||||
import { Point } from '@flatten-js/core';
|
||||
import { assign, sendTo } from 'xstate';
|
||||
import { GUIDE_LINE_COLOR, GUIDE_LINE_STYLE, GUIDE_LINE_WIDTH } from '../App.consts';
|
||||
import { SELECT_OBJECTS, rememberSelection } from '../commands/prompt';
|
||||
import type { Entity } from '../entities/Entity';
|
||||
import { LineEntity } from '../entities/LineEntity';
|
||||
import {
|
||||
addEntities,
|
||||
getActiveLayerId,
|
||||
getEntities,
|
||||
getSelectedEntities,
|
||||
setAngleGuideOriginPoint,
|
||||
setEntities,
|
||||
setGhostHelperEntities,
|
||||
setSelectedEntityIds,
|
||||
setShouldDrawHelpers,
|
||||
} from '../state';
|
||||
import { cloneKeepingLook } from './move-tool.helpers';
|
||||
import { selectToolStateMachine } from './select-tool';
|
||||
import { END_STATE } from './tool.types';
|
||||
|
||||
export const SELECT_STATE = 'WAITING_FOR_SELECTION';
|
||||
export const SELECT_CHILD = 'selectInsideTransform';
|
||||
|
||||
export interface PickedContext {
|
||||
/** 고른 원본(도면에 그대로 있음) */
|
||||
originals: Entity[];
|
||||
}
|
||||
|
||||
export type Change = (entity: Entity) => void;
|
||||
|
||||
/** 고른 것 받음 — 고르기 이전(P) 열쇠 */
|
||||
const takeSelection = assign(() => {
|
||||
const picked = getSelectedEntities();
|
||||
rememberSelection(picked.map((entity) => entity.id));
|
||||
return { originals: picked };
|
||||
});
|
||||
|
||||
/** 끝 정리 — 그림자 · 기준점 · 고름 풂 */
|
||||
export const endClean = () => {
|
||||
setGhostHelperEntities([]);
|
||||
setAngleGuideOriginPoint(null);
|
||||
setSelectedEntityIds([]);
|
||||
};
|
||||
|
||||
/** 시작 — 고른 것 있으면 받고 next 로(PICKFIRST) · 없으면 고르기 */
|
||||
export const startState = (next: string) => ({
|
||||
entry: () => {
|
||||
setShouldDrawHelpers(false);
|
||||
setGhostHelperEntities([]);
|
||||
setAngleGuideOriginPoint(null);
|
||||
},
|
||||
always: [
|
||||
{ guard: () => getSelectedEntities().length > 0, actions: takeSelection, target: next },
|
||||
{ target: SELECT_STATE },
|
||||
],
|
||||
});
|
||||
|
||||
/**
|
||||
* 고르기 물음(설계 2-14 ④) — 자식 선택 도구에 넘김(ALL · L · P 포함) · 고른 것 없이 Enter = 끝.
|
||||
* `watch` = 넘기기 전 누름 엿보는 액션(자식은 아직 그 누름 전 · STRETCH 걸침 창).
|
||||
*/
|
||||
export const selectionState = (next: string, watch?: unknown) => {
|
||||
const send = sendTo(SELECT_CHILD, ({ event }) => event);
|
||||
const forward = { actions: send };
|
||||
return {
|
||||
meta: { kind: 'selection', prompt: SELECT_OBJECTS },
|
||||
entry: () => setShouldDrawHelpers(false),
|
||||
invoke: {
|
||||
id: SELECT_CHILD,
|
||||
src: selectToolStateMachine,
|
||||
onDone: [
|
||||
{ guard: () => getSelectedEntities().length === 0, target: END_STATE },
|
||||
{ actions: takeSelection, target: next },
|
||||
],
|
||||
},
|
||||
on: {
|
||||
MOUSE_CLICK: watch ? { actions: [watch, send] } : forward,
|
||||
ENTER: forward,
|
||||
DRAW: forward,
|
||||
'OPTION.*': forward,
|
||||
ESC: { target: END_STATE },
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
/** 끝 상태 — 들면 쉬는 상태로(end-command) */
|
||||
export const endState = { type: 'final' as const, entry: endClean };
|
||||
|
||||
/** 바꾼 사본(id 그대로) — 제자리 바꿔 끼울 것 */
|
||||
export const changedCopies = (originals: Entity[], change: Change): Entity[] =>
|
||||
originals.map((entity) => {
|
||||
const copy = cloneKeepingLook(entity, true);
|
||||
change(copy);
|
||||
return copy;
|
||||
});
|
||||
|
||||
/** 원본 자리에 바꾼 것 끼움 — 순서 · id 그대로 · 되돌림 한 칸 */
|
||||
export function replaceInPlace(copies: Entity[]) {
|
||||
if (!copies.length) return;
|
||||
const byId = new Map(copies.map((copy) => [copy.id, copy]));
|
||||
setEntities(
|
||||
getEntities().map((entity) => byId.get(entity.id) ?? entity),
|
||||
true
|
||||
);
|
||||
}
|
||||
|
||||
/** 새 사본 더함(새 id) — 되돌림 한 칸 */
|
||||
export function addCopies(originals: Entity[], change: Change): Entity[] {
|
||||
const copies = originals.map((entity) => {
|
||||
const copy = cloneKeepingLook(entity);
|
||||
change(copy);
|
||||
return copy;
|
||||
});
|
||||
if (copies.length) addEntities(copies, true);
|
||||
return copies;
|
||||
}
|
||||
|
||||
/** 기준점 → 커서 점선 */
|
||||
export function guideLine(from: Point, to: Point): LineEntity {
|
||||
const line = new LineEntity(getActiveLayerId(), from, to);
|
||||
line.lineColor = GUIDE_LINE_COLOR;
|
||||
line.lineWidth = GUIDE_LINE_WIDTH;
|
||||
line.lineDash = GUIDE_LINE_STYLE;
|
||||
return line;
|
||||
}
|
||||
|
||||
/** 미리보기 — 원본은 두고 바꾼 그림자만 */
|
||||
export const showGhosts = (originals: Entity[], change: Change, ...extra: Entity[]) =>
|
||||
setGhostHelperEntities([...extra, ...changedCopies(originals, change)]);
|
||||
|
||||
/** a → b 방향 각(도 · 동쪽 0 · 반시계 +) */
|
||||
export const angleOf = (a: Point, b: Point): number =>
|
||||
(Math.atan2(b.y - a.y, b.x - a.x) * 180) / Math.PI;
|
||||
|
||||
export const distanceOf = (a: Point, b: Point): number => Math.hypot(b.x - a.x, b.y - a.y);
|
||||
|
||||
export const toRadians = (degrees: number) => (degrees * Math.PI) / 180;
|
||||
|
||||
/** 변위 → 점(좌표 꼴) */
|
||||
export const asVector = (from: Point, to: Point) => new Point(to.x - from.x, to.y - from.y);
|
||||
@@ -0,0 +1,318 @@
|
||||
/**
|
||||
* 337 라 — 옮기기 무리 7(MOVE · COPY · ROTATE · SCALE · MIRROR · STRETCH · ARRAY) + PEDIT 고르기 · 오토캐드 물음 차례.
|
||||
* 설계 `docs/raw/조사/2026-10-15_337_CAD오토캐드조작_설계.md` 1-3 · 2-14 · 6-3 라 줄.
|
||||
* 시험 쪽 flatten-js 는 다른 사본 — 점은 앱 코드(LINE · 친 좌표)로만 만들고 숫자로 판정.
|
||||
* 실행: cd B07_DesignDetail/openwebcad && npx vitest run --dir ../../resources/tester/cad transform-acad
|
||||
*/
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
vi.mock("react-toastify", () => ({
|
||||
toast: Object.assign(vi.fn(), { error: vi.fn(), info: vi.fn(), warn: vi.fn(), success: vi.fn() }),
|
||||
}));
|
||||
|
||||
import { remember } from "../../../B07_DesignDetail/openwebcad/src/commands/prompt";
|
||||
import { runCommandInput } from "../../../B07_DesignDetail/openwebcad/src/commands/run-command";
|
||||
import type { Entity } from "../../../B07_DesignDetail/openwebcad/src/entities/Entity";
|
||||
import {
|
||||
getSelectedEntityIds,
|
||||
setSelectedEntityIds,
|
||||
} from "../../../B07_DesignDetail/openwebcad/src/state";
|
||||
import { Tool } from "../../../B07_DesignDetail/openwebcad/src/tools";
|
||||
import {
|
||||
activeTool,
|
||||
click,
|
||||
entities,
|
||||
initIdle,
|
||||
key,
|
||||
move,
|
||||
noticeLine,
|
||||
promptLine,
|
||||
type,
|
||||
} from "./helpers/cad-drive";
|
||||
|
||||
type Xy = { x: number; y: number };
|
||||
const r = (value: number) => Math.round(value * 1000) / 1000 + 0;
|
||||
const ends = (entity: Entity) => {
|
||||
const line = entity as unknown as { getStartPoint(): Xy; getEndPoint(): Xy };
|
||||
const a = line.getStartPoint();
|
||||
const b = line.getEndPoint();
|
||||
return [r(a.x), r(a.y), r(b.x), r(b.y)];
|
||||
};
|
||||
/** 선들 끝점(정렬) */
|
||||
const lineEnds = () =>
|
||||
entities("Line")
|
||||
.map(ends)
|
||||
.sort((p, q) => p[0] - q[0] || p[1] - q[1] || p[2] - q[2] || p[3] - q[3]);
|
||||
const enter = () => key("Enter");
|
||||
const esc = () => key("Escape");
|
||||
|
||||
/** 선 하나 그림(쉼으로 돌아옴) */
|
||||
const line = (a: string, b: string) => {
|
||||
type("l");
|
||||
type(a);
|
||||
type(b);
|
||||
esc();
|
||||
};
|
||||
/** 선 그리고 고름 */
|
||||
const lineSelected = (a = "0,0", b = "20,0") => {
|
||||
line(a, b);
|
||||
setSelectedEntityIds(entities().map((entity) => entity.id));
|
||||
};
|
||||
|
||||
/** 세션 기억 — 시험마다 처음 값으로 */
|
||||
const KEYS = [
|
||||
"MOVE.DISP",
|
||||
"STRETCH.DISP",
|
||||
"COPYMODE",
|
||||
"ROTATE.ANGLE",
|
||||
"ROTATE.REF",
|
||||
"ROTATE.NEW",
|
||||
"SCALE.FACTOR",
|
||||
"SCALE.REF",
|
||||
"SCALE.NEW",
|
||||
"ARRAYTYPE",
|
||||
"ARRAY.COLS",
|
||||
"ARRAY.ROWS",
|
||||
"ARRAY.ITEMS",
|
||||
"ARRAY.FILL",
|
||||
];
|
||||
|
||||
beforeEach(() => {
|
||||
initIdle();
|
||||
for (const name of KEYS) remember(name, undefined);
|
||||
});
|
||||
|
||||
describe("MOVE(1-3)", () => {
|
||||
it("물음 차례 · 두 번째 점 = 기준점에서 변위 · 한 번 뒤 끝 · 고름 풂", () => {
|
||||
lineSelected();
|
||||
const id = entities()[0].id;
|
||||
type("m");
|
||||
expect(promptLine()).toBe("기준점 지정 또는 [변위(D)] <변위>:");
|
||||
type("0,0");
|
||||
expect(promptLine()).toBe("두 번째 점 지정 또는 <첫 번째 점을 변위로 사용>:");
|
||||
type("@10,5");
|
||||
expect(lineEnds()).toEqual([[10, 5, 30, 5]]);
|
||||
expect(entities()[0].id).toBe(id);
|
||||
expect(activeTool()).toBe(Tool.SELECT);
|
||||
expect(getSelectedEntityIds()).toEqual([]);
|
||||
});
|
||||
|
||||
it("빈 Enter 두 번째 점 = 첫 점 좌표가 변위", () => {
|
||||
lineSelected();
|
||||
type("move");
|
||||
type("3,4");
|
||||
enter();
|
||||
expect(lineEnds()).toEqual([[3, 4, 23, 4]]);
|
||||
expect(activeTool()).toBe(Tool.SELECT);
|
||||
});
|
||||
|
||||
it("D · 기준점 빈 Enter = <변위> · 변위 기억(MOVE.DISP)", () => {
|
||||
lineSelected();
|
||||
type("m");
|
||||
type("d");
|
||||
expect(promptLine()).toBe("변위 지정 <0.0000, 0.0000>:");
|
||||
type("10,5");
|
||||
expect(lineEnds()).toEqual([[10, 5, 30, 5]]);
|
||||
setSelectedEntityIds(entities().map((entity) => entity.id));
|
||||
type("m");
|
||||
enter(); // 기준점 빈 Enter = 변위(D)
|
||||
expect(promptLine()).toBe("변위 지정 <10.0000, 5.0000>:");
|
||||
enter(); // 기억한 변위
|
||||
expect(lineEnds()).toEqual([[20, 10, 40, 10]]);
|
||||
});
|
||||
|
||||
it("Esc = 제자리 끝(기준점 뒤 · 미리보기 중)", () => {
|
||||
lineSelected();
|
||||
type("m");
|
||||
type("0,0");
|
||||
move(50, 50);
|
||||
esc();
|
||||
expect(lineEnds()).toEqual([[0, 0, 20, 0]]);
|
||||
expect(activeTool()).toBe(Tool.SELECT);
|
||||
expect(getSelectedEntityIds()).toEqual([]);
|
||||
});
|
||||
|
||||
it("고른 것 없음 → 「객체 선택:」 · 누름 고름 · Enter → 기준점 · 빈 고르기 Enter = 끝", () => {
|
||||
line("0,0", "20,0");
|
||||
type("m");
|
||||
expect(promptLine()).toBe("객체 선택:");
|
||||
enter();
|
||||
expect(activeTool()).toBe(Tool.SELECT);
|
||||
type("m");
|
||||
click(10, 0);
|
||||
enter();
|
||||
expect(promptLine()).toBe("기준점 지정 또는 [변위(D)] <변위>:");
|
||||
type("0,0");
|
||||
type("0,10");
|
||||
expect(lineEnds()).toEqual([[0, 10, 20, 10]]);
|
||||
});
|
||||
|
||||
it("점 묻는 중 명령 이름 = 거절(도구 그대로)", () => {
|
||||
lineSelected();
|
||||
type("m");
|
||||
type("0,0");
|
||||
type("c");
|
||||
expect(noticeLine()).toBe("점 또는 옵션 키워드를 요구함.");
|
||||
expect(activeTool()).toBe(Tool.MOVE);
|
||||
});
|
||||
|
||||
it("N1 — 기준점 뒤 딴 명령 · Ctrl+Z → 원본 제자리 · 두 벌 없음", () => {
|
||||
lineSelected();
|
||||
const id = entities()[0].id;
|
||||
type("m");
|
||||
type("0,0");
|
||||
move(40, 40);
|
||||
runCommandInput("CIRCLE");
|
||||
expect(activeTool()).toBe(Tool.CIRCLE);
|
||||
expect(lineEnds()).toEqual([[0, 0, 20, 0]]);
|
||||
expect(entities()[0].id).toBe(id);
|
||||
esc();
|
||||
setSelectedEntityIds([id]);
|
||||
type("m");
|
||||
type("0,0");
|
||||
key("z", { ctrlKey: true, code: "KeyZ" });
|
||||
expect(activeTool()).toBe(Tool.SELECT);
|
||||
expect(entities().filter((entity) => entity.id === id)).toHaveLength(
|
||||
entities().length > 0 ? 1 : 0,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("ROTATE(1-3)", () => {
|
||||
it("물음 차례 · 숫자 = 반시계 + · 한 번 뒤 끝 · 각 기억", () => {
|
||||
lineSelected("10,0", "20,0");
|
||||
type("ro");
|
||||
expect(promptLine()).toBe("기준점 지정:");
|
||||
type("0,0");
|
||||
expect(promptLine()).toBe("회전 각도 지정 또는 [복사(C)/참조(R)] <0>:");
|
||||
type("90");
|
||||
expect(lineEnds()).toEqual([[0, 10, 0, 20]]);
|
||||
expect(activeTool()).toBe(Tool.SELECT);
|
||||
setSelectedEntityIds(entities().map((entity) => entity.id));
|
||||
type("ro");
|
||||
type("0,0");
|
||||
expect(promptLine()).toBe("회전 각도 지정 또는 [복사(C)/참조(R)] <90>:");
|
||||
enter(); // 기억한 90
|
||||
expect(lineEnds()).toEqual([[-10, 0, -20, 0]]);
|
||||
});
|
||||
|
||||
it("누름 한 번 = 기준점에서 그 점 방향 각", () => {
|
||||
lineSelected("10,0", "20,0");
|
||||
type("ro");
|
||||
type("0,0");
|
||||
type("-30,30"); // 135°
|
||||
const [x1, y1] = lineEnds()[0];
|
||||
expect([x1, y1]).toEqual([r(-10 / Math.SQRT2), r(10 / Math.SQRT2)]);
|
||||
});
|
||||
|
||||
it("C = 사본 회전(원본 둠)", () => {
|
||||
lineSelected("10,0", "20,0");
|
||||
type("ro");
|
||||
type("0,0");
|
||||
type("c");
|
||||
expect(activeTool()).toBe(Tool.ROTATE);
|
||||
type("90");
|
||||
expect(lineEnds()).toEqual([
|
||||
[0, 10, 0, 20],
|
||||
[10, 0, 20, 0],
|
||||
]);
|
||||
});
|
||||
|
||||
it("R — 참조 두 점(@ = 기준점) → 새 각도 · 참조 숫자 · P 두 점", () => {
|
||||
lineSelected("10,0", "20,0");
|
||||
type("ro");
|
||||
type("0,0");
|
||||
type("r");
|
||||
expect(promptLine()).toBe("참조 각도를 지정 <0>:");
|
||||
type("@0,0"); // 기준점
|
||||
expect(promptLine()).toBe("두 번째 점 지정:");
|
||||
type("10,10"); // 참조 45°
|
||||
expect(promptLine()).toBe("새 각도 지정 또는 [점(P)] <0>:");
|
||||
type("90"); // 90 − 45
|
||||
const s = 10 / Math.SQRT2;
|
||||
expect(lineEnds()).toEqual([[r(s), r(s), r(2 * s), r(2 * s)]]);
|
||||
|
||||
setSelectedEntityIds(entities().map((entity) => entity.id));
|
||||
type("ro");
|
||||
type("0,0");
|
||||
type("r");
|
||||
expect(promptLine()).toBe("참조 각도를 지정 <45>:");
|
||||
type("45");
|
||||
type("p");
|
||||
expect(promptLine()).toBe("첫 번째 점 지정:");
|
||||
type("0,0");
|
||||
type("-5,0"); // 새 180 → 135 돌림
|
||||
expect(lineEnds()).toEqual([[-10, 0, -20, 0]]);
|
||||
});
|
||||
|
||||
it("Esc = 제자리 끝 · 글 = 각 거절", () => {
|
||||
lineSelected("10,0", "20,0");
|
||||
type("ro");
|
||||
type("0,0");
|
||||
type("abc");
|
||||
expect(noticeLine()).toBe("유효한 숫자 각도, 두 번째 점 또는 옵션 키워드를 요구함.");
|
||||
move(0, 40);
|
||||
esc();
|
||||
expect(lineEnds()).toEqual([[10, 0, 20, 0]]);
|
||||
expect(activeTool()).toBe(Tool.SELECT);
|
||||
});
|
||||
});
|
||||
|
||||
describe("SCALE(1-3)", () => {
|
||||
it("물음 차례 · 숫자 비율 · 점 = 거리 · 기억", () => {
|
||||
lineSelected();
|
||||
type("sc");
|
||||
expect(promptLine()).toBe("기준점 지정:");
|
||||
type("0,0");
|
||||
expect(promptLine()).toBe("축척 비율 지정 또는 [복사(C)/참조(R)] <1.0000>:");
|
||||
type("2");
|
||||
expect(lineEnds()).toEqual([[0, 0, 40, 0]]);
|
||||
expect(activeTool()).toBe(Tool.SELECT);
|
||||
setSelectedEntityIds(entities().map((entity) => entity.id));
|
||||
type("sc");
|
||||
type("0,0");
|
||||
expect(promptLine()).toBe("축척 비율 지정 또는 [복사(C)/참조(R)] <2.0000>:");
|
||||
type("0,0.5"); // 거리 0.5
|
||||
expect(lineEnds()).toEqual([[0, 0, 20, 0]]);
|
||||
});
|
||||
|
||||
it("0 · 음수 거절(도구 그대로) · C = 사본", () => {
|
||||
lineSelected();
|
||||
type("sc");
|
||||
type("0,0");
|
||||
type("0");
|
||||
expect(noticeLine()).toBe("값은 양수이고 0이 아니어야 함.");
|
||||
type("-2");
|
||||
expect(noticeLine()).toBe("값은 양수이고 0이 아니어야 함.");
|
||||
expect(activeTool()).toBe(Tool.SCALE);
|
||||
type("c");
|
||||
type("0.5");
|
||||
expect(lineEnds()).toEqual([
|
||||
[0, 0, 10, 0],
|
||||
[0, 0, 20, 0],
|
||||
]);
|
||||
});
|
||||
|
||||
it("R — 참조 숫자 → 새 길이 · 참조 두 점 → P 두 점", () => {
|
||||
lineSelected();
|
||||
type("sc");
|
||||
type("0,0");
|
||||
type("r");
|
||||
expect(promptLine()).toBe("참조 길이 지정 <1.0000>:");
|
||||
type("10");
|
||||
expect(promptLine()).toBe("새 길이 지정 또는 [점(P)] <1.0000>:");
|
||||
type("5");
|
||||
expect(lineEnds()).toEqual([[0, 0, 10, 0]]);
|
||||
|
||||
setSelectedEntityIds(entities().map((entity) => entity.id));
|
||||
type("sc");
|
||||
type("0,0");
|
||||
type("r");
|
||||
type("0,0");
|
||||
type("10,0"); // 참조 10
|
||||
type("p");
|
||||
type("0,0");
|
||||
type("0,30"); // 새 30
|
||||
expect(lineEnds()).toEqual([[0, 0, 30, 0]]);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user