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:
2026-10-16 00:25:20 +09:00
co-authored by Claude Opus 5.5
parent d35bdfa69d
commit 1d5cdff0a5
6 changed files with 1260 additions and 799 deletions
@@ -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,
});