Files
Aislo/B07_DesignDetail/openwebcad/src/tools/copy-tool.ts
T
eomsangdonandClaude Opus 5.5 1d5cdff0a5 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
2026-10-16 00:25:20 +09:00

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,
});