feat(CAD): 337-5 라 옮기기 무리 2 단계 — COPY · MIRROR · STRETCH · ARRAY · PEDIT 고르기
- COPY: 기준점 [변위(D)/모드(O)/다중(M)] · 두 번째 점 [배열(A)] → [배열(A)/종료(E)/명령 취소(U)] <종료> 되풀이 · 첫 빈 Enter = 변위 · COPYMODE 기억 · A 항목 수 · F 맞춤 - MIRROR: 대칭선 두 점 → 원본 객체를 지우시겠습니까? [예(Y)/아니오(N)] <아니오> · Esc = 안 만듦 - STRETCH: MOVE 틀 + 걸침 창 안 꼭짓점만(선 · 폴리선 토막 · 사각형) · 창 안 통째 · 하나씩 고른 것 = 이동 · STRETCH.DISP - ARRAY: 유형 [직사각형(R)/원형(PO)] <직사각형>(ARRAYTYPE) → 편집 물음 되풀이(COU · S · COL · R · X / I · A · F · ROT · X) · 미리보기 4 × 3 · 6 개 360° · 처음 간격 범위 1.5 배 · Esc = 안 만듦(N17 없어짐) - PEDIT: 공용 고르기 상태(객체 선택 · 빈 Enter · Esc = 끝 · 묶은 뒤 끝) - 시험 transform-acad 29 + ALL · L · P 1(선택 도구 처리 뒤 돎) Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TteK8mfYZetk5P3bEZkyVZ
This commit is contained in:
@@ -0,0 +1,169 @@
|
||||
/** ARRAY 계산 · 물음(337 라 · 설계 1-3) — 직사각형 열 × 행 · 원형 항목 · 채울 각도 · 항목 회전 */
|
||||
import type { Box, Point } from '@flatten-js/core';
|
||||
import type { OptionSpec, PromptSpec } from '../commands/prompt';
|
||||
import type { Entity } from '../entities/Entity';
|
||||
import { cloneKeepingLook } from './move-tool.helpers';
|
||||
import type { PickedContext } from './transform.helpers';
|
||||
|
||||
export interface ArrayValues extends PickedContext {
|
||||
columns: number;
|
||||
rows: number;
|
||||
columnSpacing: number;
|
||||
rowSpacing: number;
|
||||
center: Point | null;
|
||||
items: number;
|
||||
/** 채울 각도(도 · + 반시계) */
|
||||
fill: number;
|
||||
rotateItems: boolean;
|
||||
}
|
||||
|
||||
const opt = (key: string, kw: string, label: string, en = key): OptionSpec => ({
|
||||
key,
|
||||
kw,
|
||||
en,
|
||||
label,
|
||||
});
|
||||
|
||||
export const ARRAY_OPT = {
|
||||
RECTANGULAR: opt('RECTANGULAR', 'R', '직사각형'),
|
||||
POLAR: opt('POLAR', 'PO', '원형'),
|
||||
PATH: opt('PATH', 'PA', '경로'),
|
||||
COUNT: opt('COUNT', 'COU', '개수'),
|
||||
SPACING: opt('SPACING', 'S', '간격두기'),
|
||||
COLUMNS: opt('COLUMNS', 'COL', '열'),
|
||||
ROWS: opt('ROWS', 'R', '행'),
|
||||
POLAR_ROWS: opt('ROWS', 'ROW', '행'),
|
||||
ITEMS: opt('ITEMS', 'I', '항목'),
|
||||
ANGLE: opt('ANGLE', 'A', '사이의 각도'),
|
||||
FILL: opt('FILL', 'F', '채울 각도'),
|
||||
ROTATE: opt('ROTATE', 'ROT', '항목 회전'),
|
||||
EXIT: opt('EXIT', 'X', '종료'),
|
||||
ASSOCIATIVE: opt('ASSOCIATIVE', 'AS', '연관'),
|
||||
BASE: opt('BASE', 'B', '기준점'),
|
||||
LEVELS: opt('LEVELS', 'L', '수준'),
|
||||
AXIS: opt('AXIS', 'A', '회전축'),
|
||||
};
|
||||
|
||||
const O = ARRAY_OPT;
|
||||
const COUNT_RULE = { int: true, min: 1, max: 32767 } as const;
|
||||
|
||||
/** 항목 사이 각 — 채울 각이 한 바퀴면 항목 수로 나눔 · 아니면 사이 수로 */
|
||||
export const angleBetween = (values: Pick<ArrayValues, 'items' | 'fill'>): number => {
|
||||
if (Math.abs(values.fill) >= 360) return values.fill / values.items;
|
||||
return values.items > 1 ? values.fill / (values.items - 1) : 0;
|
||||
};
|
||||
|
||||
const value = (text: string, remember: string, initial: number, format?: 'int' | 'ang') => ({
|
||||
text,
|
||||
default: { kind: 'value' as const, remember, initial, ...(format ? { format } : {}) },
|
||||
});
|
||||
|
||||
/** 물음 — 함수 꼴은 지금 값(빈 context 면 처음 값) */
|
||||
export const ARRAY_PROMPT = {
|
||||
type: (fallback: string): PromptSpec => ({
|
||||
text: '배열 유형 입력',
|
||||
options: [O.RECTANGULAR, O.POLAR],
|
||||
absent: [O.PATH],
|
||||
default: { kind: 'option', key: fallback },
|
||||
}),
|
||||
center: { text: '배열의 중심점 지정', absent: [O.BASE, O.AXIS] } satisfies PromptSpec,
|
||||
editRect: {
|
||||
text: '그립을 선택하여 배열을 편집하거나',
|
||||
options: [O.COUNT, O.SPACING, O.COLUMNS, O.ROWS, O.EXIT],
|
||||
absent: [O.ASSOCIATIVE, O.BASE, O.LEVELS],
|
||||
default: { kind: 'option', key: O.EXIT.key },
|
||||
} satisfies PromptSpec,
|
||||
editPolar: {
|
||||
text: '그립을 선택하여 배열을 편집하거나',
|
||||
options: [O.ITEMS, O.ANGLE, O.FILL, O.ROTATE, O.EXIT],
|
||||
absent: [O.ASSOCIATIVE, O.BASE, O.POLAR_ROWS, O.LEVELS],
|
||||
default: { kind: 'option', key: O.EXIT.key },
|
||||
} satisfies PromptSpec,
|
||||
columns: (v: Partial<ArrayValues>): PromptSpec => ({
|
||||
...value('열 수 입력', 'ARRAY.COLS', v.columns ?? 4, 'int'),
|
||||
number: COUNT_RULE,
|
||||
}),
|
||||
rows: (v: Partial<ArrayValues>): PromptSpec => ({
|
||||
...value('행 수 입력', 'ARRAY.ROWS', v.rows ?? 3, 'int'),
|
||||
number: COUNT_RULE,
|
||||
}),
|
||||
columnSpacing: (v: Partial<ArrayValues>): PromptSpec =>
|
||||
value('열 사이의 거리 지정', 'ARRAY.COLDIST', v.columnSpacing ?? 1),
|
||||
rowSpacing: (v: Partial<ArrayValues>): PromptSpec =>
|
||||
value('행 사이의 거리 지정', 'ARRAY.ROWDIST', v.rowSpacing ?? 1),
|
||||
items: (v: Partial<ArrayValues>): PromptSpec => ({
|
||||
...value('배열의 항목 수 입력', 'ARRAY.ITEMS', v.items ?? 6, 'int'),
|
||||
number: COUNT_RULE,
|
||||
}),
|
||||
angle: (v: Partial<ArrayValues>): PromptSpec =>
|
||||
value(
|
||||
'항목 사이의 각도 지정',
|
||||
'ARRAY.ANGLE',
|
||||
angleBetween({ items: v.items ?? 6, fill: v.fill ?? 360 }),
|
||||
'ang'
|
||||
),
|
||||
fill: (v: Partial<ArrayValues>): PromptSpec =>
|
||||
value('채울 각도 지정(+=ccw, -=cw)', 'ARRAY.FILL', v.fill ?? 360, 'ang'),
|
||||
rotateItems: (v: Partial<ArrayValues>): PromptSpec => ({
|
||||
text: '배열된 항목을 회전하시겠습니까?',
|
||||
options: [opt('YES', 'Y', '예'), opt('NO', 'N', '아니오')],
|
||||
default: { kind: 'option', key: v.rotateItems === false ? 'NO' : 'YES' },
|
||||
}),
|
||||
};
|
||||
|
||||
/** 고른 것 범위 — 처음 간격 = 폭 · 높이의 1.5 배(0 이면 다른 쪽 · 1) */
|
||||
export function initialSpacing(originals: Entity[]): { column: number; row: number } {
|
||||
const box = originals.reduce<Box | null>((all, entity) => {
|
||||
const next = entity.getBoundingBox();
|
||||
return all ? all.merge(next) : next;
|
||||
}, null);
|
||||
const width = box ? box.xmax - box.xmin : 0;
|
||||
const height = box ? box.ymax - box.ymin : 0;
|
||||
const fallback = Math.max(width, height) || 1;
|
||||
return { column: 1.5 * (width || fallback), row: 1.5 * (height || fallback) };
|
||||
}
|
||||
|
||||
/** 고른 것 범위 가운데 — 항목 회전 끔일 때 옮김 기준 */
|
||||
function selectionCenter(originals: Entity[]): Point | null {
|
||||
const boxes = originals.map((entity) => entity.getBoundingBox());
|
||||
if (!boxes.length) return null;
|
||||
return boxes.reduce((all, next) => all.merge(next)).center;
|
||||
}
|
||||
|
||||
/** 직사각형 — (0,0) 원본 빼고 열 × 행 사본 */
|
||||
export function rectangularCopies(values: ArrayValues): Entity[] {
|
||||
const copies: Entity[] = [];
|
||||
for (let row = 0; row < values.rows; row++) {
|
||||
for (let column = 0; column < values.columns; column++) {
|
||||
if (!row && !column) continue;
|
||||
for (const entity of values.originals) {
|
||||
const copy = cloneKeepingLook(entity);
|
||||
copy.move(column * values.columnSpacing, row * values.rowSpacing);
|
||||
copies.push(copy);
|
||||
}
|
||||
}
|
||||
}
|
||||
return copies;
|
||||
}
|
||||
|
||||
/** 원형 — 중심 둘레 항목 수 − 1 사본 · 항목 회전 끔이면 돌지 않고 자리만 */
|
||||
export function polarCopies(values: ArrayValues, center = values.center): Entity[] {
|
||||
if (!center) return [];
|
||||
const step = (angleBetween(values) * Math.PI) / 180;
|
||||
const anchor = selectionCenter(values.originals);
|
||||
const copies: Entity[] = [];
|
||||
for (let index = 1; index < values.items; index++) {
|
||||
const radians = step * index;
|
||||
for (const entity of values.originals) {
|
||||
const copy = cloneKeepingLook(entity);
|
||||
if (values.rotateItems || !anchor) {
|
||||
copy.rotate(center, radians);
|
||||
} else {
|
||||
const moved = anchor.rotate(radians, center);
|
||||
copy.move(moved.x - anchor.x, moved.y - anchor.y);
|
||||
}
|
||||
copies.push(copy);
|
||||
}
|
||||
}
|
||||
return copies;
|
||||
}
|
||||
@@ -1,369 +1,293 @@
|
||||
import { type Point, Vector } from '@flatten-js/core';
|
||||
import { assign, createMachine, sendTo } from 'xstate';
|
||||
import { GUIDE_LINE_COLOR, GUIDE_LINE_STYLE, GUIDE_LINE_WIDTH, TO_RADIANS } from '../App.consts.ts';
|
||||
import type { Entity } from '../entities/Entity';
|
||||
import { LineEntity } from '../entities/LineEntity.ts';
|
||||
import { getPointFromEvent } from '../helpers/get-point-from-event.ts';
|
||||
import {
|
||||
addEntities,
|
||||
getActiveLayerId,
|
||||
getSelectedEntities,
|
||||
getSelectedEntityIds,
|
||||
setAngleGuideOriginPoint,
|
||||
setGhostHelperEntities,
|
||||
setSelectedEntityIds,
|
||||
setShouldDrawHelpers,
|
||||
} from '../state';
|
||||
/**
|
||||
* ARRAY(337 라 · 설계 1-3) — 「객체 선택:」 → 「배열 유형 입력 [직사각형(R)/원형(PO)] <직사각형>:」(ARRAYTYPE 기억) →
|
||||
* 직사각형: 미리보기 4열 × 3행 → 「그립을 선택하여 배열을 편집하거나 [개수(COU)/간격두기(S)/열(COL)/행(R)/종료(X)] <종료>:」 되풀이 ·
|
||||
* 원형: 「배열의 중심점 지정:」 → 미리보기 6개 360° → 「… [항목(I)/사이의 각도(A)/채울 각도(F)/항목 회전(ROT)/종료(X)] <종료>:」 되풀이.
|
||||
* 종료(X) · 빈 Enter = 만듦 · Esc = 안 만듦 · 처음 간격 = 고른 것 범위 폭 · 높이의 1.5 배.
|
||||
*/
|
||||
import type { Point } from '@flatten-js/core';
|
||||
import { assign, createMachine } from 'xstate';
|
||||
import { recall, remember } from '../commands/prompt';
|
||||
import { getPointFromEvent } from '../helpers/get-point-from-event';
|
||||
import { addEntities, setGhostHelperEntities, setShouldDrawHelpers } from '../state';
|
||||
import { Tool } from '../tools';
|
||||
import { CopyAction } from './copy-tool.ts';
|
||||
import { cloneKeepingLook } from './move-tool.helpers.ts';
|
||||
import { selectToolStateMachine } from './select-tool.ts';
|
||||
import type {
|
||||
AbsolutePointInputEvent,
|
||||
DrawEvent,
|
||||
MouseClickEvent,
|
||||
NumberInputEvent,
|
||||
StateEvent,
|
||||
TextInputEvent,
|
||||
ToolContext,
|
||||
import {
|
||||
ARRAY_OPT,
|
||||
ARRAY_PROMPT,
|
||||
type ArrayValues,
|
||||
angleBetween,
|
||||
initialSpacing,
|
||||
polarCopies,
|
||||
rectangularCopies,
|
||||
} from './array-tool.helpers';
|
||||
import {
|
||||
type DrawEvent,
|
||||
END_STATE,
|
||||
type NumberInputEvent,
|
||||
type PointInputEvent,
|
||||
type StateEvent,
|
||||
type ToolContext,
|
||||
} from './tool.types';
|
||||
import { endState, selectionState, startState } from './transform.helpers';
|
||||
|
||||
export enum ArrayState {
|
||||
INIT = 'INIT',
|
||||
WAITING_FOR_SELECTION = 'WAITING_FOR_SELECTION',
|
||||
CHECK_SELECTION = 'CHECK_SELECTION',
|
||||
ASK_COPY_MODE = 'ASK_COPY_MODE',
|
||||
ASK_NUMBER_OF_COPIES = 'ASK_NUMBER_OF_COPIES',
|
||||
LINEAR_WAITING_FOR_START_POINT = 'LINEAR_WAITING_FOR_START_POINT',
|
||||
LINEAR_WAITING_FOR_END_POINT = 'LINEAR_WAITING_FOR_END_POINT',
|
||||
RADIAL_WAITING_FOR_PIVOT_POINT = 'RADIAL_WAITING_FOR_PIVOT_POINT',
|
||||
RADIAL_WAITING_FOR_ANGLE = 'RADIAL_WAITING_FOR_ANGLE',
|
||||
EXECUTE_COPY = 'EXECUTE_COPY',
|
||||
WAITING_FOR_TYPE = 'WAITING_FOR_TYPE',
|
||||
WAITING_FOR_CENTER = 'WAITING_FOR_CENTER',
|
||||
EDIT_RECTANGULAR = 'EDIT_RECTANGULAR',
|
||||
EDIT_POLAR = 'EDIT_POLAR',
|
||||
WAITING_FOR_COLUMNS = 'WAITING_FOR_COLUMNS',
|
||||
WAITING_FOR_ROWS = 'WAITING_FOR_ROWS',
|
||||
WAITING_FOR_COLUMN_SPACING = 'WAITING_FOR_COLUMN_SPACING',
|
||||
WAITING_FOR_ROW_SPACING = 'WAITING_FOR_ROW_SPACING',
|
||||
WAITING_FOR_ITEMS = 'WAITING_FOR_ITEMS',
|
||||
WAITING_FOR_ANGLE = 'WAITING_FOR_ANGLE',
|
||||
WAITING_FOR_FILL = 'WAITING_FOR_FILL',
|
||||
WAITING_FOR_ROTATE_ITEMS = 'WAITING_FOR_ROTATE_ITEMS',
|
||||
}
|
||||
|
||||
export enum ArrayAction {
|
||||
INIT_ARRAY_TOOL = 'INIT_ARRAY_TOOL',
|
||||
PERFORM_COPY = 'PERFORM_COPY',
|
||||
DESELECT_ENTITIES = 'DESELECT_ENTITIES',
|
||||
DRAW_TEMP_DISTANCE_LINE = 'DRAW_TEMP_DISTANCE_LINE',
|
||||
/** 편집 옵션이 이어 묻는 차례 — 개수 = 열 → 행 · 간격두기 = 열 거리 → 행 거리 · 열 = 수 → 거리 · 행 = 수 → 거리 */
|
||||
type Flow = 'COUNT' | 'SPACING' | 'COLUMNS' | 'ROWS' | null;
|
||||
|
||||
export interface ArrayContext extends ToolContext, ArrayValues {
|
||||
flow: Flow;
|
||||
}
|
||||
|
||||
export interface ArrayContext extends ToolContext {
|
||||
copyMode: CopyMode | null;
|
||||
startDistanceVector: Point | null;
|
||||
endDistanceVector: Point | null;
|
||||
pivotPoint: Point | null;
|
||||
angleStep: number | null;
|
||||
numberOfCopies: number;
|
||||
}
|
||||
type Ctx = { context: ArrayContext; event: StateEvent };
|
||||
|
||||
enum CopyMode {
|
||||
LINEAR = 'LINEAR',
|
||||
RADIAL = 'RADIAL',
|
||||
}
|
||||
const S = ArrayState;
|
||||
const O = ARRAY_OPT;
|
||||
const esc = { ESC: { target: END_STATE } };
|
||||
const numberOf = (event: StateEvent) => (event as NumberInputEvent).value;
|
||||
|
||||
const initialArrayContext: ArrayContext = {
|
||||
copyMode: null,
|
||||
startDistanceVector: null,
|
||||
endDistanceVector: null,
|
||||
pivotPoint: null,
|
||||
angleStep: null,
|
||||
numberOfCopies: 0,
|
||||
type: Tool.ARRAY,
|
||||
/** 물음 기본값 = 지금 값(빈 Enter 가 지금 값을 다시 보냄) */
|
||||
const syncRect = (values: ArrayValues) => {
|
||||
remember('ARRAY.COLS', values.columns);
|
||||
remember('ARRAY.ROWS', values.rows);
|
||||
remember('ARRAY.COLDIST', values.columnSpacing);
|
||||
remember('ARRAY.ROWDIST', values.rowSpacing);
|
||||
};
|
||||
const syncPolar = (values: ArrayValues) => {
|
||||
remember('ARRAY.ITEMS', values.items);
|
||||
remember('ARRAY.FILL', values.fill);
|
||||
remember('ARRAY.ANGLE', angleBetween(values));
|
||||
};
|
||||
|
||||
export const arrayToolStateMachine = createMachine(
|
||||
{
|
||||
types: {} as {
|
||||
context: ArrayContext;
|
||||
events: StateEvent;
|
||||
const showRect = ({ context }: Ctx) => {
|
||||
syncRect(context);
|
||||
setGhostHelperEntities(rectangularCopies(context));
|
||||
};
|
||||
const showPolar = ({ context }: Ctx) => {
|
||||
syncPolar(context);
|
||||
setGhostHelperEntities(polarCopies(context));
|
||||
};
|
||||
const create = (copies: (values: ArrayValues) => unknown[]) => ({
|
||||
actions: ({ context }: Ctx) => {
|
||||
const made = copies(context) as Parameters<typeof addEntities>[0];
|
||||
if (made.length) addEntities(made, true);
|
||||
},
|
||||
target: END_STATE,
|
||||
});
|
||||
|
||||
/** 숫자 물음 — 받아서 assign · 흐름 따라 다음 */
|
||||
const numberStep = (
|
||||
prompt: unknown,
|
||||
take: (value: number, context: ArrayContext) => Partial<ArrayContext> | null,
|
||||
next: { flow?: Flow; target: string }[]
|
||||
) => ({
|
||||
meta: { kind: 'number', prompt },
|
||||
on: {
|
||||
NUMBER_INPUT: [
|
||||
...next.map(({ flow, target }) => ({
|
||||
guard: ({ context, event }: Ctx) =>
|
||||
!!take(numberOf(event), context) && (flow === undefined || context.flow === flow),
|
||||
actions: assign(({ context, event }: Ctx) => take(numberOf(event), context) ?? {}),
|
||||
target,
|
||||
})),
|
||||
{ actions: () => noticeLater('값이 범위를 벗어남.') },
|
||||
],
|
||||
...esc,
|
||||
},
|
||||
});
|
||||
|
||||
const noticeLater = (text: string) => {
|
||||
void import('../inputController/prompt-input').then(({ notice }) => notice(text));
|
||||
};
|
||||
|
||||
const fillOk = (fill: number) => fill !== 0 && Math.abs(fill) <= 360;
|
||||
|
||||
// biome-ignore lint/suspicious/noExplicitAny: 공용 상태 조각(여러 도구 공통)을 섞어 만든다
|
||||
const states: Record<string, any> = {
|
||||
[S.INIT]: startState(S.WAITING_FOR_TYPE),
|
||||
[S.WAITING_FOR_SELECTION]: selectionState(S.WAITING_FOR_TYPE),
|
||||
[S.WAITING_FOR_TYPE]: {
|
||||
meta: {
|
||||
kind: 'keyword',
|
||||
prompt: () => ARRAY_PROMPT.type(recall<string>('ARRAYTYPE') ?? O.RECTANGULAR.key),
|
||||
},
|
||||
context: initialArrayContext,
|
||||
initial: ArrayState.INIT,
|
||||
states: {
|
||||
[ArrayState.INIT]: {
|
||||
description: 'Initializing the array copy tool',
|
||||
always: {
|
||||
actions: ArrayState.INIT,
|
||||
target: ArrayState.CHECK_SELECTION,
|
||||
},
|
||||
},
|
||||
[ArrayState.WAITING_FOR_SELECTION]: {
|
||||
description: 'Select what you want to copy',
|
||||
meta: {
|
||||
instructions: '배열할 객체 선택 뒤 Enter',
|
||||
},
|
||||
invoke: {
|
||||
id: 'selectToolInsideTheCopyTool',
|
||||
src: selectToolStateMachine,
|
||||
onDone: {
|
||||
actions: assign(() => {
|
||||
return initialArrayContext;
|
||||
}),
|
||||
target: ArrayState.CHECK_SELECTION,
|
||||
},
|
||||
},
|
||||
on: {
|
||||
MOUSE_CLICK: {
|
||||
// Forward the event to the select tool
|
||||
actions: sendTo('selectToolInsideTheCopyTool', ({ event }) => {
|
||||
return event;
|
||||
}),
|
||||
},
|
||||
ESC: {
|
||||
actions: [ArrayAction.DESELECT_ENTITIES, ArrayAction.INIT_ARRAY_TOOL],
|
||||
},
|
||||
ENTER: {
|
||||
// Forward the event to the select tool
|
||||
actions: sendTo('selectToolInsideTheCopyTool', ({ event }) => {
|
||||
return event;
|
||||
}),
|
||||
},
|
||||
DRAW: {
|
||||
// Forward the event to the select tool
|
||||
actions: sendTo('selectToolInsideTheCopyTool', ({ event }) => {
|
||||
return event;
|
||||
}),
|
||||
},
|
||||
},
|
||||
},
|
||||
[ArrayState.CHECK_SELECTION]: {
|
||||
description: 'Check if there is something selected',
|
||||
always: [
|
||||
{
|
||||
guard: () => {
|
||||
return getSelectedEntityIds().length > 0;
|
||||
},
|
||||
target: ArrayState.ASK_COPY_MODE,
|
||||
},
|
||||
{
|
||||
guard: () => {
|
||||
return getSelectedEntityIds().length === 0;
|
||||
},
|
||||
target: ArrayState.WAITING_FOR_SELECTION,
|
||||
},
|
||||
on: {
|
||||
'OPTION.RECTANGULAR': {
|
||||
actions: [
|
||||
() => remember('ARRAYTYPE', O.RECTANGULAR.key),
|
||||
assign(({ context }: Ctx) => {
|
||||
const spacing = initialSpacing(context.originals);
|
||||
return {
|
||||
columns: recall<number>('ARRAY.COLS') ?? 4,
|
||||
rows: recall<number>('ARRAY.ROWS') ?? 3,
|
||||
columnSpacing: spacing.column,
|
||||
rowSpacing: spacing.row,
|
||||
};
|
||||
}),
|
||||
],
|
||||
target: S.EDIT_RECTANGULAR,
|
||||
},
|
||||
[ArrayState.ASK_COPY_MODE]: {
|
||||
meta: { instructions: '배열 방식 [직선(L)/원형(R)] <직선>:' },
|
||||
on: {
|
||||
TEXT_INPUT: {
|
||||
actions: assign(({ event }) => {
|
||||
return {
|
||||
copyMode:
|
||||
(event as TextInputEvent).value === 'R' ? CopyMode.RADIAL : CopyMode.LINEAR,
|
||||
};
|
||||
}),
|
||||
target: ArrayState.ASK_NUMBER_OF_COPIES,
|
||||
},
|
||||
ENTER: {
|
||||
actions: assign(() => ({
|
||||
copyMode: CopyMode.LINEAR,
|
||||
})),
|
||||
target: ArrayState.ASK_NUMBER_OF_COPIES,
|
||||
},
|
||||
ESC: {
|
||||
actions: ArrayAction.INIT_ARRAY_TOOL,
|
||||
},
|
||||
},
|
||||
},
|
||||
[ArrayState.ASK_NUMBER_OF_COPIES]: {
|
||||
meta: { instructions: '복사 개수 입력:' },
|
||||
on: {
|
||||
NUMBER_INPUT: [
|
||||
{
|
||||
guard: ({ context }) => {
|
||||
return context.copyMode === CopyMode.LINEAR;
|
||||
},
|
||||
actions: assign(({ event }) => ({
|
||||
numberOfCopies: (event as NumberInputEvent).value,
|
||||
})),
|
||||
target: ArrayState.LINEAR_WAITING_FOR_START_POINT,
|
||||
},
|
||||
{
|
||||
guard: ({ context }) => {
|
||||
return context.copyMode === CopyMode.RADIAL;
|
||||
},
|
||||
actions: assign(({ event }) => ({
|
||||
numberOfCopies: (event as NumberInputEvent).value,
|
||||
})),
|
||||
target: ArrayState.RADIAL_WAITING_FOR_PIVOT_POINT,
|
||||
},
|
||||
],
|
||||
ESC: {
|
||||
actions: ArrayAction.INIT_ARRAY_TOOL,
|
||||
},
|
||||
},
|
||||
},
|
||||
[ArrayState.LINEAR_WAITING_FOR_START_POINT]: {
|
||||
meta: { instructions: '간격 시작점 지정:' },
|
||||
on: {
|
||||
ABSOLUTE_POINT_INPUT: {
|
||||
actions: assign(({ event }) => ({
|
||||
startDistanceVector: (event as AbsolutePointInputEvent).value,
|
||||
})),
|
||||
target: ArrayState.EXECUTE_COPY,
|
||||
},
|
||||
MOUSE_CLICK: {
|
||||
actions: assign(({ event }) => ({
|
||||
startDistanceVector: (event as MouseClickEvent).worldMouseLocation,
|
||||
})),
|
||||
target: ArrayState.LINEAR_WAITING_FOR_END_POINT,
|
||||
},
|
||||
ESC: {
|
||||
actions: ArrayAction.INIT_ARRAY_TOOL,
|
||||
},
|
||||
},
|
||||
},
|
||||
[ArrayState.LINEAR_WAITING_FOR_END_POINT]: {
|
||||
meta: { instructions: '간격 끝점 지정:' },
|
||||
on: {
|
||||
RELATIVE_POINT_INPUT: {
|
||||
actions: assign(({ event, context }) => ({
|
||||
endDistanceVector: getPointFromEvent(context.startDistanceVector, event),
|
||||
})),
|
||||
target: ArrayState.EXECUTE_COPY,
|
||||
},
|
||||
ABSOLUTE_POINT_INPUT: {
|
||||
actions: assign(({ event, context }) => ({
|
||||
endDistanceVector: getPointFromEvent(context.startDistanceVector, event),
|
||||
})),
|
||||
target: ArrayState.EXECUTE_COPY,
|
||||
},
|
||||
MOUSE_CLICK: {
|
||||
actions: assign(({ event, context }) => ({
|
||||
endDistanceVector: getPointFromEvent(context.startDistanceVector, event),
|
||||
})),
|
||||
target: ArrayState.EXECUTE_COPY,
|
||||
},
|
||||
ESC: {
|
||||
actions: ArrayAction.INIT_ARRAY_TOOL,
|
||||
},
|
||||
DRAW: {
|
||||
actions: ArrayAction.DRAW_TEMP_DISTANCE_LINE,
|
||||
},
|
||||
},
|
||||
},
|
||||
[ArrayState.RADIAL_WAITING_FOR_PIVOT_POINT]: {
|
||||
meta: { instructions: '회전 중심점 지정:' },
|
||||
on: {
|
||||
ABSOLUTE_POINT_INPUT: {
|
||||
actions: assign(({ event }) => ({
|
||||
pivotPoint: (event as AbsolutePointInputEvent).value,
|
||||
})),
|
||||
target: ArrayState.RADIAL_WAITING_FOR_ANGLE,
|
||||
},
|
||||
MOUSE_CLICK: {
|
||||
actions: assign(({ event }) => ({
|
||||
pivotPoint: (event as MouseClickEvent).worldMouseLocation,
|
||||
})),
|
||||
target: ArrayState.RADIAL_WAITING_FOR_ANGLE,
|
||||
},
|
||||
ESC: {
|
||||
actions: ArrayAction.INIT_ARRAY_TOOL,
|
||||
},
|
||||
},
|
||||
},
|
||||
[ArrayState.RADIAL_WAITING_FOR_ANGLE]: {
|
||||
meta: { instructions: '한 칸 각도(도) 입력:' },
|
||||
on: {
|
||||
NUMBER_INPUT: {
|
||||
target: ArrayState.EXECUTE_COPY,
|
||||
actions: assign(({ event }) => ({
|
||||
angleStep: (event as NumberInputEvent).value,
|
||||
})),
|
||||
},
|
||||
},
|
||||
},
|
||||
[ArrayState.EXECUTE_COPY]: {
|
||||
entry: ArrayAction.PERFORM_COPY,
|
||||
always: ArrayState.INIT,
|
||||
'OPTION.POLAR': {
|
||||
actions: [
|
||||
() => remember('ARRAYTYPE', O.POLAR.key),
|
||||
assign(() => ({
|
||||
items: recall<number>('ARRAY.ITEMS') ?? 6,
|
||||
fill: recall<number>('ARRAY.FILL') ?? 360,
|
||||
rotateItems: true,
|
||||
})),
|
||||
],
|
||||
target: S.WAITING_FOR_CENTER,
|
||||
},
|
||||
...esc,
|
||||
},
|
||||
},
|
||||
{
|
||||
actions: {
|
||||
[ArrayAction.INIT_ARRAY_TOOL]: assign(() => {
|
||||
setShouldDrawHelpers(true);
|
||||
setGhostHelperEntities([]);
|
||||
setSelectedEntityIds([]);
|
||||
setAngleGuideOriginPoint(null);
|
||||
return {
|
||||
copyMode: null,
|
||||
distanceVector: null,
|
||||
pivotPoint: null,
|
||||
angleStep: null,
|
||||
numberOfCopies: 0,
|
||||
selectedEntities: [],
|
||||
};
|
||||
}),
|
||||
|
||||
[ArrayAction.DRAW_TEMP_DISTANCE_LINE]: ({ context, event }) => {
|
||||
if (!context.startDistanceVector) {
|
||||
throw new Error('[ARRAY] Calling draw temp distance line without a start point');
|
||||
}
|
||||
|
||||
const endPointTemp = (event as DrawEvent).drawController.getWorldMouseLocation();
|
||||
|
||||
// Draw the array entities to show the result
|
||||
// Draw all selected entities according to distance vector and number of copies, so the user gets visual feedback of where the entities will be copied;
|
||||
// TODO
|
||||
|
||||
// // Draw a dashed line between the start move point and the current mouse location
|
||||
const activeDistanceLine = new LineEntity(
|
||||
getActiveLayerId(),
|
||||
context.startDistanceVector,
|
||||
endPointTemp
|
||||
);
|
||||
activeDistanceLine.lineColor = GUIDE_LINE_COLOR;
|
||||
activeDistanceLine.lineWidth = GUIDE_LINE_WIDTH;
|
||||
activeDistanceLine.lineDash = GUIDE_LINE_STYLE;
|
||||
setGhostHelperEntities([activeDistanceLine]);
|
||||
[S.WAITING_FOR_CENTER]: {
|
||||
meta: { kind: 'point', prompt: ARRAY_PROMPT.center },
|
||||
entry: () => setShouldDrawHelpers(true),
|
||||
on: {
|
||||
DRAW: {
|
||||
actions: ({ context, event }: Ctx) =>
|
||||
setGhostHelperEntities(polarCopies(context, getPointFromEvent(null, event as DrawEvent))),
|
||||
},
|
||||
|
||||
[ArrayAction.PERFORM_COPY]: ({ context }) => {
|
||||
const resultEntities: Entity[] = [];
|
||||
|
||||
for (let i = 1; i <= context.numberOfCopies; i++) {
|
||||
if (
|
||||
context.copyMode === 'LINEAR' &&
|
||||
context.startDistanceVector &&
|
||||
context.endDistanceVector
|
||||
) {
|
||||
const distanceVector = new Vector(
|
||||
context.endDistanceVector.x - context.startDistanceVector.x,
|
||||
context.endDistanceVector.y - context.startDistanceVector.y
|
||||
);
|
||||
const x = distanceVector.x * i;
|
||||
const y = distanceVector.y * i;
|
||||
resultEntities.push(
|
||||
...getSelectedEntities().map((entity) => {
|
||||
const clone = cloneKeepingLook(entity);
|
||||
clone.move(x, y);
|
||||
return clone;
|
||||
})
|
||||
);
|
||||
} else if (context.copyMode === 'RADIAL' && context.pivotPoint && context.angleStep) {
|
||||
const angleRad = context.angleStep * i * TO_RADIANS;
|
||||
resultEntities.push(
|
||||
...getSelectedEntities().map((entity) => {
|
||||
const clone = cloneKeepingLook(entity);
|
||||
clone.rotate(context.pivotPoint as Point, angleRad);
|
||||
return clone;
|
||||
})
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
addEntities(resultEntities, true);
|
||||
setGhostHelperEntities([]);
|
||||
setSelectedEntityIds([]);
|
||||
},
|
||||
[CopyAction.DESELECT_ENTITIES]: assign(() => {
|
||||
setGhostHelperEntities([]);
|
||||
setSelectedEntityIds([]);
|
||||
return initialArrayContext;
|
||||
}),
|
||||
...Object.fromEntries(
|
||||
['MOUSE_CLICK', 'ABSOLUTE_POINT_INPUT'].map((type) => [
|
||||
type,
|
||||
{
|
||||
actions: assign(({ event }: Ctx) => ({
|
||||
center: getPointFromEvent(null, event as PointInputEvent) as Point,
|
||||
})),
|
||||
target: S.EDIT_POLAR,
|
||||
},
|
||||
])
|
||||
),
|
||||
...esc,
|
||||
},
|
||||
}
|
||||
);
|
||||
},
|
||||
[S.EDIT_RECTANGULAR]: {
|
||||
meta: { kind: 'keyword', prompt: ARRAY_PROMPT.editRect },
|
||||
entry: [() => setShouldDrawHelpers(false), assign({ flow: null }), showRect],
|
||||
on: {
|
||||
'OPTION.COUNT': {
|
||||
actions: assign({ flow: 'COUNT' as Flow }),
|
||||
target: S.WAITING_FOR_COLUMNS,
|
||||
},
|
||||
'OPTION.SPACING': {
|
||||
actions: assign({ flow: 'SPACING' as Flow }),
|
||||
target: S.WAITING_FOR_COLUMN_SPACING,
|
||||
},
|
||||
'OPTION.COLUMNS': {
|
||||
actions: assign({ flow: 'COLUMNS' as Flow }),
|
||||
target: S.WAITING_FOR_COLUMNS,
|
||||
},
|
||||
'OPTION.ROWS': { actions: assign({ flow: 'ROWS' as Flow }), target: S.WAITING_FOR_ROWS },
|
||||
'OPTION.EXIT': create(rectangularCopies),
|
||||
...esc,
|
||||
},
|
||||
},
|
||||
[S.WAITING_FOR_COLUMNS]: numberStep(
|
||||
ARRAY_PROMPT.columns,
|
||||
(columns) => {
|
||||
remember('ARRAY.COLS', columns);
|
||||
return { columns };
|
||||
},
|
||||
[{ flow: 'COUNT', target: S.WAITING_FOR_ROWS }, { target: S.WAITING_FOR_COLUMN_SPACING }]
|
||||
),
|
||||
[S.WAITING_FOR_ROWS]: numberStep(
|
||||
ARRAY_PROMPT.rows,
|
||||
(rows) => {
|
||||
remember('ARRAY.ROWS', rows);
|
||||
return { rows };
|
||||
},
|
||||
[{ flow: 'ROWS', target: S.WAITING_FOR_ROW_SPACING }, { target: S.EDIT_RECTANGULAR }]
|
||||
),
|
||||
[S.WAITING_FOR_COLUMN_SPACING]: numberStep(
|
||||
ARRAY_PROMPT.columnSpacing,
|
||||
(columnSpacing) => ({ columnSpacing }),
|
||||
[{ flow: 'SPACING', target: S.WAITING_FOR_ROW_SPACING }, { target: S.EDIT_RECTANGULAR }]
|
||||
),
|
||||
[S.WAITING_FOR_ROW_SPACING]: numberStep(
|
||||
ARRAY_PROMPT.rowSpacing,
|
||||
(rowSpacing) => ({ rowSpacing }),
|
||||
[{ target: S.EDIT_RECTANGULAR }]
|
||||
),
|
||||
[S.EDIT_POLAR]: {
|
||||
meta: { kind: 'keyword', prompt: ARRAY_PROMPT.editPolar },
|
||||
entry: [() => setShouldDrawHelpers(false), showPolar],
|
||||
on: {
|
||||
'OPTION.ITEMS': { target: S.WAITING_FOR_ITEMS },
|
||||
'OPTION.ANGLE': { target: S.WAITING_FOR_ANGLE },
|
||||
'OPTION.FILL': { target: S.WAITING_FOR_FILL },
|
||||
'OPTION.ROTATE': { target: S.WAITING_FOR_ROTATE_ITEMS },
|
||||
'OPTION.EXIT': create(polarCopies),
|
||||
...esc,
|
||||
},
|
||||
},
|
||||
[S.WAITING_FOR_ITEMS]: numberStep(
|
||||
ARRAY_PROMPT.items,
|
||||
(items) => {
|
||||
remember('ARRAY.ITEMS', items);
|
||||
return { items };
|
||||
},
|
||||
[{ target: S.EDIT_POLAR }]
|
||||
),
|
||||
// 사이 각 → 채울 각 = 사이 각 × (항목 − 1)(같은 자리)
|
||||
[S.WAITING_FOR_ANGLE]: numberStep(
|
||||
ARRAY_PROMPT.angle,
|
||||
(angle, context) => {
|
||||
const fill = angle * Math.max(context.items - 1, 1);
|
||||
return fillOk(fill) ? { fill } : null;
|
||||
},
|
||||
[{ target: S.EDIT_POLAR }]
|
||||
),
|
||||
[S.WAITING_FOR_FILL]: numberStep(
|
||||
ARRAY_PROMPT.fill,
|
||||
(fill) => {
|
||||
if (!fillOk(fill)) return null;
|
||||
remember('ARRAY.FILL', fill);
|
||||
return { fill };
|
||||
},
|
||||
[{ target: S.EDIT_POLAR }]
|
||||
),
|
||||
[S.WAITING_FOR_ROTATE_ITEMS]: {
|
||||
meta: { kind: 'keyword', prompt: ARRAY_PROMPT.rotateItems },
|
||||
on: {
|
||||
'OPTION.YES': { actions: assign({ rotateItems: true }), target: S.EDIT_POLAR },
|
||||
'OPTION.NO': { actions: assign({ rotateItems: false }), target: S.EDIT_POLAR },
|
||||
...esc,
|
||||
},
|
||||
},
|
||||
[END_STATE]: endState,
|
||||
};
|
||||
|
||||
export const arrayToolStateMachine = createMachine({
|
||||
types: {} as { context: ArrayContext; events: StateEvent },
|
||||
context: {
|
||||
originals: [],
|
||||
columns: 4,
|
||||
rows: 3,
|
||||
columnSpacing: 1,
|
||||
rowSpacing: 1,
|
||||
center: null,
|
||||
items: 6,
|
||||
fill: 360,
|
||||
rotateItems: true,
|
||||
flow: null,
|
||||
type: Tool.ARRAY,
|
||||
},
|
||||
initial: S.INIT,
|
||||
states,
|
||||
});
|
||||
|
||||
@@ -1,304 +1,321 @@
|
||||
import type { Point } from '@flatten-js/core';
|
||||
/**
|
||||
* COPY(337 라 · 설계 1-3) — 「객체 선택:」 → 「기준점 지정 또는 [변위(D)/모드(O)/다중(M)] <변위>:」 →
|
||||
* 「두 번째 점 지정 또는 [배열(A)] <첫 번째 점을 변위로 사용>:」 → 첫 사본 뒤
|
||||
* 「두 번째 점 지정 또는 [배열(A)/종료(E)/명령 취소(U)] <종료>:」 되풀이(빈 Enter · Esc = 끝 · 만든 사본 남음).
|
||||
* O → 「복사 모드 옵션 입력 [단일(S)/다중(M)] <다중>:」(COPYMODE 기억) · M = 이번만 되풀이(단일 때만 보임) ·
|
||||
* A → 「배열할 항목 수 입력:」 → 「두 번째 점 지정 또는 [맞춤(F)]:」 · U = 마지막 사본 되돌림.
|
||||
*/
|
||||
import { Point } from '@flatten-js/core';
|
||||
import { assign, createMachine } from 'xstate';
|
||||
import { OPT, type OptionSpec, type PromptSpec, recall, remember } from '../commands/prompt';
|
||||
import type { Entity } from '../entities/Entity';
|
||||
import { getPointFromEvent } from '../helpers/get-point-from-event';
|
||||
import {
|
||||
addEntities,
|
||||
getActiveLayerId,
|
||||
getSelectedEntities,
|
||||
getSelectedEntityIds,
|
||||
setAngleGuideOriginPoint,
|
||||
setGhostHelperEntities,
|
||||
setSelectedEntityIds,
|
||||
setShouldDrawHelpers,
|
||||
undo,
|
||||
} 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 type { Entity } from '../entities/Entity';
|
||||
import { compact } from 'es-toolkit';
|
||||
import { LineEntity } from '../entities/LineEntity';
|
||||
import { GUIDE_LINE_COLOR, GUIDE_LINE_STYLE, GUIDE_LINE_WIDTH } from '../App.consts';
|
||||
import { cloneKeepingLook, moveEntities } from './move-tool.helpers';
|
||||
|
||||
export interface CopyContext extends ToolContext {
|
||||
startPoint: Point | null;
|
||||
originalSelectedEntities: Entity[];
|
||||
copiedEntities: Entity[];
|
||||
lastDrawLocation: Point | null;
|
||||
}
|
||||
import { cloneKeepingLook } from './move-tool.helpers';
|
||||
import {
|
||||
type DrawEvent,
|
||||
END_STATE,
|
||||
type NumberInputEvent,
|
||||
type PointInputEvent,
|
||||
type StateEvent,
|
||||
type ToolContext,
|
||||
} from './tool.types';
|
||||
import {
|
||||
type PickedContext,
|
||||
endState,
|
||||
guideLine,
|
||||
selectionState,
|
||||
startState,
|
||||
} from './transform.helpers';
|
||||
|
||||
export enum CopyState {
|
||||
INIT = 'INIT',
|
||||
CHECK_SELECTION = 'CHECK_SELECTION',
|
||||
WAITING_FOR_SELECTION = 'WAITING_FOR_SELECTION',
|
||||
WAITING_FOR_START_COPY_POINT = 'WAITING_FOR_START_COPY_POINT',
|
||||
WAITING_FOR_END_COPY_POINT = 'WAITING_FOR_END_COPY_POINT',
|
||||
WAITING_FOR_BASE_POINT = 'WAITING_FOR_BASE_POINT',
|
||||
WAITING_FOR_COPY_MODE = 'WAITING_FOR_COPY_MODE',
|
||||
WAITING_FOR_SECOND_POINT = 'WAITING_FOR_SECOND_POINT',
|
||||
WAITING_FOR_ARRAY_COUNT = 'WAITING_FOR_ARRAY_COUNT',
|
||||
WAITING_FOR_ARRAY_POINT = 'WAITING_FOR_ARRAY_POINT',
|
||||
WAITING_FOR_DISPLACEMENT = 'WAITING_FOR_DISPLACEMENT',
|
||||
}
|
||||
|
||||
export enum CopyAction {
|
||||
INIT_COPY_TOOL = 'INIT_COPY_TOOL',
|
||||
ENABLE_HELPERS = 'ENABLE_HELPERS',
|
||||
RECORD_START_POINT = 'RECORD_START_POINT',
|
||||
COPY_SELECTION_BEFORE_COPY = 'COPY_SELECTION_BEFORE_COPY',
|
||||
DRAW_TEMP_COPY_ENTITIES = 'DRAW_TEMP_COPY_ENTITIES',
|
||||
COPY_SELECTION = 'COPY_SELECTION',
|
||||
DESELECT_ENTITIES = 'DESELECT_ENTITIES',
|
||||
RESTORE_ORIGINAL_ENTITIES = 'RESTORE_ORIGINAL_ENTITIES',
|
||||
export interface CopyContext extends ToolContext, PickedContext {
|
||||
base: Point | null;
|
||||
/** COPYMODE 단일 */
|
||||
single: boolean;
|
||||
/** 다중(M) — 이번만 되풀이 */
|
||||
multiple: boolean;
|
||||
/** 만든 사본 묶음 수(U 되돌림 · 물음 바뀜) */
|
||||
made: number;
|
||||
/** 배열(A) 항목 수(원본 포함) */
|
||||
items: number;
|
||||
/** 맞춤(F) — 두 번째 점 = 마지막 항목 */
|
||||
fit: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Copy tool state machine
|
||||
* This state machine is responsible for copying entities around the canvas
|
||||
* It uses the select tool state machine to select entities to copy
|
||||
* When the user presses enter, the selected entities are marked for copying
|
||||
* 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 copied to the new location
|
||||
*/
|
||||
export const copyToolStateMachine = createMachine(
|
||||
const MODE: OptionSpec = { key: 'MODE', kw: 'O', en: 'MODE', label: '모드' };
|
||||
const SINGLE: OptionSpec = { key: 'SINGLE', kw: 'S', en: 'SINGLE', label: '단일' };
|
||||
const ARRAY: OptionSpec = { key: 'ARRAY', kw: 'A', en: 'ARRAY', label: '배열' };
|
||||
const FIT: OptionSpec = { key: 'FIT', kw: 'F', en: 'FIT', label: '맞춤' };
|
||||
|
||||
/** 한 번만 만들고 끝 — 단일 모드이고 다중(M) 안 고름 */
|
||||
const once = (context: Partial<CopyContext>) => !!context.single && !context.multiple;
|
||||
|
||||
const PROMPT = {
|
||||
base: (context: Partial<CopyContext>): PromptSpec => ({
|
||||
text: '기준점 지정',
|
||||
options: [OPT.DISPLACEMENT, MODE, ...(once(context) ? [OPT.MULTIPLE] : [])],
|
||||
default: { kind: 'option', key: OPT.DISPLACEMENT.key },
|
||||
}),
|
||||
mode: (context: Partial<CopyContext>): PromptSpec => ({
|
||||
text: '복사 모드 옵션 입력',
|
||||
options: [SINGLE, OPT.MULTIPLE],
|
||||
default: { kind: 'option', key: context.single ? SINGLE.key : OPT.MULTIPLE.key },
|
||||
}),
|
||||
second: (context: Partial<CopyContext>): PromptSpec =>
|
||||
context.made
|
||||
? {
|
||||
text: '두 번째 점 지정',
|
||||
options: [ARRAY, OPT.EXIT, OPT.UNDO],
|
||||
default: { kind: 'enter', label: '종료' },
|
||||
}
|
||||
: {
|
||||
text: '두 번째 점 지정',
|
||||
options: [ARRAY],
|
||||
default: { kind: 'enter', label: '첫 번째 점을 변위로 사용' },
|
||||
},
|
||||
count: {
|
||||
text: '배열할 항목 수 입력',
|
||||
number: { int: true, min: 2, max: 32767 },
|
||||
} satisfies PromptSpec,
|
||||
arrayPoint: (context: Partial<CopyContext>): PromptSpec => ({
|
||||
text: '두 번째 점 지정',
|
||||
...(context.fit ? {} : { options: [FIT] }),
|
||||
}),
|
||||
displacement: {
|
||||
text: '변위 지정',
|
||||
default: { kind: 'value', remember: 'COPY.DISP', initial: { x: 0, y: 0 } },
|
||||
} satisfies PromptSpec,
|
||||
};
|
||||
|
||||
type Ctx = { context: CopyContext; event: StateEvent };
|
||||
|
||||
/** 변위 (dx, dy) 를 1 · 2 · … 배 한 사본들 — 배열(A)이면 항목 수 − 1 벌 */
|
||||
function copiesAt(context: CopyContext, dx: number, dy: number, sets: number): Entity[] {
|
||||
const copies: Entity[] = [];
|
||||
for (let index = 1; index <= sets; index++) {
|
||||
for (const entity of context.originals) {
|
||||
const copy = cloneKeepingLook(entity);
|
||||
copy.move(dx * index, dy * index);
|
||||
copies.push(copy);
|
||||
}
|
||||
}
|
||||
return copies;
|
||||
}
|
||||
|
||||
/** 배열(A) — 맞춤이면 두 번째 점이 마지막 항목 */
|
||||
function arrayCopies(context: CopyContext, dx: number, dy: number): Entity[] {
|
||||
const sets = context.items - 1;
|
||||
const scale = context.fit ? 1 / sets : 1;
|
||||
return copiesAt(context, dx * scale, dy * scale, sets);
|
||||
}
|
||||
|
||||
const vectorTo = ({ context, event }: Ctx) => {
|
||||
const base = context.base as Point;
|
||||
const dest = getPointFromEvent(base, event as PointInputEvent);
|
||||
return new Point(dest.x - base.x, dest.y - base.y);
|
||||
};
|
||||
|
||||
const place = (copies: Entity[]) => {
|
||||
if (copies.length) addEntities(copies, true);
|
||||
setGhostHelperEntities([]);
|
||||
};
|
||||
const bump = assign(({ context }: Ctx) => ({ made: context.made + 1, fit: false }));
|
||||
|
||||
const onPoints = (handler: unknown) => ({
|
||||
MOUSE_CLICK: handler,
|
||||
ABSOLUTE_POINT_INPUT: handler,
|
||||
RELATIVE_POINT_INPUT: handler,
|
||||
NUMBER_INPUT: handler,
|
||||
});
|
||||
const esc = { ESC: { target: END_STATE } };
|
||||
|
||||
/** 사본 놓음 → 단일이면 끝 · 아니면 다시 두 번째 점 */
|
||||
const placeThen = (copies: (args: Ctx) => Entity[]) => [
|
||||
{
|
||||
types: {} as {
|
||||
context: CopyContext;
|
||||
events: StateEvent;
|
||||
},
|
||||
context: {
|
||||
startPoint: null,
|
||||
originalSelectedEntities: [],
|
||||
copiedEntities: [],
|
||||
lastDrawLocation: null,
|
||||
type: Tool.COPY,
|
||||
},
|
||||
initial: CopyState.INIT,
|
||||
states: {
|
||||
[CopyState.INIT]: {
|
||||
description: 'Initializing the copy tool',
|
||||
always: {
|
||||
actions: CopyAction.INIT_COPY_TOOL,
|
||||
target: CopyState.CHECK_SELECTION,
|
||||
},
|
||||
},
|
||||
[CopyState.CHECK_SELECTION]: {
|
||||
description: 'Check if there is something selected',
|
||||
always: [
|
||||
{
|
||||
guard: () => {
|
||||
return getSelectedEntityIds().length > 0;
|
||||
},
|
||||
target: CopyState.WAITING_FOR_START_COPY_POINT,
|
||||
},
|
||||
{
|
||||
guard: () => {
|
||||
return getSelectedEntityIds().length === 0;
|
||||
},
|
||||
target: CopyState.WAITING_FOR_SELECTION,
|
||||
},
|
||||
],
|
||||
},
|
||||
[CopyState.WAITING_FOR_SELECTION]: {
|
||||
description: 'Select what you want to copy',
|
||||
meta: {
|
||||
instructions: '복사할 객체 선택 뒤 Enter',
|
||||
},
|
||||
invoke: {
|
||||
id: 'selectToolInsideTheCopyTool',
|
||||
src: selectToolStateMachine,
|
||||
onDone: {
|
||||
actions: assign(() => {
|
||||
return {
|
||||
startPoint: null,
|
||||
};
|
||||
}),
|
||||
target: CopyState.CHECK_SELECTION,
|
||||
},
|
||||
},
|
||||
on: {
|
||||
MOUSE_CLICK: {
|
||||
// Forward the event to the select tool
|
||||
actions: sendTo('selectToolInsideTheCopyTool', ({ event }) => {
|
||||
return event;
|
||||
}),
|
||||
},
|
||||
ESC: {
|
||||
actions: [CopyAction.DESELECT_ENTITIES, CopyAction.INIT_COPY_TOOL],
|
||||
},
|
||||
ENTER: {
|
||||
// Forward the event to the select tool
|
||||
actions: sendTo('selectToolInsideTheCopyTool', ({ event }) => {
|
||||
return event;
|
||||
}),
|
||||
},
|
||||
DRAW: {
|
||||
// Forward the event to the select tool
|
||||
actions: sendTo('selectToolInsideTheCopyTool', ({ event }) => {
|
||||
return event;
|
||||
}),
|
||||
},
|
||||
},
|
||||
},
|
||||
[CopyState.WAITING_FOR_START_COPY_POINT]: {
|
||||
description: '기준점 지정 (x,y):',
|
||||
meta: {
|
||||
instructions: '기준점 지정 (x,y):',
|
||||
},
|
||||
always: {
|
||||
actions: CopyAction.ENABLE_HELPERS,
|
||||
},
|
||||
on: {
|
||||
MOUSE_CLICK: {
|
||||
actions: [CopyAction.RECORD_START_POINT, CopyAction.COPY_SELECTION_BEFORE_COPY],
|
||||
target: CopyState.WAITING_FOR_END_COPY_POINT,
|
||||
},
|
||||
ABSOLUTE_POINT_INPUT: {
|
||||
actions: [CopyAction.RECORD_START_POINT, CopyAction.COPY_SELECTION_BEFORE_COPY],
|
||||
target: CopyState.WAITING_FOR_END_COPY_POINT,
|
||||
},
|
||||
ESC: {
|
||||
actions: CopyAction.DESELECT_ENTITIES,
|
||||
target: CopyState.INIT,
|
||||
},
|
||||
},
|
||||
},
|
||||
[CopyState.WAITING_FOR_END_COPY_POINT]: {
|
||||
description: '둘째 점 지정 (길이 · <각도 · @x,y):',
|
||||
meta: {
|
||||
instructions: '둘째 점 지정 (길이 · <각도 · @x,y):',
|
||||
},
|
||||
on: {
|
||||
DRAW: {
|
||||
actions: [CopyAction.DRAW_TEMP_COPY_ENTITIES],
|
||||
},
|
||||
MOUSE_CLICK: {
|
||||
actions: [CopyAction.COPY_SELECTION],
|
||||
target: CopyState.WAITING_FOR_END_COPY_POINT,
|
||||
},
|
||||
NUMBER_INPUT: {
|
||||
actions: [CopyAction.COPY_SELECTION],
|
||||
target: CopyState.WAITING_FOR_END_COPY_POINT,
|
||||
},
|
||||
ABSOLUTE_POINT_INPUT: {
|
||||
actions: [CopyAction.COPY_SELECTION],
|
||||
target: CopyState.WAITING_FOR_END_COPY_POINT,
|
||||
},
|
||||
RELATIVE_POINT_INPUT: {
|
||||
actions: [CopyAction.COPY_SELECTION],
|
||||
target: CopyState.WAITING_FOR_END_COPY_POINT,
|
||||
},
|
||||
ESC: {
|
||||
actions: CopyAction.DESELECT_ENTITIES,
|
||||
target: CopyState.INIT,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
guard: ({ context }: Ctx) => once(context),
|
||||
actions: (args: Ctx) => place(copies(args)),
|
||||
target: END_STATE,
|
||||
},
|
||||
{
|
||||
actions: {
|
||||
[CopyAction.INIT_COPY_TOOL]: assign(() => {
|
||||
setShouldDrawHelpers(false);
|
||||
setGhostHelperEntities([]);
|
||||
setAngleGuideOriginPoint(null);
|
||||
return {
|
||||
startPoint: null,
|
||||
originalSelectedEntities: [],
|
||||
copiedEntities: [],
|
||||
lastDrawLocation: null,
|
||||
};
|
||||
}),
|
||||
[CopyAction.ENABLE_HELPERS]: () => {
|
||||
setShouldDrawHelpers(true);
|
||||
},
|
||||
[CopyAction.RECORD_START_POINT]: assign(({ event }) => {
|
||||
const startPoint = getPointFromEvent(null, event as PointInputEvent);
|
||||
setAngleGuideOriginPoint(startPoint);
|
||||
return { startPoint };
|
||||
}),
|
||||
[CopyAction.COPY_SELECTION_BEFORE_COPY]: assign(({ context }) => {
|
||||
const selectedEntities = getSelectedEntities();
|
||||
actions: [(args: Ctx) => place(copies(args)), bump],
|
||||
target: CopyState.WAITING_FOR_SECOND_POINT,
|
||||
},
|
||||
];
|
||||
|
||||
// Copy 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);
|
||||
const recordBase = {
|
||||
actions: assign(({ event }: Ctx) => {
|
||||
const base = getPointFromEvent(null, event as PointInputEvent);
|
||||
setAngleGuideOriginPoint(base);
|
||||
return { base };
|
||||
}),
|
||||
target: CopyState.WAITING_FOR_SECOND_POINT,
|
||||
};
|
||||
|
||||
// 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 copied and the original entities also are used for snap points / angle guides
|
||||
const S = CopyState;
|
||||
|
||||
setSelectedEntityIds([]);
|
||||
return {
|
||||
startPoint: context.startPoint,
|
||||
// Make a copy of the selected entities before copying them, so we can restore them when the user cancels the copy action
|
||||
originalSelectedEntities: compact(
|
||||
selectedEntities.map((entity) => cloneKeepingLook(entity))
|
||||
),
|
||||
copiedEntities: selectedEntities,
|
||||
};
|
||||
}),
|
||||
[CopyAction.DRAW_TEMP_COPY_ENTITIES]: ({ context, event }) => {
|
||||
if (!context.startPoint) {
|
||||
throw new Error('[COPY] Calling draw temp copy line without a start point');
|
||||
}
|
||||
|
||||
const endPointTemp = getPointFromEvent(context.startPoint, event as DrawEvent);
|
||||
|
||||
// Copy the entities to the new location
|
||||
// Draw all selected entities according to translation vector, so the user gets visual feedback of where the entities will be copied;
|
||||
const movedEntities = context.originalSelectedEntities.map((entity) =>
|
||||
cloneKeepingLook(entity)
|
||||
);
|
||||
moveEntities(
|
||||
movedEntities,
|
||||
endPointTemp.x - context.startPoint.x,
|
||||
endPointTemp.y - context.startPoint.y
|
||||
);
|
||||
|
||||
// // Draw a dashed line between the start copy point and the current mouse location
|
||||
const activeCopyLine = new LineEntity(
|
||||
getActiveLayerId(),
|
||||
context.startPoint as Point,
|
||||
endPointTemp
|
||||
);
|
||||
activeCopyLine.lineColor = GUIDE_LINE_COLOR;
|
||||
activeCopyLine.lineWidth = GUIDE_LINE_WIDTH;
|
||||
activeCopyLine.lineDash = GUIDE_LINE_STYLE;
|
||||
setGhostHelperEntities([activeCopyLine, ...movedEntities]);
|
||||
},
|
||||
[CopyAction.COPY_SELECTION]: ({ context, event }) => {
|
||||
if (!context.startPoint) {
|
||||
throw new Error('[COPY] Calling copy selection without a start point');
|
||||
}
|
||||
|
||||
// Copy the entities one final time
|
||||
// 클릭 · 거리 · 절대 · 상대 좌표(키보드)
|
||||
const currentEndPoint = getPointFromEvent(context.startPoint, event as PointInputEvent);
|
||||
const copiedEntities = context.originalSelectedEntities.map((entity) =>
|
||||
cloneKeepingLook(entity)
|
||||
);
|
||||
moveEntities(
|
||||
copiedEntities,
|
||||
currentEndPoint.x - context.startPoint.x,
|
||||
currentEndPoint.y - context.startPoint.y
|
||||
);
|
||||
|
||||
// 원본은 목록에 그대로 있음 — 복사본만 더함(원본까지 더하면 누를 때마다 원본이 겹겹이 쌓임).
|
||||
addEntities(copiedEntities, true);
|
||||
},
|
||||
[CopyAction.DESELECT_ENTITIES]: assign(() => {
|
||||
setGhostHelperEntities([]);
|
||||
setSelectedEntityIds([]);
|
||||
return {
|
||||
startPoint: null,
|
||||
originalSelectedEntities: [],
|
||||
lastDrawLocation: null,
|
||||
};
|
||||
}),
|
||||
[CopyAction.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,
|
||||
// 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: recordBase,
|
||||
ABSOLUTE_POINT_INPUT: recordBase,
|
||||
'OPTION.DISPLACEMENT': { target: S.WAITING_FOR_DISPLACEMENT },
|
||||
'OPTION.MODE': { target: S.WAITING_FOR_COPY_MODE },
|
||||
'OPTION.MULTIPLE': { actions: assign({ multiple: true }) },
|
||||
...esc,
|
||||
},
|
||||
}
|
||||
);
|
||||
},
|
||||
[S.WAITING_FOR_COPY_MODE]: {
|
||||
meta: { kind: 'keyword', prompt: PROMPT.mode },
|
||||
on: {
|
||||
'OPTION.SINGLE': {
|
||||
actions: [assign({ single: true }), () => remember('COPYMODE', SINGLE.key)],
|
||||
target: S.WAITING_FOR_BASE_POINT,
|
||||
},
|
||||
'OPTION.MULTIPLE': {
|
||||
actions: [assign({ single: false }), () => remember('COPYMODE', OPT.MULTIPLE.key)],
|
||||
target: S.WAITING_FOR_BASE_POINT,
|
||||
},
|
||||
...esc,
|
||||
},
|
||||
},
|
||||
[S.WAITING_FOR_SECOND_POINT]: {
|
||||
meta: { kind: 'point', prompt: PROMPT.second },
|
||||
on: {
|
||||
DRAW: {
|
||||
actions: ({ context, event }: Ctx) => {
|
||||
const base = context.base as Point;
|
||||
const dest = getPointFromEvent(base, event as DrawEvent);
|
||||
setGhostHelperEntities([
|
||||
guideLine(base, dest),
|
||||
...copiesAt(context, dest.x - base.x, dest.y - base.y, 1),
|
||||
]);
|
||||
},
|
||||
},
|
||||
...onPoints(
|
||||
placeThen((args) => {
|
||||
const vector = vectorTo(args);
|
||||
return copiesAt(args.context, vector.x, vector.y, 1);
|
||||
})
|
||||
),
|
||||
// 첫 물음 빈 Enter = 기준점 좌표가 변위(한 벌 뒤 끝) · 그 뒤 = <종료>
|
||||
ENTER: [
|
||||
{
|
||||
guard: ({ context }: Ctx) => context.made === 0,
|
||||
actions: ({ context }: Ctx) => {
|
||||
const base = context.base as Point;
|
||||
place(copiesAt(context, base.x, base.y, 1));
|
||||
},
|
||||
target: END_STATE,
|
||||
},
|
||||
{ target: END_STATE },
|
||||
],
|
||||
'OPTION.ARRAY': { target: S.WAITING_FOR_ARRAY_COUNT },
|
||||
'OPTION.EXIT': { target: END_STATE },
|
||||
'OPTION.UNDO': {
|
||||
guard: ({ context }: Ctx) => context.made > 0,
|
||||
actions: [
|
||||
() => {
|
||||
undo();
|
||||
setGhostHelperEntities([]);
|
||||
},
|
||||
assign(({ context }: Ctx) => ({ made: context.made - 1 })),
|
||||
],
|
||||
},
|
||||
...esc,
|
||||
},
|
||||
},
|
||||
[S.WAITING_FOR_ARRAY_COUNT]: {
|
||||
meta: { kind: 'number', prompt: PROMPT.count },
|
||||
entry: () => setGhostHelperEntities([]),
|
||||
on: {
|
||||
NUMBER_INPUT: {
|
||||
actions: assign(({ event }: Ctx) => ({ items: (event as NumberInputEvent).value })),
|
||||
target: S.WAITING_FOR_ARRAY_POINT,
|
||||
},
|
||||
...esc,
|
||||
},
|
||||
},
|
||||
[S.WAITING_FOR_ARRAY_POINT]: {
|
||||
meta: { kind: 'point', prompt: PROMPT.arrayPoint },
|
||||
on: {
|
||||
DRAW: {
|
||||
actions: ({ context, event }: Ctx) => {
|
||||
const base = context.base as Point;
|
||||
const dest = getPointFromEvent(base, event as DrawEvent);
|
||||
setGhostHelperEntities([
|
||||
guideLine(base, dest),
|
||||
...arrayCopies(context, dest.x - base.x, dest.y - base.y),
|
||||
]);
|
||||
},
|
||||
},
|
||||
...onPoints(
|
||||
placeThen((args) => {
|
||||
const vector = vectorTo(args);
|
||||
return arrayCopies(args.context, vector.x, vector.y);
|
||||
})
|
||||
),
|
||||
'OPTION.FIT': { actions: assign({ fit: true }) },
|
||||
...esc,
|
||||
},
|
||||
},
|
||||
[S.WAITING_FOR_DISPLACEMENT]: {
|
||||
meta: { kind: 'point', prompt: PROMPT.displacement },
|
||||
entry: () => setAngleGuideOriginPoint(null),
|
||||
on: {
|
||||
...Object.fromEntries(
|
||||
['MOUSE_CLICK', 'ABSOLUTE_POINT_INPUT'].map((type) => [
|
||||
type,
|
||||
{
|
||||
actions: ({ context, event }: Ctx) => {
|
||||
const vector = getPointFromEvent(null, event as PointInputEvent);
|
||||
remember('COPY.DISP', { x: vector.x, y: vector.y });
|
||||
place(copiesAt(context, vector.x, vector.y, 1));
|
||||
},
|
||||
target: END_STATE,
|
||||
},
|
||||
])
|
||||
),
|
||||
...esc,
|
||||
},
|
||||
},
|
||||
[END_STATE]: endState,
|
||||
};
|
||||
|
||||
export const copyToolStateMachine = createMachine({
|
||||
types: {} as { context: CopyContext; events: StateEvent },
|
||||
context: () => ({
|
||||
originals: [],
|
||||
base: null,
|
||||
single: recall('COPYMODE') === SINGLE.key,
|
||||
multiple: false,
|
||||
made: 0,
|
||||
items: 2,
|
||||
fit: false,
|
||||
type: Tool.COPY,
|
||||
}),
|
||||
initial: S.INIT,
|
||||
states,
|
||||
});
|
||||
|
||||
@@ -1,37 +1,80 @@
|
||||
/** 대칭·정렬·간격띄우기·신축·길이조정 (조사표 2절) */
|
||||
import { Point } from '@flatten-js/core';
|
||||
import { Box, Point, Polygon } from '@flatten-js/core';
|
||||
import { toast } from 'react-toastify';
|
||||
import { type AnyActorRef, assign } from 'xstate';
|
||||
import { OPT } from '../../commands/prompt';
|
||||
import type { Entity } from '../../entities/Entity';
|
||||
import { LineEntity } from '../../entities/LineEntity';
|
||||
import { addEntities, deleteEntities, getActiveLayerId, setSelectedEntityIds } from '../../state';
|
||||
import { PolyLineEntity } from '../../entities/PolyLineEntity';
|
||||
import { RectangleEntity } from '../../entities/RectangleEntity';
|
||||
import {
|
||||
addEntities,
|
||||
deleteEntities,
|
||||
getActiveLayerId,
|
||||
getEntities,
|
||||
setEntities,
|
||||
setSelectedEntityIds,
|
||||
} from '../../state';
|
||||
import { Tool } from '../../tools';
|
||||
import { createSequenceTool } from '../factories/sequence-tool';
|
||||
import { createDisplaceTool } from '../move-tool';
|
||||
import { SelectState } from '../select-tool';
|
||||
import type { MouseClickEvent, StateEvent } from '../tool.types';
|
||||
import { SELECT_CHILD } from '../transform.helpers';
|
||||
import { copyStyle, lengthenLine, offsetEntity } from './modify.helpers';
|
||||
|
||||
/** 원본 특성을 유지한 복사본 */
|
||||
const cloneStyled = (entity: Entity): Entity => copyStyle(entity, entity.clone());
|
||||
|
||||
/** 대칭선 — 두 점이 같으면 없음 */
|
||||
const mirrorAxis = (first: Point, second: Point): LineEntity | null =>
|
||||
first.distanceTo(second)[0] < 1e-9 ? null : new LineEntity(getActiveLayerId(), first, second);
|
||||
|
||||
const mirrored = (originals: Entity[], axis: LineEntity): Entity[] =>
|
||||
originals.map((entity) => {
|
||||
const copy = cloneStyled(entity);
|
||||
copy.mirror(axis);
|
||||
return copy;
|
||||
});
|
||||
|
||||
/**
|
||||
* MIRROR(337 라 · 설계 1-3) — 「객체 선택:」 → 「대칭선의 첫 번째 점 지정:」 → 「대칭선의 두 번째 점 지정:」 →
|
||||
* 「원본 객체를 지우시겠습니까? [예(Y)/아니오(N)] <아니오>:」 · Esc = 안 만듦 · 한 번 뒤 끝.
|
||||
*/
|
||||
export const mirrorToolStateMachine = createSequenceTool({
|
||||
tool: Tool.MIRROR,
|
||||
steps: [
|
||||
{ kind: 'selection', instructions: '대칭 복사할 객체를 선택한 뒤 ENTER.' },
|
||||
{ kind: 'point', instructions: '대칭축의 첫 점을 지정하십시오.' },
|
||||
{ kind: 'point', instructions: '대칭축의 두 번째 점을 지정하십시오.' },
|
||||
{ kind: 'selection' },
|
||||
{ kind: 'point', id: 'first', prompt: { text: '대칭선의 첫 번째 점 지정' } },
|
||||
{ kind: 'point', id: 'second', prompt: { text: '대칭선의 두 번째 점 지정' } },
|
||||
{
|
||||
kind: 'keyword',
|
||||
id: 'erase',
|
||||
prompt: {
|
||||
text: '원본 객체를 지우시겠습니까?',
|
||||
options: [OPT.YES, OPT.NO],
|
||||
default: { kind: 'option', key: OPT.NO.key },
|
||||
},
|
||||
},
|
||||
],
|
||||
preview: (input) => {
|
||||
const points = input.points();
|
||||
if (points.length !== 1) return [];
|
||||
return [new LineEntity(getActiveLayerId(), points[0], input.cursor)];
|
||||
const [first, second] = input.points();
|
||||
const axis = first ? mirrorAxis(first, second ?? input.cursor) : null;
|
||||
if (!axis) return [];
|
||||
return [...(second ? [] : [axis]), ...mirrored(input.entities(0), axis)];
|
||||
},
|
||||
commit: (input) => {
|
||||
const [first, second] = input.points();
|
||||
const axis = new LineEntity(getActiveLayerId(), first, second);
|
||||
const mirrored = input.entities(0).map((entity) => {
|
||||
const copy = cloneStyled(entity);
|
||||
copy.mirror(axis);
|
||||
return copy;
|
||||
});
|
||||
if (mirrored.length) addEntities(mirrored, true);
|
||||
const axis = mirrorAxis(first, second);
|
||||
const originals = input.entities(0);
|
||||
if (!axis || !originals.length) return;
|
||||
const copies = mirrored(originals, axis);
|
||||
if (input.get('erase') === OPT.YES.key) {
|
||||
const gone = new Set(originals.map((entity) => entity.id));
|
||||
setEntities([...getEntities().filter((entity) => !gone.has(entity.id)), ...copies], true);
|
||||
} else {
|
||||
addEntities(copies, true);
|
||||
}
|
||||
setSelectedEntityIds([]);
|
||||
},
|
||||
});
|
||||
@@ -84,44 +127,85 @@ export const offsetToolStateMachine = createSequenceTool({
|
||||
},
|
||||
});
|
||||
|
||||
export const stretchToolStateMachine = createSequenceTool({
|
||||
tool: Tool.STRETCH,
|
||||
steps: [
|
||||
{ kind: 'selection', instructions: '신축할 객체를 선택한 뒤 ENTER.' },
|
||||
{ kind: 'point', instructions: '기준점을 지정하십시오.' },
|
||||
{ kind: 'point', instructions: '이동할 위치를 지정하십시오.' },
|
||||
],
|
||||
commit: (input) => {
|
||||
const [base, destination] = input.points();
|
||||
const dx = destination.x - base.x;
|
||||
const dy = destination.y - base.y;
|
||||
const replaced: Entity[] = [];
|
||||
const removed: Entity[] = [];
|
||||
/** 바꾼 것을 원본 자리에 — id · 묶음 · 꼴 그대로 */
|
||||
const inPlaceOf = <T extends Entity>(source: Entity, made: T): T => {
|
||||
copyStyle(source, made);
|
||||
made.id = source.id;
|
||||
made.groupId = source.groupId;
|
||||
made.opacity = source.opacity;
|
||||
return made;
|
||||
};
|
||||
|
||||
for (const entity of input.entities(0)) {
|
||||
const shape = entity.getShape();
|
||||
// 선은 기준점에 가까운 끝점만 끌어당긴다. 그 밖의 객체는 통째로 옮긴다.
|
||||
if (shape && 'start' in shape && 'end' in shape) {
|
||||
const start = (shape as { start: Point }).start;
|
||||
const end = (shape as { end: Point }).end;
|
||||
const moveStart = start.distanceTo(base)[0] < end.distanceTo(base)[0];
|
||||
const newLine = new LineEntity(
|
||||
entity.layerId,
|
||||
moveStart ? new Point(start.x + dx, start.y + dy) : start,
|
||||
moveStart ? end : new Point(end.x + dx, end.y + dy)
|
||||
);
|
||||
replaced.push(copyStyle(entity, newLine));
|
||||
removed.push(entity);
|
||||
} else {
|
||||
const copy = cloneStyled(entity);
|
||||
copy.move(dx, dy);
|
||||
replaced.push(copy);
|
||||
removed.push(entity);
|
||||
}
|
||||
}
|
||||
if (removed.length) deleteEntities(removed, false);
|
||||
addEntities(replaced, true);
|
||||
setSelectedEntityIds([]);
|
||||
/**
|
||||
* 걸침 창 신축 — 창 안 꼭짓점만 (dx, dy) · 창 안에 통째 든 것 · 하나씩 집은 것(창 밖) = 이동 · 그 밖 = 그대로(null).
|
||||
* ponytail: 선 · 폴리선(선 토막) · 사각형만 꼭짓점 신축 — 원 · 호 · 글은 걸치면 그대로(오토캐드는 중심 · 끝점 기준).
|
||||
*/
|
||||
function stretchEntity(entity: Entity, box: Box | null, dx: number, dy: number): Entity | null {
|
||||
const inside = (point: Point) =>
|
||||
!!box &&
|
||||
point.x >= box.xmin &&
|
||||
point.x <= box.xmax &&
|
||||
point.y >= box.ymin &&
|
||||
point.y <= box.ymax;
|
||||
const shift = (point: Point) => (inside(point) ? new Point(point.x + dx, point.y + dy) : point);
|
||||
if (!box || entity.isContainedInBox(box) || !entity.intersectsWithBox(box)) {
|
||||
const copy = cloneStyled(entity);
|
||||
copy.move(dx, dy);
|
||||
return inPlaceOf(entity, copy);
|
||||
}
|
||||
if (entity instanceof LineEntity) {
|
||||
const start = entity.getStartPoint();
|
||||
const end = entity.getEndPoint();
|
||||
if (!inside(start) && !inside(end)) return null;
|
||||
return inPlaceOf(entity, new LineEntity(entity.layerId, shift(start), shift(end)));
|
||||
}
|
||||
if (entity instanceof PolyLineEntity) {
|
||||
let changed = false;
|
||||
const parts = entity.getEntities().map((part) => {
|
||||
if (!(part instanceof LineEntity)) return cloneStyled(part);
|
||||
const start = part.getStartPoint();
|
||||
const end = part.getEndPoint();
|
||||
if (inside(start) || inside(end)) changed = true;
|
||||
return copyStyle(part, new LineEntity(part.layerId, shift(start), shift(end)));
|
||||
});
|
||||
return changed ? inPlaceOf(entity, new PolyLineEntity(entity.layerId, parts)) : null;
|
||||
}
|
||||
if (entity instanceof RectangleEntity) {
|
||||
const corners = (entity.getShape() as Polygon).vertices;
|
||||
if (!corners.some(inside)) return null;
|
||||
return inPlaceOf(entity, new RectangleEntity(entity.layerId, new Polygon(corners.map(shift))));
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/** 고르기 중 마지막 걸침 창 — 선택 도구가 두 번째 구석을 기다릴 때의 누름 */
|
||||
const watchWindow = assign(({ event, self }: { event: StateEvent; self: AnyActorRef }) => {
|
||||
const snapshot = self.getSnapshot().children[SELECT_CHILD]?.getSnapshot();
|
||||
const start = snapshot?.context?.startPoint as Point | null | undefined;
|
||||
if (snapshot?.value !== SelectState.WAITING_FOR_SECOND_SELECT_POINT || !start) return {};
|
||||
return { window: [start, (event as MouseClickEvent).worldMouseLocation] as [Point, Point] };
|
||||
});
|
||||
|
||||
/**
|
||||
* STRETCH(337 라 · 설계 1-3) — 기록 「걸치기 윈도우 또는 걸치기 폴리곤만큼 신축할 객체 선택...」 → 「객체 선택:」 →
|
||||
* MOVE 와 같은 기준점 [변위(D)] · 두 번째 점 · 변위 기억(STRETCH.DISP) · 걸침 창 안 꼭짓점만 옮김.
|
||||
*/
|
||||
export const stretchToolStateMachine = createDisplaceTool({
|
||||
tool: Tool.STRETCH,
|
||||
remember: 'STRETCH.DISP',
|
||||
intro: '걸치기 윈도우 또는 걸치기 폴리곤만큼 신축할 객체 선택...',
|
||||
watch: watchWindow,
|
||||
change: (context, dx, dy) => {
|
||||
const window = context.window;
|
||||
const box = window
|
||||
? new Box(
|
||||
Math.min(window[0].x, window[1].x),
|
||||
Math.min(window[0].y, window[1].y),
|
||||
Math.max(window[0].x, window[1].x),
|
||||
Math.max(window[0].y, window[1].y)
|
||||
)
|
||||
: null;
|
||||
return context.originals.flatMap((entity) => stretchEntity(entity, box, dx, dy) ?? []);
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
@@ -1,138 +1,48 @@
|
||||
/**
|
||||
* PEDIT — 고른 선 · 호를 폴리선 하나로 묶음. 고르기 물음 = 오토캐드 「객체 선택:」(337 라 · 설계 2-14 ④) ·
|
||||
* 고른 것 있으면 바로 · 빈 고르기 Enter · Esc = 끝 · 묶은 뒤 끝(쉬는 상태).
|
||||
*/
|
||||
import { toast } from 'react-toastify';
|
||||
import { assign, createMachine, sendTo } from 'xstate';
|
||||
import { createMachine } from 'xstate';
|
||||
import { PolyLineEntity } from '../entities/PolyLineEntity.ts';
|
||||
import {
|
||||
getActiveLayerId,
|
||||
getNotSelectedEntities,
|
||||
getSelectedEntities,
|
||||
getSelectedEntityIds,
|
||||
setAngleGuideOriginPoint,
|
||||
setEntities,
|
||||
setGhostHelperEntities,
|
||||
setSelectedEntityIds,
|
||||
setShouldDrawHelpers,
|
||||
} from '../state';
|
||||
import { Tool } from '../tools';
|
||||
import { selectToolStateMachine } from './select-tool';
|
||||
import type { StateEvent, ToolContext } from './tool.types';
|
||||
import { END_STATE, type StateEvent, type ToolContext } from './tool.types';
|
||||
import { endState, selectionState, startState } from './transform.helpers';
|
||||
|
||||
export interface PeditContext extends ToolContext {}
|
||||
|
||||
export enum PeditState {
|
||||
INIT = 'INIT',
|
||||
CHECK_SELECTION = 'CHECK_SELECTION',
|
||||
WAITING_FOR_SELECTION = 'WAITING_FOR_SELECTION',
|
||||
CONVERT_SELECTION_TO_POLYLINE = 'CONVERT_SELECTION_TO_POLYLINE',
|
||||
}
|
||||
|
||||
export enum PeditAction {
|
||||
INIT_PEDIT_TOOL = 'INIT_PEDIT_TOOL',
|
||||
DESELECT_ENTITIES = 'DESELECT_ENTITIES',
|
||||
CONVERT_SELECTION_TO_POLYLINE = 'CONVERT_SELECTION_TO_POLYLINE',
|
||||
}
|
||||
const convert = () => {
|
||||
const newPolyLine = new PolyLineEntity(getActiveLayerId(), getSelectedEntities());
|
||||
setEntities([...getNotSelectedEntities(), newPolyLine], true);
|
||||
toast.success(`Created polyline with ${newPolyLine.numberOfSegments()} segments`);
|
||||
};
|
||||
|
||||
/**
|
||||
* Pedit tool state machine
|
||||
* This state machine is responsible for combining entities into a polyline
|
||||
* It uses the select tool state machine to select entities to combine
|
||||
* When the user presses enter, the selected entities are combined into one polyline
|
||||
*/
|
||||
export const peditToolStateMachine = createMachine(
|
||||
{
|
||||
types: {} as {
|
||||
context: PeditContext;
|
||||
events: StateEvent;
|
||||
},
|
||||
context: {
|
||||
type: Tool.PEDIT,
|
||||
},
|
||||
initial: PeditState.INIT,
|
||||
states: {
|
||||
[PeditState.INIT]: {
|
||||
description: 'Initializing the pedit tool',
|
||||
always: {
|
||||
actions: PeditAction.INIT_PEDIT_TOOL,
|
||||
target: PeditState.CHECK_SELECTION,
|
||||
},
|
||||
},
|
||||
[PeditState.CHECK_SELECTION]: {
|
||||
description: 'Check if there is something selected',
|
||||
always: [
|
||||
{
|
||||
guard: () => {
|
||||
return getSelectedEntityIds().length > 0;
|
||||
},
|
||||
target: PeditState.CONVERT_SELECTION_TO_POLYLINE,
|
||||
},
|
||||
{
|
||||
guard: () => {
|
||||
return getSelectedEntityIds().length === 0;
|
||||
},
|
||||
target: PeditState.WAITING_FOR_SELECTION,
|
||||
},
|
||||
],
|
||||
},
|
||||
[PeditState.WAITING_FOR_SELECTION]: {
|
||||
description: 'Select the entities that you want to combine into a polyline',
|
||||
meta: {
|
||||
instructions: '폴리선으로 합칠 객체 선택 뒤 Enter',
|
||||
},
|
||||
invoke: {
|
||||
id: 'selectToolInsideThePeditTool',
|
||||
src: selectToolStateMachine,
|
||||
onDone: {
|
||||
target: PeditState.CHECK_SELECTION,
|
||||
},
|
||||
},
|
||||
on: {
|
||||
MOUSE_CLICK: {
|
||||
// Forward the event to the select tool
|
||||
actions: sendTo('selectToolInsideThePeditTool', ({ event }) => {
|
||||
return event;
|
||||
}),
|
||||
},
|
||||
ESC: {
|
||||
actions: [PeditAction.DESELECT_ENTITIES, PeditAction.INIT_PEDIT_TOOL],
|
||||
},
|
||||
ENTER: {
|
||||
// Forward the event to the select tool
|
||||
actions: sendTo('selectToolInsideThePeditTool', ({ event }) => {
|
||||
return event;
|
||||
}),
|
||||
},
|
||||
DRAW: {
|
||||
// Forward the event to the select tool
|
||||
actions: sendTo('selectToolInsideThePeditTool', ({ event }) => {
|
||||
return event;
|
||||
}),
|
||||
},
|
||||
},
|
||||
},
|
||||
[PeditState.CONVERT_SELECTION_TO_POLYLINE]: {
|
||||
always: {
|
||||
actions: PeditAction.CONVERT_SELECTION_TO_POLYLINE,
|
||||
target: PeditState.INIT,
|
||||
},
|
||||
},
|
||||
},
|
||||
// biome-ignore lint/suspicious/noExplicitAny: 공용 상태 조각(여러 도구 공통)을 섞어 만든다
|
||||
const states: Record<string, any> = {
|
||||
[PeditState.INIT]: startState(PeditState.CONVERT_SELECTION_TO_POLYLINE),
|
||||
[PeditState.WAITING_FOR_SELECTION]: selectionState(PeditState.CONVERT_SELECTION_TO_POLYLINE),
|
||||
[PeditState.CONVERT_SELECTION_TO_POLYLINE]: {
|
||||
always: { actions: convert, target: END_STATE },
|
||||
},
|
||||
{
|
||||
actions: {
|
||||
[PeditAction.INIT_PEDIT_TOOL]: assign(() => {
|
||||
setShouldDrawHelpers(false);
|
||||
setGhostHelperEntities([]);
|
||||
setAngleGuideOriginPoint(null);
|
||||
return {};
|
||||
}),
|
||||
[PeditAction.CONVERT_SELECTION_TO_POLYLINE]: assign(() => {
|
||||
const newPolyLine = new PolyLineEntity(getActiveLayerId(), getSelectedEntities());
|
||||
const newEntities = [...getNotSelectedEntities(), newPolyLine];
|
||||
setEntities(newEntities, true);
|
||||
setSelectedEntityIds([]);
|
||||
toast.success(`Created polyline with ${newPolyLine.numberOfSegments()} segments`);
|
||||
return {};
|
||||
}),
|
||||
...selectToolStateMachine.implementations.actions,
|
||||
},
|
||||
}
|
||||
);
|
||||
[END_STATE]: endState,
|
||||
};
|
||||
|
||||
/** 고른 객체를 폴리선 하나로 */
|
||||
export const peditToolStateMachine = createMachine({
|
||||
types: {} as { context: PeditContext & { originals: unknown[] }; events: StateEvent },
|
||||
context: { originals: [], type: Tool.PEDIT },
|
||||
initial: PeditState.INIT,
|
||||
states,
|
||||
});
|
||||
|
||||
@@ -13,7 +13,9 @@ vi.mock("react-toastify", () => ({
|
||||
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 { selectToolStateMachine } from "../../../B07_DesignDetail/openwebcad/src/tools/select-tool";
|
||||
import {
|
||||
getGhostHelperEntities,
|
||||
getSelectedEntityIds,
|
||||
setSelectedEntityIds,
|
||||
} from "../../../B07_DesignDetail/openwebcad/src/state";
|
||||
@@ -316,3 +318,358 @@ describe("SCALE(1-3)", () => {
|
||||
expect(lineEnds()).toEqual([[0, 0, 30, 0]]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("COPY(1-3)", () => {
|
||||
it("물음 차례 · 되풀이 · U 마지막 사본 되돌림 · 빈 Enter = 끝(사본 남음)", () => {
|
||||
lineSelected();
|
||||
type("co");
|
||||
expect(promptLine()).toBe("기준점 지정 또는 [변위(D)/모드(O)] <변위>:");
|
||||
type("0,0");
|
||||
expect(promptLine()).toBe("두 번째 점 지정 또는 [배열(A)] <첫 번째 점을 변위로 사용>:");
|
||||
type("0,10");
|
||||
expect(promptLine()).toBe("두 번째 점 지정 또는 [배열(A)/종료(E)/명령 취소(U)] <종료>:");
|
||||
type("0,20");
|
||||
expect(entities("Line")).toHaveLength(3);
|
||||
type("u");
|
||||
expect(entities("Line")).toHaveLength(2);
|
||||
expect(activeTool()).toBe(Tool.COPY);
|
||||
enter();
|
||||
expect(activeTool()).toBe(Tool.SELECT);
|
||||
expect(lineEnds()).toEqual([
|
||||
[0, 0, 20, 0],
|
||||
[0, 10, 20, 10],
|
||||
]);
|
||||
});
|
||||
|
||||
it("첫 물음 빈 Enter = 기준점 좌표가 변위(한 벌 뒤 끝) · E = 끝 · Esc = 끝(사본 남음)", () => {
|
||||
lineSelected();
|
||||
type("co");
|
||||
type("5,5");
|
||||
enter();
|
||||
expect(activeTool()).toBe(Tool.SELECT);
|
||||
expect(lineEnds()).toEqual([
|
||||
[0, 0, 20, 0],
|
||||
[5, 5, 25, 5],
|
||||
]);
|
||||
setSelectedEntityIds([entities()[0].id]);
|
||||
type("co");
|
||||
type("0,0");
|
||||
type("0,-5");
|
||||
type("e");
|
||||
expect(activeTool()).toBe(Tool.SELECT);
|
||||
setSelectedEntityIds([entities()[0].id]);
|
||||
type("co");
|
||||
type("0,0");
|
||||
type("0,-9");
|
||||
esc();
|
||||
expect(entities("Line")).toHaveLength(4);
|
||||
});
|
||||
|
||||
it("O 단일(COPYMODE 기억) · 단일 땐 M 보임 · M = 이번만 되풀이", () => {
|
||||
lineSelected();
|
||||
type("co");
|
||||
type("o");
|
||||
expect(promptLine()).toBe("복사 모드 옵션 입력 [단일(S)/다중(M)] <다중>:");
|
||||
type("s");
|
||||
expect(promptLine()).toBe("기준점 지정 또는 [변위(D)/모드(O)/다중(M)] <변위>:");
|
||||
type("0,0");
|
||||
type("0,10");
|
||||
expect(activeTool()).toBe(Tool.SELECT); // 단일 = 한 벌 뒤 끝
|
||||
expect(entities("Line")).toHaveLength(2);
|
||||
setSelectedEntityIds([entities()[0].id]);
|
||||
type("co");
|
||||
expect(promptLine()).toBe("기준점 지정 또는 [변위(D)/모드(O)/다중(M)] <변위>:");
|
||||
type("m");
|
||||
type("0,0");
|
||||
type("0,20");
|
||||
type("0,30");
|
||||
expect(activeTool()).toBe(Tool.COPY);
|
||||
expect(entities("Line")).toHaveLength(4);
|
||||
});
|
||||
|
||||
it("A 배열 · F 맞춤 · D 변위", () => {
|
||||
lineSelected();
|
||||
type("co");
|
||||
type("0,0");
|
||||
type("a");
|
||||
expect(promptLine()).toBe("배열할 항목 수 입력:");
|
||||
type("1.5");
|
||||
expect(noticeLine()).toBe("정수 값을 요구함.");
|
||||
type("3");
|
||||
expect(promptLine()).toBe("두 번째 점 지정 또는 [맞춤(F)]:");
|
||||
type("0,10");
|
||||
expect(lineEnds().map((ends) => ends[1])).toEqual([0, 10, 20]);
|
||||
type("a");
|
||||
type("3");
|
||||
type("f");
|
||||
type("0,-10"); // 맞춤 — 마지막 항목이 −10
|
||||
expect(lineEnds().map((ends) => ends[1])).toEqual([-10, -5, 0, 10, 20]);
|
||||
esc();
|
||||
setSelectedEntityIds([entities()[0].id]);
|
||||
type("co");
|
||||
type("d");
|
||||
expect(promptLine()).toBe("변위 지정 <0.0000, 0.0000>:");
|
||||
type("100,0");
|
||||
expect(activeTool()).toBe(Tool.SELECT);
|
||||
expect(entities("Line")).toHaveLength(6);
|
||||
});
|
||||
});
|
||||
|
||||
describe("MIRROR(1-3)", () => {
|
||||
it("물음 차례 · 빈 Enter = 아니오(원본 둠) · Y = 원본 지움 · N", () => {
|
||||
lineSelected("0,10", "20,10");
|
||||
type("mi");
|
||||
expect(promptLine()).toBe("대칭선의 첫 번째 점 지정:");
|
||||
type("0,0");
|
||||
expect(promptLine()).toBe("대칭선의 두 번째 점 지정:");
|
||||
type("10,0");
|
||||
expect(promptLine()).toBe("원본 객체를 지우시겠습니까? [예(Y)/아니오(N)] <아니오>:");
|
||||
enter();
|
||||
expect(activeTool()).toBe(Tool.SELECT);
|
||||
expect(lineEnds()).toEqual([
|
||||
[0, -10, 20, -10],
|
||||
[0, 10, 20, 10],
|
||||
]);
|
||||
setSelectedEntityIds([entities()[0].id]);
|
||||
type("mi");
|
||||
type("0,0");
|
||||
type("0,10");
|
||||
type("y");
|
||||
expect(lineEnds()).toEqual([
|
||||
[0, -10, 20, -10],
|
||||
[0, 10, -20, 10],
|
||||
]);
|
||||
setSelectedEntityIds([entities()[0].id]);
|
||||
type("mi");
|
||||
type("0,0");
|
||||
type("0,10");
|
||||
type("n");
|
||||
expect(entities("Line")).toHaveLength(3);
|
||||
});
|
||||
|
||||
it("Esc = 안 만듦(예/아니오 물음에서도)", () => {
|
||||
lineSelected("0,10", "20,10");
|
||||
type("mi");
|
||||
type("0,0");
|
||||
type("10,0");
|
||||
esc();
|
||||
expect(lineEnds()).toEqual([[0, 10, 20, 10]]);
|
||||
expect(activeTool()).toBe(Tool.SELECT);
|
||||
});
|
||||
});
|
||||
|
||||
describe("STRETCH(1-3)", () => {
|
||||
it("걸침 창 안 꼭짓점만 옮김 · 창 밖 객체 그대로", () => {
|
||||
line("0,0", "20,0");
|
||||
line("100,0", "120,0");
|
||||
type("s");
|
||||
expect(promptLine()).toBe("객체 선택:");
|
||||
click(30, 5); // 걸침(오→왼)
|
||||
click(15, -5);
|
||||
enter();
|
||||
expect(promptLine()).toBe("기준점 지정 또는 [변위(D)] <변위>:");
|
||||
type("0,0");
|
||||
type("@10,0");
|
||||
expect(lineEnds()).toEqual([
|
||||
[0, 0, 30, 0],
|
||||
[100, 0, 120, 0],
|
||||
]);
|
||||
expect(activeTool()).toBe(Tool.SELECT);
|
||||
});
|
||||
|
||||
it("창 안에 통째 든 것 = 이동 · D 변위 · 하나씩 고른 것(창 없음) = 이동", () => {
|
||||
line("0,0", "20,0");
|
||||
type("s");
|
||||
click(-5, 5);
|
||||
click(25, -5);
|
||||
enter();
|
||||
type("d");
|
||||
type("0,7");
|
||||
expect(lineEnds()).toEqual([[0, 7, 20, 7]]);
|
||||
setSelectedEntityIds([entities()[0].id]);
|
||||
type("s");
|
||||
type("0,0");
|
||||
type("3,0");
|
||||
expect(lineEnds()).toEqual([[3, 7, 23, 7]]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("ARRAY(1-3)", () => {
|
||||
it("R = 직사각형 · 미리보기 4 × 3 · 처음 간격 1.5 배 · 빈 Enter = 만듦", () => {
|
||||
lineSelected("0,0", "10,0");
|
||||
type("ar");
|
||||
expect(promptLine()).toBe("배열 유형 입력 [직사각형(R)/원형(PO)] <직사각형>:");
|
||||
type("r");
|
||||
expect(promptLine()).toBe(
|
||||
"그립을 선택하여 배열을 편집하거나 [개수(COU)/간격두기(S)/열(COL)/행(R)/종료(X)] <종료>:",
|
||||
);
|
||||
expect(getGhostHelperEntities()).toHaveLength(11);
|
||||
enter();
|
||||
expect(activeTool()).toBe(Tool.SELECT);
|
||||
expect(entities("Line")).toHaveLength(12);
|
||||
expect(lineEnds()[11]).toEqual([45, 30, 55, 30]);
|
||||
});
|
||||
|
||||
it("COU · S · COL · R · X", () => {
|
||||
lineSelected("0,0", "10,0");
|
||||
type("ar");
|
||||
enter(); // <직사각형>
|
||||
type("cou");
|
||||
expect(promptLine()).toBe("열 수 입력 <4>:");
|
||||
type("2");
|
||||
expect(promptLine()).toBe("행 수 입력 <3>:");
|
||||
type("2");
|
||||
type("s");
|
||||
expect(promptLine()).toBe("열 사이의 거리 지정 <15.0000>:");
|
||||
type("20");
|
||||
expect(promptLine()).toBe("행 사이의 거리 지정 <15.0000>:");
|
||||
type("30");
|
||||
type("col");
|
||||
type("3");
|
||||
expect(promptLine()).toBe("열 사이의 거리 지정 <20.0000>:");
|
||||
enter();
|
||||
type("r");
|
||||
expect(promptLine()).toBe("행 수 입력 <2>:");
|
||||
type("1");
|
||||
expect(promptLine()).toBe("행 사이의 거리 지정 <30.0000>:");
|
||||
enter();
|
||||
type("x");
|
||||
expect(lineEnds()).toEqual([
|
||||
[0, 0, 10, 0],
|
||||
[20, 0, 30, 0],
|
||||
[40, 0, 50, 0],
|
||||
]);
|
||||
});
|
||||
|
||||
it("PO — 중심 · I · F · A · ROT · 유형 기억", () => {
|
||||
lineSelected("10,0", "20,0");
|
||||
type("ar");
|
||||
type("po");
|
||||
expect(promptLine()).toBe("배열의 중심점 지정:");
|
||||
type("0,0");
|
||||
expect(promptLine()).toBe(
|
||||
"그립을 선택하여 배열을 편집하거나 [항목(I)/사이의 각도(A)/채울 각도(F)/항목 회전(ROT)/종료(X)] <종료>:",
|
||||
);
|
||||
expect(getGhostHelperEntities()).toHaveLength(5); // 6 개 360°
|
||||
type("i");
|
||||
expect(promptLine()).toBe("배열의 항목 수 입력 <6>:");
|
||||
type("4");
|
||||
type("x");
|
||||
expect(lineEnds()).toEqual([
|
||||
[-10, 0, -20, 0],
|
||||
[0, -10, 0, -20],
|
||||
[0, 10, 0, 20],
|
||||
[10, 0, 20, 0],
|
||||
]);
|
||||
|
||||
setSelectedEntityIds([entities()[0].id]);
|
||||
type("ar");
|
||||
expect(promptLine()).toBe("배열 유형 입력 [직사각형(R)/원형(PO)] <원형>:");
|
||||
enter();
|
||||
type("0,0");
|
||||
type("i");
|
||||
type("3");
|
||||
type("f");
|
||||
expect(promptLine()).toBe("채울 각도 지정(+=ccw, -=cw) <360>:");
|
||||
type("180");
|
||||
type("a");
|
||||
expect(promptLine()).toBe("항목 사이의 각도 지정 <90>:");
|
||||
type("45");
|
||||
type("rot");
|
||||
expect(promptLine()).toBe("배열된 항목을 회전하시겠습니까? [예(Y)/아니오(N)] <예>:");
|
||||
type("n");
|
||||
type("x");
|
||||
// 원본 (10,0)-(20,0) · 가운데 (15,0) 을 45° · 90° 자리로 옮김(돌지 않음)
|
||||
const c45 = 15 / Math.SQRT2;
|
||||
expect(lineEnds()).toContainEqual([r(c45 - 5), r(c45), r(c45 + 5), r(c45)]);
|
||||
expect(lineEnds()).toContainEqual([-5, 15, 5, 15]);
|
||||
});
|
||||
|
||||
it("Esc = 안 만듦(편집 물음 · 숫자 물음)", () => {
|
||||
lineSelected("0,0", "10,0");
|
||||
type("ar");
|
||||
type("r");
|
||||
esc();
|
||||
expect(entities("Line")).toHaveLength(1);
|
||||
expect(getGhostHelperEntities()).toHaveLength(0);
|
||||
setSelectedEntityIds([entities()[0].id]);
|
||||
type("ar");
|
||||
type("po");
|
||||
type("0,0");
|
||||
type("i");
|
||||
esc();
|
||||
expect(entities("Line")).toHaveLength(1);
|
||||
expect(activeTool()).toBe(Tool.SELECT);
|
||||
});
|
||||
});
|
||||
|
||||
describe("PEDIT 고르기 · 고르기 공용(2-14 ④ · 2-15)", () => {
|
||||
it("PEDIT — 고른 것 바로 묶음 · 없으면 「객체 선택:」 · 빈 Enter · Esc = 끝", () => {
|
||||
line("0,0", "10,0");
|
||||
line("10,0", "10,10");
|
||||
type("pe");
|
||||
expect(promptLine()).toBe("객체 선택:");
|
||||
enter();
|
||||
expect(activeTool()).toBe(Tool.SELECT);
|
||||
type("pe");
|
||||
esc();
|
||||
expect(activeTool()).toBe(Tool.SELECT);
|
||||
setSelectedEntityIds(entities().map((entity) => entity.id));
|
||||
type("pe");
|
||||
expect(activeTool()).toBe(Tool.SELECT);
|
||||
expect(entities("Line")).toHaveLength(0);
|
||||
expect(entities()).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("고르기 물음의 ALL · L · P 는 거절 없이 선택 도구로 넘어감", () => {
|
||||
line("0,0", "10,0");
|
||||
type("m");
|
||||
for (const word of ["all", "l", "p"]) {
|
||||
type(word);
|
||||
expect(noticeLine()).toBeNull();
|
||||
expect(activeTool()).toBe(Tool.MOVE);
|
||||
}
|
||||
});
|
||||
|
||||
const selectTakesKeywords = !!(
|
||||
selectToolStateMachine.states.WAITING_FOR_FIRST_SELECT_POINT.config.on as Record<
|
||||
string,
|
||||
unknown
|
||||
>
|
||||
)?.["OPTION.ALL"];
|
||||
|
||||
it.skipIf(!selectTakesKeywords)(
|
||||
"ALL · P(앞 고름) · L(마지막 객체) — 마 선택 도구 처리 뒤",
|
||||
() => {
|
||||
line("0,0", "10,0");
|
||||
line("0,20", "10,20");
|
||||
type("m");
|
||||
type("all");
|
||||
enter();
|
||||
type("0,0");
|
||||
type("0,5");
|
||||
expect(lineEnds()).toEqual([
|
||||
[0, 5, 10, 5],
|
||||
[0, 25, 10, 25],
|
||||
]);
|
||||
type("m");
|
||||
type("p");
|
||||
enter();
|
||||
type("0,0");
|
||||
type("5,0");
|
||||
expect(lineEnds()).toEqual([
|
||||
[5, 5, 15, 5],
|
||||
[5, 25, 15, 25],
|
||||
]);
|
||||
type("m");
|
||||
type("l");
|
||||
enter();
|
||||
type("0,0");
|
||||
type("0,100");
|
||||
expect(lineEnds()).toEqual([
|
||||
[5, 5, 15, 5],
|
||||
[5, 125, 15, 125],
|
||||
]);
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user