Files
Aislo/B07_DesignDetail/openwebcad/src/tools/array-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

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