/** * 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[0]; if (made.length) addEntities(made, true); }, target: END_STATE, }); /** 숫자 물음 — 받아서 assign · 흐름 따라 다음 */ const numberStep = ( prompt: unknown, take: (value: number, context: ArrayContext) => Partial | 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 = { [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('ARRAYTYPE') ?? O.RECTANGULAR.key), }, on: { 'OPTION.RECTANGULAR': { actions: [ () => remember('ARRAYTYPE', O.RECTANGULAR.key), assign(({ context }: Ctx) => { const spacing = initialSpacing(context.originals); return { columns: recall('ARRAY.COLS') ?? 4, rows: recall('ARRAY.ROWS') ?? 3, columnSpacing: spacing.column, rowSpacing: spacing.row, }; }), ], target: S.EDIT_RECTANGULAR, }, 'OPTION.POLAR': { actions: [ () => remember('ARRAYTYPE', O.POLAR.key), assign(() => ({ items: recall('ARRAY.ITEMS') ?? 6, fill: recall('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, });