- 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
294 lines
9.1 KiB
TypeScript
294 lines
9.1 KiB
TypeScript
/**
|
|
* 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 {
|
|
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',
|
|
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',
|
|
}
|
|
|
|
/** 편집 옵션이 이어 묻는 차례 — 개수 = 열 → 행 · 간격두기 = 열 거리 → 행 거리 · 열 = 수 → 거리 · 행 = 수 → 거리 */
|
|
type Flow = 'COUNT' | 'SPACING' | 'COLUMNS' | 'ROWS' | null;
|
|
|
|
export interface ArrayContext extends ToolContext, ArrayValues {
|
|
flow: Flow;
|
|
}
|
|
|
|
type Ctx = { context: ArrayContext; event: StateEvent };
|
|
|
|
const S = ArrayState;
|
|
const O = ARRAY_OPT;
|
|
const esc = { ESC: { target: END_STATE } };
|
|
const numberOf = (event: StateEvent) => (event as NumberInputEvent).value;
|
|
|
|
/** 물음 기본값 = 지금 값(빈 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));
|
|
};
|
|
|
|
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),
|
|
},
|
|
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,
|
|
},
|
|
'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,
|
|
},
|
|
},
|
|
[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))),
|
|
},
|
|
...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,
|
|
});
|