- 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
322 lines
10 KiB
TypeScript
322 lines
10 KiB
TypeScript
/**
|
|
* 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<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[]) => [
|
|
{
|
|
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<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,
|
|
});
|