/** * 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, setAngleGuideOriginPoint, setGhostHelperEntities, setShouldDrawHelpers, undo, } from '../state'; import { Tool } from '../tools'; 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', WAITING_FOR_SELECTION = 'WAITING_FOR_SELECTION', 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 interface CopyContext extends ToolContext, PickedContext { base: Point | null; /** COPYMODE 단일 */ single: boolean; /** 다중(M) — 이번만 되풀이 */ multiple: boolean; /** 만든 사본 묶음 수(U 되돌림 · 물음 바뀜) */ made: number; /** 배열(A) 항목 수(원본 포함) */ items: number; /** 맞춤(F) — 두 번째 점 = 마지막 항목 */ fit: boolean; } 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) => !!context.single && !context.multiple; const PROMPT = { base: (context: Partial): PromptSpec => ({ text: '기준점 지정', options: [OPT.DISPLACEMENT, MODE, ...(once(context) ? [OPT.MULTIPLE] : [])], default: { kind: 'option', key: OPT.DISPLACEMENT.key }, }), mode: (context: Partial): PromptSpec => ({ text: '복사 모드 옵션 입력', options: [SINGLE, OPT.MULTIPLE], default: { kind: 'option', key: context.single ? SINGLE.key : OPT.MULTIPLE.key }, }), second: (context: Partial): 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): 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[]) => [ { guard: ({ context }: Ctx) => once(context), actions: (args: Ctx) => place(copies(args)), target: END_STATE, }, { actions: [(args: Ctx) => place(copies(args)), bump], target: CopyState.WAITING_FOR_SECOND_POINT, }, ]; const recordBase = { actions: assign(({ event }: Ctx) => { const base = getPointFromEvent(null, event as PointInputEvent); setAngleGuideOriginPoint(base); return { base }; }), target: CopyState.WAITING_FOR_SECOND_POINT, }; const S = CopyState; // biome-ignore lint/suspicious/noExplicitAny: 공용 상태 조각(여러 도구 공통)을 섞어 만든다 const states: Record = { [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, });