Merge remote-tracking branch 'origin/sub_laptop_2' into main_laptop_1 — 337 가-1 옵션 틀 · 마-1 import 겹침 풂(안 쓰는 import 셋 뺌)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01C6BNBFXFndbqYxu6sZuknK
This commit is contained in:
@@ -57,7 +57,7 @@
|
||||
{"id": "MTEXT", "label": "여러 줄 문자", "aliases": ["T", "MT"]},
|
||||
{"id": "TEXT", "label": "단일 행 문자", "aliases": ["DT"]},
|
||||
{"id": "STYLE", "label": "문자 스타일", "aliases": ["ST"]},
|
||||
{"id": "TEXTEDIT", "label": "문자 편집", "aliases": ["TEDIT"]},
|
||||
{"id": "TEXTEDIT", "label": "문자 편집", "aliases": ["ED", "TEDIT"]},
|
||||
{"id": "FIND", "label": "찾기·대치", "aliases": []},
|
||||
{"id": "DIM", "label": "치수", "aliases": []},
|
||||
{"id": "DIMLINEAR", "label": "선형 치수", "aliases": ["DLI"]},
|
||||
@@ -136,7 +136,7 @@
|
||||
{"id": "IMAGEATTACH", "label": "이미지 부착", "aliases": ["IAT"]},
|
||||
{"id": "BLOCK", "label": "블록 작성", "aliases": ["B"]},
|
||||
{"id": "INSERT", "label": "블록 삽입", "aliases": ["I"]},
|
||||
{"id": "SELECT", "label": "선택", "aliases": ["SE"]},
|
||||
{"id": "SELECT", "label": "선택", "aliases": []},
|
||||
{"id": "ZOOM", "label": "범위 줌", "aliases": ["Z"]},
|
||||
{"id": "ZOOMIN", "label": "확대", "aliases": []},
|
||||
{"id": "ZOOMOUT", "label": "축소", "aliases": []},
|
||||
|
||||
@@ -81,7 +81,7 @@ export const TEXT_COMMANDS: CadCommand[] = [
|
||||
{
|
||||
id: 'TEXTEDIT',
|
||||
label: '문자 편집',
|
||||
aliases: ['TEDIT'],
|
||||
aliases: ['ED', 'TEDIT'],
|
||||
glyph: '✎',
|
||||
hint: '기존 문자의 내용을 고친다',
|
||||
tool: Tool.TEXTEDIT,
|
||||
|
||||
@@ -7,7 +7,8 @@ import { requestDrawingExport, requestHostSave } from '../integration/aislo-draw
|
||||
import { runSendSelectionTable } from '../integration/aislo-table-export';
|
||||
import { exportEntitiesToPngFile } from '../helpers/import-export-handlers/export-entities-to-png';
|
||||
import { exportEntitiesToSvgFile } from '../helpers/import-export-handlers/export-entities-to-svg';
|
||||
import { isHostSave, redo, undo } from '../state';
|
||||
import { redoCommand, undoCommand } from '../inputController/prompt-input';
|
||||
import { isHostSave } from '../state';
|
||||
import type { CadCommand } from './command.types';
|
||||
|
||||
export const FILE_COMMANDS: CadCommand[] = [
|
||||
@@ -102,7 +103,7 @@ export const FILE_COMMANDS: CadCommand[] = [
|
||||
aliases: ['U'],
|
||||
glyph: '↶',
|
||||
run: () => {
|
||||
undo();
|
||||
undoCommand(); // 끊고 뒤 되돌림 — Ctrl+Z 와 한 길
|
||||
return '실행 취소';
|
||||
},
|
||||
},
|
||||
@@ -111,7 +112,7 @@ export const FILE_COMMANDS: CadCommand[] = [
|
||||
label: '다시 실행',
|
||||
glyph: '↷',
|
||||
run: () => {
|
||||
redo();
|
||||
redoCommand();
|
||||
return '다시 실행';
|
||||
},
|
||||
},
|
||||
|
||||
@@ -33,7 +33,6 @@ export const VIEW_COMMANDS: CadCommand[] = [
|
||||
{
|
||||
id: 'SELECT',
|
||||
label: '선택',
|
||||
aliases: ['SE'],
|
||||
glyph: '↖',
|
||||
hint: '객체를 클릭하거나 선택 사각형으로 고른다',
|
||||
tool: Tool.SELECT,
|
||||
|
||||
@@ -0,0 +1,179 @@
|
||||
/**
|
||||
* 물음 꼴(337 설계 2-3 · 2-4 · 2-9) — 오토캐드 명령행 한 줄 「글 또는 [이름(키)/…] <기본>:」 ·
|
||||
* 옵션 맞추기(initget 줄임 규칙) · 세션 기억(앞에 쓴 값이 다음 <기본>).
|
||||
* 도구는 상태 `meta: { kind, prompt }` 로 물음을 선언하고 `OPTION.<key>` 사건으로 옵션을 받는다.
|
||||
* 이 파일은 상태(state.ts)를 읽지 않음 — 기억은 탭 수명 Map 하나.
|
||||
*/
|
||||
import type { StepKind } from '../tools/tool.types';
|
||||
|
||||
export type XY = { x: number; y: number };
|
||||
|
||||
/** 옵션 하나 — 그림 「label(kw)」 · 사건 `OPTION.<key>` · 맞추기는 kw · en */
|
||||
export interface OptionSpec {
|
||||
key: string;
|
||||
kw: string;
|
||||
en: string;
|
||||
label: string;
|
||||
hidden?: true;
|
||||
}
|
||||
|
||||
export type DefaultSpec =
|
||||
| { kind: 'value'; remember: string; initial: number | XY | null; format?: 'len' | 'int' | 'ang' }
|
||||
/** <변위> · <전체(T)>(label 로 바꿈) — 빈 Enter = `OPTION.<key>` */
|
||||
| { kind: 'option'; key: string; label?: string }
|
||||
/** <첫 번째 점을 변위로 사용> · <종료> · <객체 선택> — 빈 Enter = `ENTER` */
|
||||
| { kind: 'enter'; label: string };
|
||||
|
||||
export interface PromptSpec {
|
||||
/** '다음 점 지정' — ' 또는 [..]' · ' <..>' · ':' 는 renderPrompt 가 붙임 */
|
||||
text: string;
|
||||
/** 넣은 옵션(숨김 포함) */
|
||||
options?: OptionSpec[];
|
||||
/** 오토캐드엔 있고 우리엔 없음 — 치면 안내 · 안 그림 */
|
||||
absent?: OptionSpec[];
|
||||
default?: DefaultSpec;
|
||||
number?: { int?: true; min?: number; max?: number; positive?: true };
|
||||
/** 거절 글 바꿈(없으면 물음 종류 기본 글) */
|
||||
reject?: string;
|
||||
/** ↑↓ 최근 값 묶음(없으면 text) */
|
||||
history?: string;
|
||||
}
|
||||
|
||||
// biome-ignore lint/suspicious/noExplicitAny: 도구마다 context 꼴이 다름
|
||||
export type PromptLike = PromptSpec | ((context: any) => PromptSpec);
|
||||
|
||||
/** 상태 meta 꼴 — `instructions` 는 옛 꼴(전환 중) */
|
||||
export interface PromptMeta {
|
||||
kind?: StepKind;
|
||||
prompt?: PromptLike;
|
||||
instructions?: string;
|
||||
/** 옛 꼴 — 점 물음이 받는 옵션 글자(대문자) */
|
||||
options?: string[];
|
||||
}
|
||||
|
||||
/** 함수 꼴 물음을 지금 context 로 풂 */
|
||||
export const resolvePrompt = (prompt: PromptLike | undefined, context: unknown): PromptSpec | null =>
|
||||
prompt ? (typeof prompt === 'function' ? prompt(context) : prompt) : null;
|
||||
|
||||
// ---- 세션 기억(설계 2-9) ----
|
||||
|
||||
const memory = new Map<string, unknown>();
|
||||
let lastPicked: string[] = [];
|
||||
let lastPointValue: XY | null = null;
|
||||
|
||||
export const remember = (key: string, value: unknown): void => {
|
||||
memory.set(key, value);
|
||||
};
|
||||
export const recall = <T = unknown>(key: string): T | undefined => memory.get(key) as T | undefined;
|
||||
/** 고르기 이전(P) — 명령이 고른 것을 쓸 때 */
|
||||
export const rememberSelection = (ids: string[]): void => {
|
||||
lastPicked = [...ids];
|
||||
};
|
||||
export const previousSelection = (): string[] => lastPicked;
|
||||
/** 마지막 점(LASTPOINT) — `@` 의 기준 */
|
||||
export const rememberPoint = (point: XY): void => {
|
||||
lastPointValue = { x: point.x, y: point.y };
|
||||
};
|
||||
export const lastPoint = (): XY | null => lastPointValue;
|
||||
|
||||
// ---- 그림(설계 2-3) ----
|
||||
|
||||
const isXY = (value: unknown): value is XY =>
|
||||
typeof value === 'object' &&
|
||||
value !== null &&
|
||||
typeof (value as XY).x === 'number' &&
|
||||
typeof (value as XY).y === 'number';
|
||||
|
||||
/** 기본 값 — 기억 우선 · 없으면 처음 값 */
|
||||
export function defaultValue(spec: DefaultSpec & { kind: 'value' }): number | XY | null {
|
||||
const remembered = recall<number | XY>(spec.remember);
|
||||
return remembered ?? spec.initial;
|
||||
}
|
||||
|
||||
const formatNumber = (value: number, format: 'len' | 'int' | 'ang' = 'len'): string => {
|
||||
if (format === 'int') return String(Math.round(value));
|
||||
if (format === 'ang') return String(Number(value.toFixed(4)));
|
||||
return value.toFixed(4); // LUPREC 4
|
||||
};
|
||||
|
||||
function renderDefault(prompt: PromptSpec): string | null {
|
||||
const spec = prompt.default;
|
||||
if (!spec) return null;
|
||||
if (spec.kind === 'enter') return spec.label;
|
||||
if (spec.kind === 'option') {
|
||||
return spec.label ?? prompt.options?.find((option) => option.key === spec.key)?.label ?? null;
|
||||
}
|
||||
const value = defaultValue(spec);
|
||||
if (value === null) return null;
|
||||
if (isXY(value)) return `${value.x.toFixed(4)}, ${value.y.toFixed(4)}`;
|
||||
return formatNumber(value, spec.format);
|
||||
}
|
||||
|
||||
/** 「다음 점 지정 또는 [닫기(C)/명령 취소(U)] <10.0000>:」 · 키워드 물음은 「또는」 없이 「옵션 입력 [..] <I>:」 */
|
||||
export function renderPrompt(prompt: PromptSpec, kind?: StepKind): string {
|
||||
const shown = (prompt.options ?? []).filter((option) => !option.hidden);
|
||||
const joiner = kind === 'keyword' ? ' ' : ' 또는 ';
|
||||
const options = shown.length
|
||||
? `${joiner}[${shown.map((option) => `${option.label}(${option.kw})`).join('/')}]`
|
||||
: '';
|
||||
const fallback = renderDefault(prompt);
|
||||
return `${prompt.text}${options}${fallback === null ? '' : ` <${fallback}>`}:`;
|
||||
}
|
||||
|
||||
// ---- 옵션 맞추기(설계 2-4) ----
|
||||
|
||||
/** 친 글 → 맞추기 꼴 — 빈칸 뗌 · 대문자 · 앞 `_` 하나 뗌 */
|
||||
export const normalizeKeyword = (text: string): string => text.trim().toUpperCase().replace(/^_/, '');
|
||||
|
||||
const matches = (text: string, option: OptionSpec): boolean =>
|
||||
text === option.kw || text === option.en || (option.en.startsWith(text) && text.startsWith(option.kw));
|
||||
|
||||
export type OptionMatch = { option: OptionSpec } | { absent: OptionSpec } | null;
|
||||
|
||||
/** 친 글이 맞는 옵션 — kw 딱 맞음 우선 · 숨김도 · 없는 옵션(absent)은 안내용 */
|
||||
export function matchOption(text: string, prompt: PromptSpec | null | undefined): OptionMatch {
|
||||
if (!prompt) return null;
|
||||
const typed = normalizeKeyword(text);
|
||||
if (!typed) return null;
|
||||
const pick = (list: OptionSpec[] | undefined) => {
|
||||
const hits = (list ?? []).filter((option) => matches(typed, option));
|
||||
return hits.find((option) => option.kw === typed) ?? hits[0];
|
||||
};
|
||||
const option = pick(prompt.options);
|
||||
if (option) return { option };
|
||||
const absent = pick(prompt.absent);
|
||||
return absent ? { absent } : null;
|
||||
}
|
||||
|
||||
// ---- 공용 옵션 ----
|
||||
|
||||
const opt = (key: string, kw: string, label: string, en = key): OptionSpec => ({
|
||||
key,
|
||||
kw,
|
||||
en,
|
||||
label,
|
||||
});
|
||||
const hidden = (option: OptionSpec): OptionSpec => ({ ...option, hidden: true });
|
||||
|
||||
/** 여러 명령이 같이 쓰는 옵션 — 글자가 다른 명령(종료(X) 등)은 그 도구가 따로 만듦 */
|
||||
export const OPT = {
|
||||
UNDO: opt('UNDO', 'U', '명령 취소'),
|
||||
CLOSE: opt('CLOSE', 'C', '닫기'),
|
||||
EXIT: opt('EXIT', 'E', '종료'),
|
||||
MULTIPLE: opt('MULTIPLE', 'M', '다중'),
|
||||
YES: opt('YES', 'Y', '예'),
|
||||
NO: opt('NO', 'N', '아니오'),
|
||||
DISPLACEMENT: opt('DISPLACEMENT', 'D', '변위'),
|
||||
COPY: opt('COPY', 'C', '복사'),
|
||||
REFERENCE: opt('REFERENCE', 'R', '참조'),
|
||||
TEXT: opt('TEXT', 'T', '문자'),
|
||||
ALL: hidden(opt('ALL', 'ALL', '전체')),
|
||||
LAST: hidden(opt('LAST', 'L', '최종')),
|
||||
PREVIOUS: hidden(opt('PREVIOUS', 'P', '이전')),
|
||||
} as const;
|
||||
|
||||
/** 고르기 물음 공용(설계 2-15) — 숨은 옵션 ALL · L · P 의 처리는 선택 도구 몫 */
|
||||
export const SELECT_OBJECTS: PromptSpec = {
|
||||
text: '객체 선택',
|
||||
options: [OPT.ALL, OPT.LAST, OPT.PREVIOUS],
|
||||
};
|
||||
@@ -24,6 +24,7 @@ import {
|
||||
import { ALIGN_COMMANDS, MODIFY_COMMANDS } from './commands.modify';
|
||||
import { VIEW_COMMANDS } from './commands.view';
|
||||
import { inputPrefs } from '../../../../A00_Common/input_prefs';
|
||||
import '../tools/factories/end-command'; // 명령 끝 → 쉬는 상태 갈고리
|
||||
|
||||
const ALL_COMMANDS: CadCommand[] = [
|
||||
...DRAW_COMMANDS,
|
||||
@@ -121,15 +122,14 @@ function userAliasCommands(match: (alias: string) => boolean): CadCommand[] {
|
||||
});
|
||||
}
|
||||
|
||||
/** 명령행 입력 해석 — 이름 > 내 별칭 > 기본 별칭(acad.pgp 식), 없으면 접두사 후보의 첫 항목 */
|
||||
/** 명령행 입력 해석 — 이름 > 내 별칭 > 기본 별칭(acad.pgp 식) 딱 맞음만(앞글자 실행 없음 · 후보는 목록) */
|
||||
export function resolveCommandInput(input: string): CadCommand | undefined {
|
||||
const text = input.trim().toUpperCase();
|
||||
const text = input.trim().toUpperCase().replace(/^_/, '');
|
||||
if (!text) return undefined;
|
||||
return (
|
||||
COMMANDS_BY_ID.get(text) ??
|
||||
userAliasCommands((alias) => alias === text)[0] ??
|
||||
COMMANDS_BY_ALIAS.get(text) ??
|
||||
matchCommandPrefixes(text)[0]
|
||||
COMMANDS_BY_ALIAS.get(text)
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -3,6 +3,7 @@ import { toast } from 'react-toastify';
|
||||
import { Actor } from 'xstate';
|
||||
import { HtmlEvent } from '../App.types';
|
||||
import { releaseOnePoint } from '../inputController/input-angle-lock';
|
||||
import { cancelActiveCommand } from '../inputController/prompt-input';
|
||||
import { getSelectedEntities, isDrawingReadOnly, notifyWindow, setActiveToolActor } from '../state';
|
||||
import type { CadCommand } from './command.types';
|
||||
import { getCommandById, isViewOnlyCommand, resolveCommandInput } from './registry';
|
||||
@@ -15,7 +16,8 @@ const commandHistory: string[] = [];
|
||||
export const getLastCommandId = (): string | null => lastCommandId;
|
||||
export const getCommandHistory = (): string[] => commandHistory;
|
||||
|
||||
function log(line: string) {
|
||||
/** 기록 줄에 한 줄(명령 · 거절 · 알림) */
|
||||
export function logLine(line: string) {
|
||||
commandHistory.push(line);
|
||||
if (commandHistory.length > COMMAND_HISTORY_LIMIT) {
|
||||
commandHistory.splice(0, commandHistory.length - COMMAND_HISTORY_LIMIT);
|
||||
@@ -29,12 +31,12 @@ export function runCommand(command: CadCommand): string {
|
||||
// 이 한 곳을 지나므로 여기서 한 번 막으면 새는 길이 없다 (2026-09-01 사용자 확정).
|
||||
if (isDrawingReadOnly() && !isViewOnlyCommand(command)) {
|
||||
toast.info(`확정한 도면입니다. 고치려면 [수정]을 먼저 누르세요. (${command.label})`);
|
||||
log(`${command.id}: 확정 도면 — 실행하지 않음`);
|
||||
logLine(`${command.id}: 확정 도면 — 실행하지 않음`);
|
||||
return '';
|
||||
}
|
||||
if (command.needsSelection && getSelectedEntities().length === 0) {
|
||||
toast.info(`${command.label}: 객체를 먼저 선택하십시오.`);
|
||||
log(`${command.id}: 선택 없음`);
|
||||
logLine(`${command.id}: 선택 없음`);
|
||||
return '';
|
||||
}
|
||||
|
||||
@@ -42,15 +44,16 @@ export function runCommand(command: CadCommand): string {
|
||||
releaseOnePoint(); // 앞 명령의 각도 고정 · 동적 입력 잠금은 새 명령에 넘기지 않음
|
||||
|
||||
if (command.machine) {
|
||||
cancelActiveCommand(); // 앞 명령을 끊고 켬(오토캐드 ^C^C) · 즉시형은 도구를 둠
|
||||
setActiveToolActor(new Actor(command.machine));
|
||||
// 도구를 켠 뒤 준비 동작이 필요한 명령(파일 선택 등)은 run()도 함께 부른다
|
||||
command.run?.();
|
||||
log(`${command.id} ${command.label}`);
|
||||
logLine(`${command.id} ${command.label}`);
|
||||
return command.id;
|
||||
}
|
||||
|
||||
const result = command.run?.();
|
||||
log(`${command.id} ${typeof result === 'string' ? result : command.label}`);
|
||||
logLine(`${command.id} ${typeof result === 'string' ? result : command.label}`);
|
||||
return typeof result === 'string' ? result : command.id;
|
||||
}
|
||||
|
||||
|
||||
@@ -3,14 +3,15 @@ import { type FC, type FormEvent, useMemo, useRef, useState } from 'react';
|
||||
import { describeCommand, matchCommandPrefixes } from '../commands/registry';
|
||||
import { getCommandHistory, runCommandInput } from '../commands/run-command';
|
||||
import { activeToolWantsFreeText, isPlainSpace } from '../inputController/ime-sink';
|
||||
import { getInputController, getLastStateInstructions } from '../state';
|
||||
import { currentPrompt } from '../inputController/prompt-input';
|
||||
import { getInputController } from '../state';
|
||||
|
||||
export const CommandLine: FC = () => {
|
||||
const [text, setText] = useState('');
|
||||
const [historyIndex, setHistoryIndex] = useState<number | null>(null);
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
const history = getCommandHistory();
|
||||
const instruction = getLastStateInstructions() || '명령을 입력하거나 도구를 선택하십시오.';
|
||||
const instruction = currentPrompt().line || '명령을 입력하거나 도구를 선택하십시오.';
|
||||
|
||||
const suggestions = useMemo(() => matchCommandPrefixes(text).slice(0, 8), [text]);
|
||||
|
||||
|
||||
@@ -112,7 +112,7 @@ describe('Space = Enter(오토캐드)', () => {
|
||||
expect(activeTool()).toBe(Tool.TEXT);
|
||||
});
|
||||
|
||||
it('빈 Space · Enter = 마지막 명령 반복 · 빈 우클릭은 반복 안 함', () => {
|
||||
it('빈 Space · Enter · 우클릭 = 마지막 명령 반복(337 · 쉴 때 우클릭도)', () => {
|
||||
selectIdle();
|
||||
const controller = new InputController();
|
||||
press(controller, 'l');
|
||||
@@ -135,6 +135,6 @@ describe('Space = Enter(오토캐드)', () => {
|
||||
preventDefault: vi.fn(),
|
||||
stopPropagation: vi.fn(),
|
||||
} as unknown as MouseEvent);
|
||||
expect(activeTool()).toBe(Tool.SELECT);
|
||||
expect(activeTool()).toBe(Tool.LINE);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -9,33 +9,6 @@ import { getActiveToolActor } from '../state.ts';
|
||||
export const isImeKey = (evt: KeyboardEvent): boolean =>
|
||||
evt.isComposing || evt.keyCode === 229 || evt.key === 'Process';
|
||||
|
||||
/** 활성 도구의 지금 단계 설정(평평한 단계만) */
|
||||
const activeStateConfig = () => {
|
||||
const snapshot = getActiveToolActor()?.getSnapshot();
|
||||
return snapshot?.machine?.states?.[snapshot?.value]?.config;
|
||||
};
|
||||
|
||||
/** 활성 도구의 지금 단계가 TEXT_INPUT 을 받는가 */
|
||||
export function activeToolAcceptsText(): boolean {
|
||||
return !!activeStateConfig()?.on?.TEXT_INPUT;
|
||||
}
|
||||
|
||||
/** 지금 단계가 점(`x,y`)을 받는가 — 한 번 스냅 이름 · 선택지 판정 */
|
||||
export function activeToolWantsPoint(): boolean {
|
||||
return !!activeStateConfig()?.on?.ABSOLUTE_POINT_INPUT;
|
||||
}
|
||||
|
||||
/** 지금 단계가 이 글을 받는가 — 점 단계는 선택지 글자(meta.options · 선 C · U)만 · 나머지는 명령 이름으로 */
|
||||
export function activeToolTakesText(text: string): boolean {
|
||||
if (!activeToolAcceptsText()) return false;
|
||||
if (!activeToolWantsPoint()) return true;
|
||||
const options = (activeStateConfig()?.meta as { options?: string[] } | undefined)?.options;
|
||||
return !!options?.includes(text);
|
||||
}
|
||||
|
||||
/** 한글 받개에 초점을 줄 단계 — 글은 받되 점 단계 선택지(영문 C · U)는 아님 */
|
||||
export const activeToolWantsIme = (): boolean => activeToolAcceptsText() && !activeToolWantsPoint();
|
||||
|
||||
/** 지금 단계가 자유 글(TEXT · MTEXT · 표 칸 · 이름 …)을 받는가 — Space 가 띄어쓰기(아니면 Enter) */
|
||||
export function activeToolWantsFreeText(): boolean {
|
||||
const meta = getActiveToolActor()?.getSnapshot()?.getMeta?.() as
|
||||
@@ -44,6 +17,9 @@ export function activeToolWantsFreeText(): boolean {
|
||||
return Object.values(meta ?? {}).some((stateMeta) => stateMeta?.kind === 'text');
|
||||
}
|
||||
|
||||
/** 한글 받개에 초점을 줄 단계 = 글 물음(`kind: 'text'`)만 — 숫자 · 키워드 물음은 영문 자리 글자 */
|
||||
export const activeToolWantsIme = (): boolean => activeToolWantsFreeText();
|
||||
|
||||
/** Enter 로 받을 Space — 수정키(Shift · Ctrl · Alt · Meta: 한/영 전환 등) · 길게 눌러 반복은 아님 */
|
||||
export const isPlainSpace = (
|
||||
evt: Pick<KeyboardEvent, 'key' | 'shiftKey' | 'ctrlKey' | 'altKey' | 'metaKey' | 'repeat'>
|
||||
|
||||
@@ -1,13 +1,8 @@
|
||||
import { Point } from '@flatten-js/core';
|
||||
import { compact, round } from 'es-toolkit';
|
||||
import { Actor } from 'xstate';
|
||||
import { MouseButton } from '../App.types.ts';
|
||||
import {
|
||||
describeCommand,
|
||||
matchCommandPrefixes,
|
||||
resolveCommandInput,
|
||||
} from '../commands/registry.ts';
|
||||
import { repeatLastCommand, runCommand } from '../commands/run-command.ts';
|
||||
import { describeCommand, matchCommandPrefixes } from '../commands/registry.ts';
|
||||
import { logLine, runCommandInput } from '../commands/run-command.ts';
|
||||
import { requestHostSave } from '../integration/aislo-drawing-bridge.ts';
|
||||
import type { ScreenCanvasDrawController } from '../drawControllers/screenCanvas.drawController.ts';
|
||||
import { calculateAngleGuidesAndSnapPoints } from '../helpers/calculate-angle-guides-and-snap-points.ts';
|
||||
@@ -21,68 +16,58 @@ import {
|
||||
getHighlightedEntityIds,
|
||||
getPickableEntities,
|
||||
getGridEnabled,
|
||||
getLastStateInstructions,
|
||||
getPanStartLocation,
|
||||
getScreenCanvasDrawController,
|
||||
getSelectedEntities,
|
||||
getSnapEnabled,
|
||||
getSnapPoint,
|
||||
getSnapPointOnAngleGuide,
|
||||
isHostSave,
|
||||
isViewOnly,
|
||||
redo,
|
||||
setActiveToolActor,
|
||||
setAngleStep,
|
||||
setGhostHelperEntities,
|
||||
setGridEnabled,
|
||||
setHighlightedEntityIds,
|
||||
setPanStartLocation,
|
||||
setSelectedEntityIds,
|
||||
setShouldDrawCursor,
|
||||
setSnapEnabled,
|
||||
undo,
|
||||
} from '../state.ts';
|
||||
import { Tool } from '../tools.ts';
|
||||
import { SelectState, selectToolStateMachine } from '../tools/select-tool.ts';
|
||||
import { ActorEvent, type MouseClickEvent, type TextInputEvent } from '../tools/tool.types.ts';
|
||||
import {
|
||||
activeToolTakesText,
|
||||
activeToolWantsFreeText,
|
||||
activeToolWantsIme,
|
||||
activeToolWantsPoint,
|
||||
ImeSink,
|
||||
isImeKey,
|
||||
isPlainSpace,
|
||||
} from './ime-sink.ts';
|
||||
import { SelectState } from '../tools/select-tool.ts';
|
||||
import { ActorEvent, type MouseClickEvent } from '../tools/tool.types.ts';
|
||||
import { activeToolWantsFreeText, activeToolWantsIme, ImeSink, isImeKey, isPlainSpace } from './ime-sink.ts';
|
||||
import { drawInputField, dynamicInputLine } from './input-field-draw.ts';
|
||||
import { ctrlShortcut } from './input-shortcut.ts';
|
||||
import { cadCtrlCommand, ctrlShortcut } from './input-shortcut.ts';
|
||||
import { wheelZoom } from './input-wheel.ts';
|
||||
import { isObjectSnap, noteShift, snapAt } from './ortho.ts';
|
||||
import { isWaitingForSecondSelectPoint, selectingActor } from './selecting.ts';
|
||||
import { isMiddleDouble, zoomExtents } from './zoom-prompt.ts';
|
||||
import { attachTouch } from './input-touch.ts';
|
||||
import {
|
||||
ANGLE_LOCK_REGEXP,
|
||||
NUMBER_REGEXP,
|
||||
parsePointText,
|
||||
type PointTextEvent,
|
||||
} from './input-patterns.ts';
|
||||
import { NUMBER_REGEXP, type PointTextEvent } from './input-patterns.ts';
|
||||
import {
|
||||
angleLockHint,
|
||||
getAngleLock,
|
||||
getDynamicField,
|
||||
getLockedLength,
|
||||
releaseOnePoint,
|
||||
setAngleLock,
|
||||
tabDynamicInput,
|
||||
} from './input-angle-lock.ts';
|
||||
import {
|
||||
getSnapOverride,
|
||||
isSnapName,
|
||||
setSnapOverride,
|
||||
snapOverrideHint,
|
||||
snapOverridePoint,
|
||||
} from './input-snap-override.ts';
|
||||
import {
|
||||
cancelActiveCommand,
|
||||
closeSubPrompt,
|
||||
currentNotice,
|
||||
currentPrompt,
|
||||
dynamicInputApplies,
|
||||
isIdle,
|
||||
notice,
|
||||
redoCommand,
|
||||
routeTyped,
|
||||
sendPoint,
|
||||
undoCommand,
|
||||
} from './prompt-input.ts';
|
||||
|
||||
/** 이만큼 넘게 끌면 클릭이 아니라 드래그로 본다 (화면 픽셀) */
|
||||
const DRAG_SELECT_MIN_PX = 4;
|
||||
@@ -138,17 +123,20 @@ export class InputController {
|
||||
|
||||
public draw(drawController: ScreenCanvasDrawController) {
|
||||
this.ime.sync(activeToolWantsIme());
|
||||
const toolInstruction = getLastStateInstructions();
|
||||
const toolInstruction = currentPrompt().line;
|
||||
const texts: string[] = [];
|
||||
if (toolInstruction) {
|
||||
// Draw tool instruction
|
||||
texts.push(toolInstruction);
|
||||
}
|
||||
texts.push(...compact([currentNotice()])); // 거절 · 안내 — 둘째 줄
|
||||
if (toolInstruction) {
|
||||
const roundedX = round(drawController.getWorldMouseLocation().x, 2);
|
||||
const roundedY = round(drawController.getWorldMouseLocation().y, 2);
|
||||
texts.push(`${roundedX},${roundedY}`);
|
||||
}
|
||||
const base = getAngleGuideOriginPoint();
|
||||
if (base && this.dynamicInputApplies()) {
|
||||
if (base && dynamicInputApplies()) {
|
||||
texts.push(
|
||||
dynamicInputLine(
|
||||
base,
|
||||
@@ -175,10 +163,10 @@ export class InputController {
|
||||
|
||||
public handleMouseUp(evt: MouseEvent) {
|
||||
if (evt.button === MouseButton.Right) {
|
||||
// Right click => confirm action (ENTER)
|
||||
// 우클릭 = Enter(명령 중 확정 · 쉴 때 반복)
|
||||
evt.preventDefault();
|
||||
evt.stopPropagation();
|
||||
this.handleEnterKey();
|
||||
this.handleEnterKey(true);
|
||||
}
|
||||
|
||||
// If ancestor parent exist with class .controls => ignore clicks, since a button was clicked instead of the canvas
|
||||
@@ -379,8 +367,10 @@ export class InputController {
|
||||
return;
|
||||
}
|
||||
const shortcut = ctrlShortcut(evt);
|
||||
const cadCommand = cadCtrlCommand(evt);
|
||||
// 한글 조합 중 — 단축키가 먹지 않게 IME 에 넘김(Ctrl 단축키는 조합 중에도 먹음)
|
||||
if (isImeKey(evt) && !shortcut) return;
|
||||
if (isImeKey(evt) && !shortcut && !cadCommand) return;
|
||||
if (!['Shift', 'Control', 'Alt', 'Meta'].includes(evt.key)) notice(null); // 알림 줄은 다음 키까지
|
||||
console.log(`key pressed: ${evt.key}`);
|
||||
if (evt.key === 'F12') {
|
||||
// F12 => open developer tools
|
||||
@@ -419,7 +409,7 @@ export class InputController {
|
||||
}
|
||||
if (evt.key === 'Tab') {
|
||||
// 점 단계(기준점 있음) = 동적 입력 칸 넘김 · 그 밖 = 브라우저 초점 이동
|
||||
if (!this.dynamicInputApplies()) return;
|
||||
if (!dynamicInputApplies()) return;
|
||||
evt.preventDefault();
|
||||
const isNumber = NUMBER_REGEXP.test(this.text);
|
||||
if (this.text && !isNumber) return; // 숫자 아닌 글(@x,y 등)은 그대로 둠
|
||||
@@ -427,6 +417,12 @@ export class InputController {
|
||||
this.text = '';
|
||||
return;
|
||||
}
|
||||
if (cadCommand) {
|
||||
// Ctrl+C · X · Shift+C · Shift+V · 1 — 오토캐드 클립보드 · 특성
|
||||
evt.preventDefault();
|
||||
runCommandInput(cadCommand);
|
||||
return;
|
||||
}
|
||||
if (evt.ctrlKey && evt.code === 'KeyV') {
|
||||
// 막지 않음 — 브라우저가 `paste` 를 쏘고 `integration/dxf-clip-paste.ts` 가 받음(한글 자판도 code 로)
|
||||
return;
|
||||
@@ -480,163 +476,66 @@ export class InputController {
|
||||
} else if (evt.key === 'ArrowRight') {
|
||||
// Move the screen right
|
||||
getScreenCanvasDrawController().setScreenOffset(this.getScreenPanStep('right', evt.shiftKey));
|
||||
} else if (evt.key?.length === 1) {
|
||||
} else if (evt.key?.length === 1 && !evt.ctrlKey && !evt.altKey && !evt.metaKey) {
|
||||
// +·-는 확대·축소 단축키로 쓰지 않는다 — 음수 좌표(-100,-50)의 첫 글자를
|
||||
// 먹어 버렸다. 줌은 휠·뷰 막대·ZOOMIN/ZOOMOUT 명령으로 한다.
|
||||
// User entered a single character => add to input field text
|
||||
// 글 단계(TEXT·MTEXT)는 친 그대로 — 명령 입력일 때만 대문자로 바꾼다.
|
||||
// 글 단계(TEXT·MTEXT)는 친 그대로 — 명령 입력일 때만 대문자로 바꾼다(보이는 글만 · 맞추기는 prompt.ts).
|
||||
// Ctrl · Alt · ⌘ 와 함께 누른 글자는 쌓지 않음(Ctrl+C → 원이 되던 것).
|
||||
this.text += activeToolWantsFreeText() ? evt.key : evt.key.toUpperCase();
|
||||
}
|
||||
}
|
||||
|
||||
/** Esc 한 번 = 명령 끝(오토캐드) — 친 글 · 조합 · 각도 잠금 · 한 번 스냅 · 덧물음도 함께 풂 */
|
||||
public handleEscapeKey() {
|
||||
this.text = '';
|
||||
this.ime.composing = '';
|
||||
const boxing =
|
||||
this.isSelectToolActive() &&
|
||||
getActiveToolActor()?.getSnapshot()?.value === SelectState.WAITING_FOR_SECOND_SELECT_POINT;
|
||||
if (boxing) {
|
||||
getActiveToolActor()?.send({ type: ActorEvent.ESC }); // 상자 취소
|
||||
} else if (isIdle()) {
|
||||
setSelectedEntityIds([]); // 쉴 때 = 고름 풂
|
||||
} else {
|
||||
cancelActiveCommand();
|
||||
}
|
||||
releaseOnePoint();
|
||||
setSnapOverride(null);
|
||||
if (getSelectedEntities().length > 0) {
|
||||
// Deselect entities
|
||||
setSelectedEntityIds([]);
|
||||
} else if (this.text === '') {
|
||||
// Cancel tool action
|
||||
getActiveToolActor()?.send({
|
||||
type: ActorEvent.ESC,
|
||||
});
|
||||
} else {
|
||||
// clear the input field
|
||||
this.text = '';
|
||||
}
|
||||
closeSubPrompt();
|
||||
logLine('*취소*');
|
||||
}
|
||||
|
||||
/** 커서 옆에 띄울 명령 후보 (이름·별칭 접두사 일치) */
|
||||
/** 커서 옆에 띄울 명령 후보 (이름·별칭 접두사 일치) — 쉴 때만 */
|
||||
private getCommandSuggestions(): string[] {
|
||||
if (this.text === '') {
|
||||
if (this.text === '' || !isIdle()) {
|
||||
return [];
|
||||
}
|
||||
return matchCommandPrefixes(this.text).slice(0, 6).map(describeCommand);
|
||||
}
|
||||
|
||||
/** @param repeatIfIdle 키보드 Enter · Space — 빈 입력 + 선택 도구 대기면 마지막 명령 반복(우클릭은 확정만) */
|
||||
public handleEnterKey(repeatIfIdle = false) {
|
||||
// submit the text as input to the active tool and clear the input field
|
||||
|
||||
const lockedLength = getLockedLength();
|
||||
if (this.text === '' && lockedLength !== null && this.dynamicInputApplies()) {
|
||||
// 동적 입력 길이를 잠근 채 Enter — 그 길이(방향 = 각도 고정 · 아니면 커서)
|
||||
this.sendPointEvent(this.distanceEvent(lockedLength));
|
||||
} else if (this.text === '') {
|
||||
console.log('ENTER: ', {
|
||||
text: this.text,
|
||||
activeTool: getActiveToolActor(),
|
||||
});
|
||||
const wasSelectTool = this.isSelectToolActive();
|
||||
const selectIdle =
|
||||
getActiveToolActor()?.getSnapshot()?.value === SelectState.WAITING_FOR_FIRST_SELECT_POINT;
|
||||
if (repeatIfIdle && wasSelectTool && selectIdle && repeatLastCommand()) return;
|
||||
// Send the ENTER event to the active tool
|
||||
getActiveToolActor()?.send({
|
||||
type: ActorEvent.ENTER,
|
||||
});
|
||||
// 선택 도구는 ENTER(우클릭)로 최종 상태에 들어가 **멈춘다**. 다른 도구가 선택을
|
||||
// 자식으로 불러 쓸 때는 그 끝남이 필요하지만(move·copy·rotate의 onDone),
|
||||
// 선택이 그 자체로 활성 도구일 때 멈추면 커서 옆 안내가 사라지고 이후 클릭도
|
||||
// 안 먹는다(2026-08-30 사용자 지적). 그 경우에만 새로 세워 준다.
|
||||
if (wasSelectTool && getActiveToolActor()?.getSnapshot()?.status === 'done') {
|
||||
setActiveToolActor(new Actor(selectToolStateMachine));
|
||||
}
|
||||
} else if (activeToolWantsFreeText()) {
|
||||
// 글 단계(TEXT · MTEXT · 표 칸 …)는 친 그대로 — 숫자 · 명령 이름도 글
|
||||
this.sendText();
|
||||
} else if (
|
||||
getDynamicField() === 'angle' &&
|
||||
NUMBER_REGEXP.test(this.text) &&
|
||||
this.dynamicInputApplies()
|
||||
) {
|
||||
// 동적 입력 각도 칸 — 각도 고정 · 길이를 잠갔으면 바로 그 점
|
||||
setAngleLock(Number.parseFloat(this.text));
|
||||
this.text = '';
|
||||
if (lockedLength !== null) this.sendPointEvent(this.distanceEvent(lockedLength));
|
||||
} else if (ANGLE_LOCK_REGEXP.test(this.text)) {
|
||||
// 각도 고정 `<30` — 다음 한 점만 그 방향
|
||||
setAngleLock(Number.parseFloat((ANGLE_LOCK_REGEXP.exec(this.text) as RegExpExecArray)[1]));
|
||||
this.text = '';
|
||||
} else if (this.pointEventFromText()) {
|
||||
// 거리 `100` · `x,y` · `@x,y` · `@d<a` · `d<a`
|
||||
this.sendPointEvent(this.pointEventFromText() as PointTextEvent);
|
||||
this.text = '';
|
||||
} else if (isSnapName(this.text) && activeToolWantsPoint()) {
|
||||
// 한 번 스냅 END · MID · … — 다음 한 점만
|
||||
setSnapOverride(this.text);
|
||||
this.text = '';
|
||||
} else if (activeToolTakesText(this.text)) {
|
||||
// 지금 단계의 선택지(선 C · U · 배열 L/R · 숫자 단계 글)
|
||||
this.sendText();
|
||||
} else if (resolveCommandInput(this.text)) {
|
||||
// 명령 이름 또는 AutoCAD 별칭을 입력했다. 예: L, LINE, REC
|
||||
const command = resolveCommandInput(this.text);
|
||||
if (command) {
|
||||
runCommand(command);
|
||||
}
|
||||
this.text = '';
|
||||
} else {
|
||||
this.sendText();
|
||||
}
|
||||
}
|
||||
|
||||
private sendText() {
|
||||
console.log('TEXT_INPUT: ', { text: this.text, activeTool: getActiveToolActor() });
|
||||
getActiveToolActor()?.send({
|
||||
type: ActorEvent.TEXT_INPUT,
|
||||
value: this.text,
|
||||
} as TextInputEvent);
|
||||
/**
|
||||
* 친 글 제출(Enter · Space · 우클릭 · 명령행) — 해석은 `prompt-input.routeTyped` 한 곳.
|
||||
* @param _repeatIfIdle 옛 꼴 — 빈 입력 + 쉼이면 늘 마지막 명령 반복(우클릭 포함)
|
||||
*/
|
||||
public handleEnterKey(_repeatIfIdle = false) {
|
||||
const text = this.text;
|
||||
this.text = '';
|
||||
routeTyped(text);
|
||||
}
|
||||
|
||||
/** 거리의 방향 = 스냅 · 가이드 · 커서 */
|
||||
private distanceCursor() {
|
||||
return (
|
||||
getSnapPointOnAngleGuide()?.point ||
|
||||
getSnapPoint()?.point ||
|
||||
getScreenCanvasDrawController().getWorldMouseLocation()
|
||||
);
|
||||
}
|
||||
|
||||
private distanceEvent(value: number): PointTextEvent {
|
||||
return { type: ActorEvent.NUMBER_INPUT, value, worldMouseLocation: this.distanceCursor() };
|
||||
}
|
||||
|
||||
/** 친 글을 점 · 거리 사건으로 */
|
||||
private pointEventFromText(): PointTextEvent | null {
|
||||
return parsePointText(this.text, () => this.distanceCursor());
|
||||
}
|
||||
|
||||
/** 동적 입력(길이 · 각도 칸 · Tab) — 기준점이 있는 점 단계에서만 */
|
||||
private dynamicInputApplies(): boolean {
|
||||
return activeToolWantsPoint() && !!getAngleGuideOriginPoint();
|
||||
}
|
||||
|
||||
/** 점 하나를 도구에 보냄 — 각도 고정 · 한 번 스냅은 이 한 점으로 끝 */
|
||||
/** 점 하나를 도구에 보냄(덧물음 먼저) — 각도 고정 · 한 번 스냅은 이 한 점으로 끝 */
|
||||
private sendPointEvent(event: PointTextEvent | MouseClickEvent) {
|
||||
getActiveToolActor()?.send(event);
|
||||
releaseOnePoint();
|
||||
setSnapOverride(null);
|
||||
sendPoint(event);
|
||||
}
|
||||
|
||||
public handleUndo(evt: KeyboardEvent) {
|
||||
evt.preventDefault();
|
||||
undo();
|
||||
setGhostHelperEntities([]);
|
||||
setSelectedEntityIds([]);
|
||||
getActiveToolActor()?.send({
|
||||
type: ActorEvent.ESC,
|
||||
});
|
||||
undoCommand();
|
||||
}
|
||||
|
||||
public handleRedo(evt: KeyboardEvent) {
|
||||
evt.preventDefault();
|
||||
redo();
|
||||
setGhostHelperEntities([]);
|
||||
setSelectedEntityIds([]);
|
||||
getActiveToolActor()?.send({
|
||||
type: ActorEvent.ESC,
|
||||
});
|
||||
redoCommand();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,9 +2,31 @@
|
||||
* Ctrl 단축키 판정 — 공용 `A00_Common/key_shortcut.ts`(214-2)를 그대로 내보냄.
|
||||
* 한글 자판은 `key` 가 「ㅋ」·「ㅛ」로 와 글자로는 안 맞아 자판 자리(`code`)로 본다.
|
||||
*/
|
||||
import { keyLetter } from '../../../../A00_Common/key_shortcut';
|
||||
|
||||
export {
|
||||
ctrlShortcut,
|
||||
isCtrlKey,
|
||||
keyLetter,
|
||||
type CtrlShortcut,
|
||||
} from '../../../../A00_Common/key_shortcut';
|
||||
|
||||
/** CAD 만의 Ctrl 단축키 → 명령 이름(오토캐드) — Ctrl+V 는 브라우저 `paste` 사건 몫 */
|
||||
export function cadCtrlCommand(
|
||||
evt: Pick<KeyboardEvent, 'code' | 'ctrlKey' | 'altKey' | 'shiftKey'> &
|
||||
Partial<Pick<KeyboardEvent, 'key' | 'metaKey'>>
|
||||
): string | null {
|
||||
if (!(evt.ctrlKey || evt.metaKey) || evt.altKey) return null;
|
||||
switch (keyLetter(evt)) {
|
||||
case 'c':
|
||||
return evt.shiftKey ? 'COPYBASE' : 'COPYCLIP';
|
||||
case 'x':
|
||||
return evt.shiftKey ? null : 'CUTCLIP';
|
||||
case 'v':
|
||||
return evt.shiftKey ? 'PASTEBLOCK' : null;
|
||||
case '1':
|
||||
return evt.shiftKey ? null : 'PROPERTIES';
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,424 @@
|
||||
/**
|
||||
* 친 글 사다리 한 곳(337 설계 2-5 ~ 2-11) — 키보드 · 명령행 · 손가락 단추가 친 글을 지금 물음 꼴로 풂.
|
||||
* 덧물음 → 빈 Enter → 글 물음 → 투명 `'` → 쉼(명령 이름) → 점 · 옵션 · 숫자 → 거절.
|
||||
* 점 · 옵션 묻는 중 명령 이름은 거절(오토캐드) · 앞글자 실행 없음 · 대소문자 무관(맞추기는 `prompt.ts` 한 곳).
|
||||
* 명령 끊기 · 되돌림도 여기 — 끊고(ESC) 뒤 실행(오토캐드 리본 `^C^C`).
|
||||
*/
|
||||
import { Point } from '@flatten-js/core';
|
||||
import { Actor } from 'xstate';
|
||||
import {
|
||||
type PromptMeta,
|
||||
type PromptSpec,
|
||||
defaultValue,
|
||||
matchOption,
|
||||
normalizeKeyword,
|
||||
renderPrompt,
|
||||
resolvePrompt,
|
||||
lastPoint,
|
||||
} from '../commands/prompt';
|
||||
import { resolveCommandInput } from '../commands/registry';
|
||||
import { logLine, repeatLastCommand, runCommand } from '../commands/run-command';
|
||||
import {
|
||||
getActiveToolActor,
|
||||
getAngleGuideOriginPoint,
|
||||
getScreenCanvasDrawController,
|
||||
getSnapPoint,
|
||||
getSnapPointOnAngleGuide,
|
||||
notifyWindow,
|
||||
redo,
|
||||
setActiveToolActor,
|
||||
setAngleGuideOriginPoint,
|
||||
setGhostHelperEntities,
|
||||
setSelectedEntityIds,
|
||||
setShouldDrawHelpers,
|
||||
undo,
|
||||
} from '../state';
|
||||
import { HtmlEvent } from '../App.types';
|
||||
import { Tool } from '../tools';
|
||||
import { SelectState, selectToolStateMachine } from '../tools/select-tool';
|
||||
import {
|
||||
ActorEvent,
|
||||
type MouseClickEvent,
|
||||
type StepKind,
|
||||
type TextInputEvent,
|
||||
} from '../tools/tool.types';
|
||||
import { getDynamicField, getLockedLength, releaseOnePoint, setAngleLock } from './input-angle-lock';
|
||||
import {
|
||||
ANGLE_LOCK_REGEXP,
|
||||
NUMBER_REGEXP,
|
||||
parsePointText,
|
||||
type PointTextEvent,
|
||||
} from './input-patterns';
|
||||
import { isSnapName, setSnapOverride } from './input-snap-override';
|
||||
|
||||
// ---- 덧물음(설계 2-11) ----
|
||||
|
||||
export interface SubPrompt {
|
||||
prompt: PromptSpec;
|
||||
kind: StepKind;
|
||||
onPoint?(p: Point): SubResult;
|
||||
onNumber?(n: number): SubResult;
|
||||
onOption?(key: string): SubResult;
|
||||
onEnter?(): SubResult;
|
||||
}
|
||||
/** `{point}` = 닫고 그 점을 원래 물음에 ABSOLUTE_POINT_INPUT 로 */
|
||||
export type SubResult = 'stay' | 'done' | { point: Point };
|
||||
|
||||
const subs: SubPrompt[] = [];
|
||||
export const pushSubPrompt = (sub: SubPrompt): void => {
|
||||
subs.push(sub);
|
||||
notifyWindow(HtmlEvent.UPDATE_STATE);
|
||||
};
|
||||
export const subPrompt = (): SubPrompt | null => subs[subs.length - 1] ?? null;
|
||||
/** 덧물음 모두 닫음(명령 끝 · Esc) */
|
||||
export const closeSubPrompt = (): void => {
|
||||
subs.length = 0;
|
||||
};
|
||||
|
||||
function settle(result: SubResult | undefined) {
|
||||
if (!result || result === 'stay') return;
|
||||
subs.pop();
|
||||
if (result !== 'done') sendPoint({ type: ActorEvent.ABSOLUTE_POINT_INPUT, value: result.point });
|
||||
}
|
||||
|
||||
// ---- 알림 줄(설계 2-10) ----
|
||||
|
||||
const NOTICE_MS = 3000;
|
||||
let noticeText: string | null = null;
|
||||
let noticeAt = 0;
|
||||
|
||||
/** 커서 곁 둘째 줄(3 초 또는 다음 키까지) + 기록 줄 · null = 지움 */
|
||||
export function notice(text: string | null): void {
|
||||
noticeText = text;
|
||||
noticeAt = Date.now();
|
||||
if (text) logLine(text);
|
||||
}
|
||||
export const currentNotice = (): string | null =>
|
||||
noticeText && Date.now() - noticeAt < NOTICE_MS ? noticeText : null;
|
||||
|
||||
// ---- 지금 물음 ----
|
||||
|
||||
const activeSnapshot = () => getActiveToolActor()?.getSnapshot();
|
||||
|
||||
/** 평평한 상태 설정 — 겹 상태는 읽지 않음 */
|
||||
const flatConfig = () => {
|
||||
const snapshot = activeSnapshot();
|
||||
return typeof snapshot?.value === 'string'
|
||||
? snapshot?.machine?.states?.[snapshot.value]?.config
|
||||
: undefined;
|
||||
};
|
||||
|
||||
const activeMeta = (): PromptMeta | undefined => {
|
||||
const metas = Object.values((activeSnapshot()?.getMeta?.() ?? {}) as Record<string, PromptMeta>);
|
||||
return metas.find((meta) => meta && (meta.kind || meta.prompt || meta.instructions));
|
||||
};
|
||||
|
||||
/** 쉼 = 맨 위 도구가 선택 도구이고 첫 점 대기(상자 그리는 중은 아님) */
|
||||
export function isIdle(): boolean {
|
||||
const snapshot = activeSnapshot();
|
||||
if (!snapshot || snapshot.status !== 'active') return !snapshot;
|
||||
return (
|
||||
snapshot.context?.type === Tool.SELECT &&
|
||||
snapshot.value === SelectState.WAITING_FOR_FIRST_SELECT_POINT
|
||||
);
|
||||
}
|
||||
|
||||
/** 물음 종류 — meta.kind · 없으면 `on` 으로 짐작 */
|
||||
function toolKind(): StepKind | null {
|
||||
const meta = activeMeta();
|
||||
if (meta?.kind) return meta.kind;
|
||||
const on = (flatConfig()?.on ?? {}) as Record<string, unknown>;
|
||||
if (on.ABSOLUTE_POINT_INPUT) return 'point';
|
||||
if (on.NUMBER_INPUT) return 'number';
|
||||
if (on.MOUSE_CLICK) return 'entity';
|
||||
if (Object.keys(on).some((key) => key.startsWith('OPTION.'))) return 'keyword';
|
||||
return null;
|
||||
}
|
||||
|
||||
export interface CurrentPrompt {
|
||||
kind: StepKind | 'idle' | null;
|
||||
spec: PromptSpec | null;
|
||||
/** 그린 한 줄(새 꼴) 또는 옛 instructions */
|
||||
line: string | null;
|
||||
/** 옛 꼴 — 점 물음이 받는 옵션 글자 */
|
||||
legacyOptions: string[];
|
||||
}
|
||||
|
||||
export function currentPrompt(): CurrentPrompt {
|
||||
const sub = subPrompt();
|
||||
if (sub) {
|
||||
return { kind: sub.kind, spec: sub.prompt, line: renderPrompt(sub.prompt, sub.kind), legacyOptions: [] };
|
||||
}
|
||||
const meta = activeMeta();
|
||||
const spec = resolvePrompt(meta?.prompt, activeSnapshot()?.context);
|
||||
const kind = isIdle() ? 'idle' : toolKind();
|
||||
return {
|
||||
kind,
|
||||
spec,
|
||||
line: spec ? renderPrompt(spec, meta?.kind) : (meta?.instructions ?? null),
|
||||
legacyOptions: meta?.options ?? [],
|
||||
};
|
||||
}
|
||||
|
||||
// ---- 보내기 ----
|
||||
|
||||
const send = (event: { type: string; [key: string]: unknown }) =>
|
||||
getActiveToolActor()?.send(event as unknown as TextInputEvent);
|
||||
|
||||
/** 거리의 방향 = 스냅 · 가이드 · 커서 */
|
||||
export const distanceCursor = (): Point =>
|
||||
getSnapPointOnAngleGuide()?.point ||
|
||||
getSnapPoint()?.point ||
|
||||
getScreenCanvasDrawController().getWorldMouseLocation();
|
||||
|
||||
export const distanceEvent = (value: number): PointTextEvent => ({
|
||||
type: ActorEvent.NUMBER_INPUT,
|
||||
value,
|
||||
worldMouseLocation: distanceCursor(),
|
||||
});
|
||||
|
||||
/** 동적 입력(길이 · 각도 칸 · Tab) — 기준점이 있는 점 물음에서만 */
|
||||
export const dynamicInputApplies = (): boolean =>
|
||||
currentPrompt().kind === 'point' && !!getAngleGuideOriginPoint();
|
||||
|
||||
/** 점 하나 — 덧물음 먼저(마우스 누름도) · 각도 고정 · 한 번 스냅은 이 한 점으로 끝 */
|
||||
export function sendPoint(event: PointTextEvent | MouseClickEvent): void {
|
||||
const sub = subPrompt();
|
||||
if (sub) {
|
||||
if (event.type === ActorEvent.NUMBER_INPUT) {
|
||||
if (sub.onNumber) settle(sub.onNumber(event.value));
|
||||
else reject(currentPrompt());
|
||||
} else if (event.type === ActorEvent.MOUSE_CLICK) {
|
||||
settle(sub.onPoint?.(event.worldMouseLocation));
|
||||
} else if (event.type === ActorEvent.RELATIVE_POINT_INPUT) {
|
||||
const base = getAngleGuideOriginPoint() ?? lastPoint();
|
||||
if (base) settle(sub.onPoint?.(new Point(base.x + event.value.x, base.y + event.value.y)));
|
||||
else reject(currentPrompt());
|
||||
} else {
|
||||
settle(sub.onPoint?.(event.value));
|
||||
}
|
||||
} else {
|
||||
getActiveToolActor()?.send(event);
|
||||
}
|
||||
releaseOnePoint();
|
||||
setSnapOverride(null);
|
||||
}
|
||||
|
||||
function sendOption(key: string) {
|
||||
const sub = subPrompt();
|
||||
if (sub) settle(sub.onOption?.(key));
|
||||
else send({ type: `OPTION.${key}` });
|
||||
}
|
||||
|
||||
function sendEnter() {
|
||||
const sub = subPrompt();
|
||||
if (sub) settle(sub.onEnter?.());
|
||||
else send({ type: ActorEvent.ENTER });
|
||||
}
|
||||
|
||||
// ---- 거절(설계 2-10) ----
|
||||
|
||||
const REJECT: Record<string, string> = {
|
||||
point: '점 또는 옵션 키워드를 요구함.',
|
||||
pointNumber: '숫자 거리, 두 번째 점 또는 옵션 키워드를 요구함.',
|
||||
number: '숫자 값을 요구함.',
|
||||
keyword: '옵션 키워드가 잘못됨.',
|
||||
selection: '*선택이 잘못됨*',
|
||||
};
|
||||
|
||||
function reject(prompt: CurrentPrompt, text?: string) {
|
||||
const kind = prompt.kind;
|
||||
const fallback =
|
||||
kind === 'point'
|
||||
? prompt.spec?.number
|
||||
? REJECT.pointNumber
|
||||
: REJECT.point
|
||||
: kind === 'number'
|
||||
? REJECT.number
|
||||
: kind === 'selection' || kind === 'entity'
|
||||
? REJECT.selection
|
||||
: REJECT.keyword;
|
||||
notice(text ?? prompt.spec?.reject ?? fallback);
|
||||
}
|
||||
|
||||
/** 숫자 검사 — 맞으면 null · 틀리면 거절 글 */
|
||||
function numberProblem(value: number, rule: PromptSpec['number']): string | null {
|
||||
if (!rule) return null;
|
||||
if (rule.int && !Number.isInteger(value)) return '정수 값을 요구함.';
|
||||
const low = rule.min ?? Number.NEGATIVE_INFINITY;
|
||||
const high = rule.max ?? Number.POSITIVE_INFINITY;
|
||||
if (value < low || value > high) {
|
||||
return rule.min !== undefined && rule.max !== undefined
|
||||
? `${rule.min}에서 ${rule.max} 사이의 ${rule.int ? '정수' : '값'}를 요구함.`
|
||||
: '값이 범위를 벗어남.';
|
||||
}
|
||||
if (rule.positive && value <= 0) return '값은 양수이고 0이 아니어야 함.';
|
||||
return null;
|
||||
}
|
||||
|
||||
// ---- 사다리(설계 2-5) ----
|
||||
|
||||
/** 옛 꼴 통과 — 새 물음이 없는 상태가 TEXT_INPUT 을 받고 숫자는 안 받음 */
|
||||
const legacyTakesText = (): boolean => {
|
||||
const on = (flatConfig()?.on ?? {}) as Record<string, unknown>;
|
||||
return !!on.TEXT_INPUT && !on.NUMBER_INPUT;
|
||||
};
|
||||
|
||||
/** ② 빈 Enter · Space · 우클릭 */
|
||||
function routeEmpty(prompt: CurrentPrompt) {
|
||||
const lockedLength = getLockedLength();
|
||||
if (lockedLength !== null && dynamicInputApplies()) {
|
||||
sendPoint(distanceEvent(lockedLength)); // 길이 잠근 채 Enter — 그 길이
|
||||
return;
|
||||
}
|
||||
const fallback = prompt.spec?.default;
|
||||
if (fallback?.kind === 'value') {
|
||||
const value = defaultValue(fallback);
|
||||
if (typeof value === 'number') {
|
||||
if (subPrompt()?.onNumber) settle(subPrompt()?.onNumber?.(value));
|
||||
else sendPoint(distanceEvent(value));
|
||||
return;
|
||||
}
|
||||
if (value) {
|
||||
sendPoint({ type: ActorEvent.ABSOLUTE_POINT_INPUT, value: new Point(value.x, value.y) });
|
||||
return;
|
||||
}
|
||||
} else if (fallback?.kind === 'option') {
|
||||
sendOption(fallback.key);
|
||||
return;
|
||||
}
|
||||
if (prompt.kind === 'idle' && repeatLastCommand()) return;
|
||||
sendEnter();
|
||||
}
|
||||
|
||||
/** ⑧ `'이름` — 즉시형은 지금 도구 둔 채 · 도구형은 거절 */
|
||||
function routeTransparent(name: string) {
|
||||
const command = resolveCommandInput(name);
|
||||
if (!command) {
|
||||
notice(`알 수 없는 명령 「${normalizeKeyword(name)}」.`);
|
||||
} else if (command.machine) {
|
||||
notice(`「${command.id}」 명령은 투명하게 쓸 수 없음.`);
|
||||
} else {
|
||||
runCommand(command);
|
||||
}
|
||||
}
|
||||
|
||||
/** ④ 점 물음 보조 — 동적 각도 칸 · `<각도` · 한 번 스냅. 먹었으면 true */
|
||||
function routePointAid(text: string): boolean {
|
||||
if (getDynamicField() === 'angle' && NUMBER_REGEXP.test(text) && dynamicInputApplies()) {
|
||||
setAngleLock(Number.parseFloat(text));
|
||||
const lockedLength = getLockedLength();
|
||||
if (lockedLength !== null) sendPoint(distanceEvent(lockedLength));
|
||||
return true;
|
||||
}
|
||||
const lock = ANGLE_LOCK_REGEXP.exec(text);
|
||||
if (lock) {
|
||||
setAngleLock(Number.parseFloat(lock[1]));
|
||||
return true;
|
||||
}
|
||||
const upper = normalizeKeyword(text);
|
||||
if (isSnapName(upper)) {
|
||||
setSnapOverride(upper);
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/** ④ 좌표 · 거리 */
|
||||
function routePointText(text: string, prompt: CurrentPrompt) {
|
||||
const event = parsePointText(text, distanceCursor);
|
||||
if (!event) return reject(prompt);
|
||||
const hasBase = !!getAngleGuideOriginPoint() || !!subPrompt();
|
||||
if (event.type !== ActorEvent.ABSOLUTE_POINT_INPUT && !hasBase) return reject(prompt);
|
||||
if (event.type === ActorEvent.NUMBER_INPUT) {
|
||||
const problem = numberProblem(event.value, prompt.spec?.number);
|
||||
if (problem) return reject(prompt, problem);
|
||||
}
|
||||
sendPoint(event);
|
||||
}
|
||||
|
||||
/** ⑥ 숫자 물음 — 글 전체가 수일 때만(`3.2*1.5` · `2m` 거절) */
|
||||
function routeNumber(text: string, prompt: CurrentPrompt) {
|
||||
if (!NUMBER_REGEXP.test(text)) return reject(prompt);
|
||||
const value = Number.parseFloat(text);
|
||||
const problem = numberProblem(value, prompt.spec?.number);
|
||||
if (problem) return reject(prompt, problem);
|
||||
const sub = subPrompt();
|
||||
if (sub) settle(sub.onNumber?.(value));
|
||||
else send({ type: ActorEvent.NUMBER_INPUT, value, worldMouseLocation: distanceCursor() });
|
||||
}
|
||||
|
||||
/** 친 글 하나를 지금 물음으로 — 늘 다 먹음(거절이면 알림 줄) */
|
||||
export function routeTyped(raw: string): void {
|
||||
const prompt = currentPrompt();
|
||||
const text = raw.trim();
|
||||
if (!text) return routeEmpty(prompt);
|
||||
if (prompt.kind === 'text') {
|
||||
send({ type: ActorEvent.TEXT_INPUT, value: raw }); // ③ 원문 그대로
|
||||
return;
|
||||
}
|
||||
if (text.startsWith("'")) return routeTransparent(text.slice(1));
|
||||
if (prompt.kind === 'idle') {
|
||||
// ⑨ 이름 > 내 별칭 > 기본 별칭 딱 맞음만
|
||||
const command = resolveCommandInput(text);
|
||||
if (command) runCommand(command);
|
||||
else notice(`알 수 없는 명령 「${normalizeKeyword(text)}」.`);
|
||||
return;
|
||||
}
|
||||
if (prompt.kind === 'point' && routePointAid(text)) return;
|
||||
// ⑤ 옵션(숨김 포함) · 없는 옵션 안내
|
||||
const hit = matchOption(text, prompt.spec);
|
||||
if (hit && 'option' in hit) return sendOption(hit.option.key);
|
||||
if (hit && 'absent' in hit) {
|
||||
notice(`「${hit.absent.label}(${hit.absent.kw})」 옵션은 이 CAD 에 없음.`);
|
||||
return;
|
||||
}
|
||||
const upper = normalizeKeyword(text);
|
||||
if (!prompt.spec && prompt.legacyOptions.includes(upper)) {
|
||||
send({ type: ActorEvent.TEXT_INPUT, value: upper }); // 옛 꼴 통과
|
||||
return;
|
||||
}
|
||||
if (prompt.kind === 'point') return routePointText(text, prompt);
|
||||
if (prompt.kind === 'number') return routeNumber(text, prompt);
|
||||
if (!prompt.spec && legacyTakesText()) {
|
||||
send({ type: ActorEvent.TEXT_INPUT, value: upper }); // 옛 꼴 통과
|
||||
return;
|
||||
}
|
||||
reject(prompt); // ⑦ ⑩
|
||||
}
|
||||
|
||||
// ---- 명령 끊기 · 되돌림(설계 2-8) ----
|
||||
|
||||
/** 쉬는 상태로 — 옛 도구가 ESC 뒤 처음 물음으로 돌아가도 한 번에 끝 */
|
||||
function startIdle() {
|
||||
setGhostHelperEntities([]);
|
||||
setAngleGuideOriginPoint(null);
|
||||
setShouldDrawHelpers(false);
|
||||
setActiveToolActor(new Actor(selectToolStateMachine));
|
||||
}
|
||||
|
||||
/** 진행 중 명령 끊기 — 쉼이면 아무 일 없음 */
|
||||
export function cancelActiveCommand(): void {
|
||||
closeSubPrompt();
|
||||
releaseOnePoint();
|
||||
setSnapOverride(null);
|
||||
if (isIdle()) return;
|
||||
const actor = getActiveToolActor();
|
||||
actor?.send({ type: ActorEvent.ESC });
|
||||
if (getActiveToolActor() === actor && !isIdle()) startIdle();
|
||||
}
|
||||
|
||||
/** 되돌림 — 끊고 뒤 · 고름 풂(Ctrl+Z · ↶ · U) */
|
||||
export function undoCommand(): void {
|
||||
cancelActiveCommand();
|
||||
undo();
|
||||
setGhostHelperEntities([]);
|
||||
setSelectedEntityIds([]);
|
||||
}
|
||||
|
||||
export function redoCommand(): void {
|
||||
cancelActiveCommand();
|
||||
redo();
|
||||
setGhostHelperEntities([]);
|
||||
setSelectedEntityIds([]);
|
||||
}
|
||||
@@ -8,13 +8,13 @@ import {
|
||||
HtmlEvent,
|
||||
type Layer,
|
||||
type SnapPoint,
|
||||
type StateMetaData,
|
||||
} from './App.types';
|
||||
import type { ScreenCanvasDrawController } from './drawControllers/screenCanvas.drawController';
|
||||
import type { Entity } from './entities/Entity';
|
||||
import { bumpSceneVersion } from './helpers/scene-version';
|
||||
import { StateVariable, type UndoState, createStack } from './helpers/undo-stack';
|
||||
import type { InputController } from './inputController/input-controller.ts'; // state variables
|
||||
import { type PromptMeta, renderPrompt, rememberPoint, resolvePrompt } from './commands/prompt';
|
||||
|
||||
// state variables
|
||||
/**
|
||||
@@ -285,6 +285,11 @@ export const isOriginMarker = (): boolean => originMarker;
|
||||
export const setCanvas = (newCanvas: HTMLCanvasElement) => {
|
||||
canvas = newCanvas;
|
||||
};
|
||||
let commandEndHook: (() => void) | null = null;
|
||||
/** 도구 머신이 final 에 들 때 부를 것(한 곳) */
|
||||
export const setCommandEndHook = (hook: () => void) => {
|
||||
commandEndHook = hook;
|
||||
};
|
||||
export const setActiveToolActor = (
|
||||
// biome-ignore lint/suspicious/noExplicitAny: <explanation>
|
||||
newToolActor: Actor<any>,
|
||||
@@ -297,8 +302,14 @@ export const setActiveToolActor = (
|
||||
activeToolActor.subscribe({
|
||||
// biome-ignore lint/suspicious/noExplicitAny: <explanation>
|
||||
next: (state: MachineSnapshot<any, any, any, any, any, any, any, any>) => {
|
||||
const stateInstructions = Object.values(state?.getMeta() as Record<string, StateMetaData>)[0]
|
||||
?.instructions;
|
||||
// 명령 끝(final) → 쉬는 상태로(`tools/factories/end-command.ts` 가 갈고리를 붙임)
|
||||
if (state?.status === 'done') {
|
||||
if (activeToolActor === newToolActor) commandEndHook?.();
|
||||
return;
|
||||
}
|
||||
const meta = Object.values(state?.getMeta() as Record<string, PromptMeta>)[0];
|
||||
const prompt = resolvePrompt(meta?.prompt, state.context);
|
||||
const stateInstructions = prompt ? renderPrompt(prompt, meta?.kind) : meta?.instructions;
|
||||
|
||||
if (getLastStateInstructions() === stateInstructions) {
|
||||
return;
|
||||
@@ -418,6 +429,7 @@ export const setSnapPointOnAngleGuide = (newSnapPointOnAngleGuide: SnapPoint | n
|
||||
};
|
||||
export const setAngleGuideOriginPoint = (newAngleGuideOriginPoint: Point | null) => {
|
||||
angleGuideOriginPoint = newAngleGuideOriginPoint;
|
||||
if (newAngleGuideOriginPoint) rememberPoint(newAngleGuideOriginPoint); // LASTPOINT
|
||||
};
|
||||
export const setHoveredSnapPoints = (newHoveredSnapPoints: HoverPoint[]) => {
|
||||
hoveredSnapPoints = newHoveredSnapPoints;
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
/**
|
||||
* 명령 끝(설계 2-8) — 도구 머신이 final(`END`)에 들면 그림자 · 기준점 · 도움선 · 덧물음을 끄고
|
||||
* 같은 틱에 새 선택 도구(쉬는 상태)로 바꿈. 선택 도구 자체의 끝(Enter)도 이 길.
|
||||
* 불러오기만 하면 갈고리가 붙음(`commands/registry.ts` 가 불러옴). 고른 것 풂은 도구 몫.
|
||||
*/
|
||||
import { Actor } from 'xstate';
|
||||
import { closeSubPrompt } from '../../inputController/prompt-input';
|
||||
import {
|
||||
setActiveToolActor,
|
||||
setAngleGuideOriginPoint,
|
||||
setCommandEndHook,
|
||||
setGhostHelperEntities,
|
||||
setShouldDrawHelpers,
|
||||
} from '../../state';
|
||||
import { selectToolStateMachine } from '../select-tool';
|
||||
|
||||
setCommandEndHook(() => {
|
||||
setGhostHelperEntities([]);
|
||||
setAngleGuideOriginPoint(null);
|
||||
setShouldDrawHelpers(false);
|
||||
closeSubPrompt();
|
||||
setActiveToolActor(new Actor(selectToolStateMachine));
|
||||
});
|
||||
@@ -1,363 +1,551 @@
|
||||
/**
|
||||
* 시퀀스 도구 공장 — AutoCAD 명령의 공통 골격(단계별 입력 → 미리보기 → 확정)을
|
||||
* 선언만으로 만든다.
|
||||
* 시퀀스 도구 공장 v2(337 설계 2-13) — AutoCAD 명령의 공통 골격(물음 차례 → 미리보기 → 확정)을
|
||||
* 선언만으로 만든다. `steps`(점·숫자·문자·객체·고르기·키워드)를 나열하고 `commit` 만 채우면 머신이 나온다.
|
||||
*
|
||||
* 명령마다 xstate 머신을 손으로 쓰면 2점 그리기에도 170줄이 든다. 여기서는
|
||||
* `steps`(점·숫자·문자·객체 선택)를 나열하고 `commit`만 채우면 같은 머신이 나온다.
|
||||
* 흐름 = INIT → ROUTE(갈 단계 고름 · `when` 건너뜀) → STEP_i(값 · 옵션) → … → COMMIT → END(쉬는 상태).
|
||||
* 확정 뒤 기본은 끝 — 오토캐드 되풀이 명령만 `loopFrom`. Esc 는 늘 끝(`escCommits` 면 모은 값으로 확정 뒤).
|
||||
* 옵션 = `OPTION.<key>` 사건(물음 `prompt.options`) · 처리 = 단계 `options`.
|
||||
*/
|
||||
import type { Point } from '@flatten-js/core';
|
||||
import { Actor, assign, createMachine, sendTo } from 'xstate';
|
||||
import type { Entity } from '../../entities/Entity';
|
||||
import { findClosestEntity } from '../../helpers/find-closest-entity';
|
||||
import { getPointFromEvent } from '../../helpers/get-point-from-event';
|
||||
import { pickRadius } from '../../helpers/pick-radius';
|
||||
import { queryEntitiesNearPoint } from '../../helpers/spatial-index';
|
||||
import { Point } from "@flatten-js/core";
|
||||
import { assign, createMachine, sendTo } from "xstate";
|
||||
import {
|
||||
getLayerById,
|
||||
getScreenCanvasDrawController,
|
||||
getSelectedEntities,
|
||||
setActiveToolActor,
|
||||
setAngleGuideOriginPoint,
|
||||
setGhostHelperEntities,
|
||||
setShouldDrawHelpers,
|
||||
} from '../../state';
|
||||
import type { Tool } from '../../tools';
|
||||
import { selectToolStateMachine } from '../select-tool';
|
||||
import type {
|
||||
DrawEvent,
|
||||
MouseClickEvent,
|
||||
NumberInputEvent,
|
||||
PointInputEvent,
|
||||
StateEvent,
|
||||
TextInputEvent,
|
||||
ToolContext,
|
||||
} from '../tool.types';
|
||||
type PromptSpec,
|
||||
SELECT_OBJECTS,
|
||||
remember,
|
||||
rememberSelection,
|
||||
} from "../../commands/prompt";
|
||||
import type { Entity } from "../../entities/Entity";
|
||||
import { findClosestEntity } from "../../helpers/find-closest-entity";
|
||||
import { getPointFromEvent } from "../../helpers/get-point-from-event";
|
||||
import { pickRadius } from "../../helpers/pick-radius";
|
||||
import { queryEntitiesNearPoint } from "../../helpers/spatial-index";
|
||||
import {
|
||||
getLayerById,
|
||||
getScreenCanvasDrawController,
|
||||
getSelectedEntities,
|
||||
setAngleGuideOriginPoint,
|
||||
setGhostHelperEntities,
|
||||
setSelectedEntityIds,
|
||||
setShouldDrawHelpers,
|
||||
} from "../../state";
|
||||
import type { Tool } from "../../tools";
|
||||
import { selectToolStateMachine } from "../select-tool";
|
||||
import {
|
||||
type DrawEvent,
|
||||
END_STATE,
|
||||
type MouseClickEvent,
|
||||
type NumberInputEvent,
|
||||
type PointInputEvent,
|
||||
type StateEvent,
|
||||
type StepKind,
|
||||
type TextInputEvent,
|
||||
type ToolContext,
|
||||
optionKey,
|
||||
} from "../tool.types";
|
||||
|
||||
export type SequenceValue = Point | number | string | Entity | Entity[];
|
||||
|
||||
export type SequenceStepKind = 'point' | 'number' | 'text' | 'entity' | 'selection';
|
||||
export type SequenceStepKind = StepKind;
|
||||
|
||||
/** 단계 옵션 처리 — `OPTION.<key>` 를 받았을 때 */
|
||||
export type StepOption =
|
||||
/** `input.mode` 세우고 다음(또는 next) 단계 */
|
||||
| { mode: string; next?: string | number }
|
||||
/** 설정만 바꾸고 같은 물음 */
|
||||
| { run: (input: SequenceInput) => void }
|
||||
/** 마지막 값 하나 뺌 · 그 값의 단계로 */
|
||||
| "undo"
|
||||
/** `input.closed` 세우고 확정 */
|
||||
| "close"
|
||||
/** 확정 없이 끝 */
|
||||
| "end";
|
||||
|
||||
export interface SequenceStep {
|
||||
kind: SequenceStepKind;
|
||||
/** 명령행·커서 옆에 뜨는 안내문 */
|
||||
instructions: string;
|
||||
/** number·text 단계에서 ENTER만 눌렀을 때 채택할 값 */
|
||||
defaultValue?: number | string;
|
||||
kind: SequenceStepKind;
|
||||
/** 값 이름 — `input.get(id)` · `next` · `loopFrom` 이 가리킴 */
|
||||
id?: string;
|
||||
/** 옛 꼴 안내문 — `prompt` 가 있으면 안 씀 */
|
||||
instructions?: string;
|
||||
/** 새 꼴 물음(함수면 지금 입력으로 그림) */
|
||||
prompt?: PromptSpec | ((input: SequenceInput) => PromptSpec);
|
||||
/** 옛 꼴 — number·text 단계에서 ENTER만 눌렀을 때 채택할 값 */
|
||||
defaultValue?: number | string;
|
||||
/** 거짓이면 이 단계를 건너뜀 */
|
||||
when?: (input: SequenceInput) => boolean;
|
||||
/** 값을 받은 뒤 갈 단계(id · 번호) — 없으면 다음 칸 */
|
||||
next?: string | number;
|
||||
/** `OPTION.<key>` 처리 — keyword 단계는 없어도 고른 key 가 값 */
|
||||
options?: Record<string, StepOption>;
|
||||
}
|
||||
|
||||
/** commit·preview에 넘어가는 입력 묶음. 단계 순서 그대로 인덱스로 읽는다. */
|
||||
export interface SequenceInput {
|
||||
values: SequenceValue[];
|
||||
/** 커서(미리보기) 또는 마지막 확정 점 */
|
||||
cursor: Point;
|
||||
point(index: number): Point;
|
||||
/** 모든 점 값 (가변 점 명령에서 사용) */
|
||||
points(): Point[];
|
||||
number(index: number): number;
|
||||
text(index: number): string;
|
||||
entity(index: number): Entity;
|
||||
entities(index: number): Entity[];
|
||||
/** 객체 선택 단계에서 실제로 클릭한 위치 (어느 쪽을 집었는지 필요한 명령용) */
|
||||
pick(index: number): Point;
|
||||
values: SequenceValue[];
|
||||
/** 커서(미리보기) 또는 마지막 확정 점 */
|
||||
cursor: Point;
|
||||
point(index: number): Point;
|
||||
/** 모든 점 값 (가변 점 명령에서 사용) */
|
||||
points(): Point[];
|
||||
number(index: number): number;
|
||||
text(index: number): string;
|
||||
entity(index: number): Entity;
|
||||
entities(index: number): Entity[];
|
||||
/** 객체 선택 단계에서 실제로 클릭한 위치 (어느 쪽을 집었는지 필요한 명령용) */
|
||||
pick(index: number): Point;
|
||||
/** id 단계의 마지막 값(없으면 undefined) */
|
||||
get<T = SequenceValue>(id: string): T;
|
||||
has(id: string): boolean;
|
||||
/** 옵션 `{ mode }` 가 세운 값 */
|
||||
mode: string | null;
|
||||
/** 닫기(`'close'`)로 확정했는가 */
|
||||
closed: boolean;
|
||||
}
|
||||
|
||||
export interface SequenceToolConfig {
|
||||
tool: Tool;
|
||||
steps: SequenceStep[];
|
||||
/** 마지막 점 단계를 ENTER 전까지 반복해 점을 모은다 (폴리선·스플라인) */
|
||||
repeatLastStep?: boolean;
|
||||
/** 커서를 따라 그려줄 임시 엔티티 */
|
||||
preview?: (input: SequenceInput) => Entity[];
|
||||
/** 확정 — 엔티티 추가·상태 변경을 직접 수행한다 */
|
||||
commit: (input: SequenceInput) => void;
|
||||
/**
|
||||
* 확정 뒤 같은 명령을 처음부터 다시 시작할지.
|
||||
* 기본값은 "점만 찍는 그리기 명령이면 계속, 숫자·문자·객체 선택이 섞이면 종료".
|
||||
* 종료하면 선택 도구로 돌아가 명령행 입력이 다음 명령으로 해석된다 (AutoCAD와 같다).
|
||||
*/
|
||||
restart?: boolean;
|
||||
/** 확정 뒤 마지막 점을 첫 값으로 이어받아 계속 그린다 (선) */
|
||||
chainFromLastPoint?: boolean;
|
||||
/** 점 입력 중 스냅·각도 가이드 사용 여부 (기본 true) */
|
||||
helpers?: boolean;
|
||||
/** ESC 로 확정 없이 그만둘 때 */
|
||||
onCancel?: () => void;
|
||||
tool: Tool;
|
||||
steps: SequenceStep[];
|
||||
/** 마지막 점 단계를 ENTER 전까지 반복해 점을 모은다 (폴리선·스플라인) */
|
||||
repeatLastStep?: boolean;
|
||||
/** 커서를 따라 그려줄 임시 엔티티 */
|
||||
preview?: (input: SequenceInput) => Entity[];
|
||||
/** 확정 — 엔티티 추가·상태 변경을 직접 수행한다 */
|
||||
commit: (input: SequenceInput) => void;
|
||||
/** 확정 뒤 그 단계(id · 번호)부터 다시 · 앞 값 둠 — 없으면 확정 뒤 끝 */
|
||||
loopFrom?: string | number;
|
||||
/** Esc 때 모은 값으로 확정 뒤 끝(PLINE) — 마지막 단계 값이 하나라도 있을 때 */
|
||||
escCommits?: boolean;
|
||||
/** 첫 단계가 고르기이고 고른 것이 있으면 그대로 받음(기본 참 · PICKFIRST) */
|
||||
pickfirst?: boolean;
|
||||
/** 옛 꼴 — true = `loopFrom: 0` · false = 끝 */
|
||||
restart?: boolean;
|
||||
/** 옛 꼴 — 확정 뒤 마지막 점을 첫 값으로 이어받아 계속 그린다 */
|
||||
chainFromLastPoint?: boolean;
|
||||
/** 점 입력 중 스냅·각도 가이드 사용 여부 (기본 true) */
|
||||
helpers?: boolean;
|
||||
/** ESC 로 확정 없이 그만둘 때 */
|
||||
onCancel?: () => void;
|
||||
}
|
||||
|
||||
export interface SequenceContext extends ToolContext {
|
||||
values: SequenceValue[];
|
||||
/** values와 같은 자리에 놓이는 클릭 위치 (객체를 집은 지점) */
|
||||
picks: (Point | null)[];
|
||||
values: SequenceValue[];
|
||||
/** values와 같은 자리에 놓이는 클릭 위치 (객체를 집은 지점) */
|
||||
picks: (Point | null)[];
|
||||
/** values와 같은 자리 — 그 값을 받은 단계 번호 */
|
||||
stepOf: number[];
|
||||
/** 갈 단계 · -1 = 끝 · 단계 수 = 확정 */
|
||||
at: number;
|
||||
mode: string | null;
|
||||
closed: boolean;
|
||||
/** 되풀이 횟수 */
|
||||
loops: number;
|
||||
}
|
||||
|
||||
type Ctx = { context: SequenceContext; event: StateEvent };
|
||||
|
||||
const STEP_STATE = (index: number) => `STEP_${index}`;
|
||||
const COMMIT_STATE = 'COMMIT';
|
||||
const INIT_STATE = 'INIT';
|
||||
|
||||
function makeInput(
|
||||
values: SequenceValue[],
|
||||
cursor: Point,
|
||||
picks: (Point | null)[] = []
|
||||
): SequenceInput {
|
||||
/** 단계 인덱스로 값을 꺼낸다. 종류가 다르면 어느 단계가 잘못됐는지 바로 알린다. */
|
||||
const expect = <T>(index: number, kind: string, ok: (value: SequenceValue) => boolean): T => {
|
||||
const value = values[index];
|
||||
if (!ok(value)) {
|
||||
throw new Error(`[sequence-tool] ${index}번 단계 값이 ${kind}이(가) 아닙니다`);
|
||||
}
|
||||
return value as T;
|
||||
};
|
||||
|
||||
return {
|
||||
values,
|
||||
cursor,
|
||||
pick: (index: number) => (picks[index] ?? cursor) as Point,
|
||||
point: (index: number) => expect<Point>(index, '점', isPointValue),
|
||||
points: () => values.filter((value) => isPointValue(value)) as Point[],
|
||||
number: (index: number) => expect<number>(index, '숫자', (v) => typeof v === 'number'),
|
||||
text: (index: number) => expect<string>(index, '문자', (v) => typeof v === 'string'),
|
||||
entity: (index: number) =>
|
||||
expect<Entity>(index, '객체', (v) => !!v && typeof (v as Entity).getType === 'function'),
|
||||
entities: (index: number) => expect<Entity[]>(index, '선택 목록', Array.isArray),
|
||||
};
|
||||
}
|
||||
const INIT_STATE = "INIT";
|
||||
const ROUTE_STATE = "ROUTE";
|
||||
const COMMIT_STATE = "COMMIT";
|
||||
const ORIGIN = new Point(0, 0);
|
||||
|
||||
/** flatten-js Point 판별 — instanceof는 번들 중복 시 실패할 수 있어 형태로 본다. */
|
||||
function isPointValue(value: SequenceValue): boolean {
|
||||
return (
|
||||
typeof value === 'object' &&
|
||||
value !== null &&
|
||||
typeof (value as Point).x === 'number' &&
|
||||
typeof (value as Point).y === 'number'
|
||||
);
|
||||
function isPointValue(value: SequenceValue | undefined): boolean {
|
||||
return (
|
||||
typeof value === "object" &&
|
||||
value !== null &&
|
||||
typeof (value as Point).x === "number" &&
|
||||
typeof (value as Point).y === "number"
|
||||
);
|
||||
}
|
||||
|
||||
/** 마지막으로 찍은 점 — 상대 좌표·거리 입력의 기준점이 된다. */
|
||||
function lastPoint(values: SequenceValue[]): Point | null {
|
||||
for (let index = values.length - 1; index >= 0; index--) {
|
||||
if (isPointValue(values[index])) return values[index] as Point;
|
||||
}
|
||||
return null;
|
||||
for (let index = values.length - 1; index >= 0; index--) {
|
||||
if (isPointValue(values[index])) return values[index] as Point;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function cursorPoint(event: StateEvent | undefined): Point {
|
||||
if (event && (event as DrawEvent).drawController) {
|
||||
return (event as DrawEvent).drawController.getWorldMouseLocation();
|
||||
}
|
||||
return getScreenCanvasDrawController().getWorldMouseLocation();
|
||||
if (event && (event as DrawEvent).drawController) {
|
||||
return (event as DrawEvent).drawController.getWorldMouseLocation();
|
||||
}
|
||||
return getScreenCanvasDrawController().getWorldMouseLocation();
|
||||
}
|
||||
|
||||
/** 클릭 지점에서 가장 가까운 엔티티 (선택 반경 안에 있을 때만) */
|
||||
function pickEntityAt(worldPoint: Point): Entity | null {
|
||||
const radius = pickRadius();
|
||||
// 잠금 도면층(도각 등)은 집지 않는다 — 수정 도구가 참조 선을 잡으면 안 된다.
|
||||
const candidates = queryEntitiesNearPoint(worldPoint.x, worldPoint.y, radius).filter(
|
||||
(entity) => !getLayerById(entity.layerId)?.isLocked
|
||||
);
|
||||
const { distance, entity } = findClosestEntity(worldPoint, candidates);
|
||||
return entity && distance <= radius ? entity : null;
|
||||
const radius = pickRadius();
|
||||
// 잠금 도면층(도각 등)은 집지 않는다 — 수정 도구가 참조 선을 잡으면 안 된다.
|
||||
const candidates = queryEntitiesNearPoint(worldPoint.x, worldPoint.y, radius).filter(
|
||||
(entity) => !getLayerById(entity.layerId)?.isLocked,
|
||||
);
|
||||
const { distance, entity } = findClosestEntity(worldPoint, candidates);
|
||||
return entity && distance <= radius ? entity : null;
|
||||
}
|
||||
|
||||
export function createSequenceTool(config: SequenceToolConfig) {
|
||||
const { steps, tool } = config;
|
||||
const useHelpers = config.helpers !== false;
|
||||
const lastIndex = steps.length - 1;
|
||||
const { steps, tool } = config;
|
||||
const useHelpers = config.helpers !== false;
|
||||
const lastIndex = steps.length - 1;
|
||||
|
||||
const nextTarget = (index: number): string => {
|
||||
if (config.repeatLastStep && index === lastIndex) return STEP_STATE(index);
|
||||
return index < lastIndex ? STEP_STATE(index + 1) : COMMIT_STATE;
|
||||
};
|
||||
const indexOf = (ref: string | number): number => {
|
||||
const index = typeof ref === "number" ? ref : steps.findIndex((step) => step.id === ref);
|
||||
if (index < 0 || index > lastIndex) {
|
||||
throw new Error(`[sequence-tool] ${tool}: 없는 단계 ${String(ref)}`);
|
||||
}
|
||||
return index;
|
||||
};
|
||||
const loopIndex =
|
||||
config.loopFrom !== undefined
|
||||
? indexOf(config.loopFrom)
|
||||
: config.restart === true
|
||||
? 0
|
||||
: config.chainFromLastPoint && config.restart !== false
|
||||
? 1
|
||||
: null;
|
||||
for (const step of steps) if (step.next !== undefined) indexOf(step.next);
|
||||
const isRepeat = (index: number) => !!config.repeatLastStep && index === lastIndex;
|
||||
|
||||
const resetTool = () => {
|
||||
setShouldDrawHelpers(useHelpers);
|
||||
setGhostHelperEntities([]);
|
||||
setAngleGuideOriginPoint(null);
|
||||
};
|
||||
/** context → 입력 묶음. 단계 인덱스로 값을 꺼낸다(종류가 다르면 어느 단계가 잘못됐는지 바로 알림) */
|
||||
const makeInput = (context: Partial<SequenceContext>, cursor: Point): SequenceInput => {
|
||||
const values = context.values ?? [];
|
||||
const picks = context.picks ?? [];
|
||||
const stepOf = context.stepOf ?? [];
|
||||
const expect = <T>(index: number, kind: string, ok: (value: SequenceValue) => boolean): T => {
|
||||
const value = values[index];
|
||||
if (!ok(value)) {
|
||||
throw new Error(`[sequence-tool] ${index}번 단계 값이 ${kind}이(가) 아닙니다`);
|
||||
}
|
||||
return value as T;
|
||||
};
|
||||
const slot = (id: string) => {
|
||||
for (let index = values.length - 1; index >= 0; index--) {
|
||||
if (steps[stepOf[index]]?.id === id) return index;
|
||||
}
|
||||
return -1;
|
||||
};
|
||||
return {
|
||||
values,
|
||||
cursor,
|
||||
pick: (index: number) => (picks[index] ?? cursor) as Point,
|
||||
point: (index: number) => expect<Point>(index, "점", isPointValue),
|
||||
points: () => values.filter((value) => isPointValue(value)) as Point[],
|
||||
number: (index: number) => expect<number>(index, "숫자", (v) => typeof v === "number"),
|
||||
text: (index: number) => expect<string>(index, "문자", (v) => typeof v === "string"),
|
||||
entity: (index: number) =>
|
||||
expect<Entity>(index, "객체", (v) => !!v && typeof (v as Entity).getType === "function"),
|
||||
entities: (index: number) => expect<Entity[]>(index, "선택 목록", Array.isArray),
|
||||
get: <T>(id: string) => values[slot(id)] as T,
|
||||
has: (id: string) => slot(id) >= 0,
|
||||
mode: context.mode ?? null,
|
||||
closed: !!context.closed,
|
||||
};
|
||||
};
|
||||
const inputOf = (context: Partial<SequenceContext>) =>
|
||||
makeInput(context, lastPoint(context.values ?? []) ?? ORIGIN);
|
||||
const promptOf = (index: number, context: Partial<SequenceContext>): PromptSpec | null => {
|
||||
const prompt = steps[index].prompt;
|
||||
return typeof prompt === "function" ? prompt(inputOf(context)) : (prompt ?? null);
|
||||
};
|
||||
|
||||
const drawPreview = ({ context, event }: { context: SequenceContext; event: StateEvent }) => {
|
||||
if (!config.preview) return;
|
||||
// 각도 고정(`<30`) 중이면 커서를 고정 방향 위로
|
||||
const cursor = getPointFromEvent(lastPoint(context.values), event as DrawEvent);
|
||||
const ghosts = config.preview(makeInput(context.values, cursor, context.picks));
|
||||
setGhostHelperEntities(ghosts);
|
||||
};
|
||||
/** index 부터 `when` 거짓인 단계를 건너뜀 · 단계 수 = 확정 */
|
||||
const skipWhen = (index: number, context: Partial<SequenceContext>): number => {
|
||||
let at = index;
|
||||
while (at <= lastIndex && steps[at].when && !steps[at].when?.(inputOf(context))) at++;
|
||||
return at;
|
||||
};
|
||||
const after = (index: number, context: Partial<SequenceContext>): number => {
|
||||
const step = steps[index];
|
||||
if (step.next !== undefined) return skipWhen(indexOf(step.next), context);
|
||||
return isRepeat(index) ? index : skipWhen(index + 1, context);
|
||||
};
|
||||
|
||||
const pushPoint = assign(
|
||||
({ context, event }: { context: SequenceContext; event: StateEvent }) => {
|
||||
const point = getPointFromEvent(lastPoint(context.values), event as PointInputEvent);
|
||||
setAngleGuideOriginPoint(point);
|
||||
return { values: [...context.values, point], picks: [...context.picks, point] };
|
||||
}
|
||||
);
|
||||
/** 단계 index 가 값을 받음 → 다음 갈 곳 · 기본값 기억 */
|
||||
const accepted = (
|
||||
context: SequenceContext,
|
||||
index: number,
|
||||
value: SequenceValue,
|
||||
pick: Point | null,
|
||||
): Partial<SequenceContext> => {
|
||||
const next = {
|
||||
...context,
|
||||
values: [...context.values, value],
|
||||
picks: [...context.picks, pick],
|
||||
stepOf: [...context.stepOf, index],
|
||||
};
|
||||
const fallback = promptOf(index, context)?.default;
|
||||
if (fallback?.kind === "value" && (typeof value === "number" || isPointValue(value))) {
|
||||
remember(fallback.remember, value);
|
||||
}
|
||||
return { ...next, at: after(index, next) };
|
||||
};
|
||||
|
||||
const pushNumberFromEvent = assign(
|
||||
({ context, event }: { context: SequenceContext; event: StateEvent }) => {
|
||||
const value =
|
||||
event.type === 'NUMBER_INPUT'
|
||||
? (event as NumberInputEvent).value
|
||||
: Number.parseFloat((event as TextInputEvent).value);
|
||||
return { values: [...context.values, value], picks: [...context.picks, null] };
|
||||
}
|
||||
);
|
||||
const resetTool = () => {
|
||||
setShouldDrawHelpers(useHelpers);
|
||||
setGhostHelperEntities([]);
|
||||
setAngleGuideOriginPoint(null);
|
||||
};
|
||||
|
||||
const pushDefault = (index: number) =>
|
||||
assign(({ context }: { context: SequenceContext }) => ({
|
||||
values: [...context.values, steps[index].defaultValue as SequenceValue],
|
||||
picks: [...context.picks, null],
|
||||
}));
|
||||
const drawPreview = ({ context, event }: Ctx) => {
|
||||
if (!config.preview) return;
|
||||
// 각도 고정(`<30`) 중이면 커서를 고정 방향 위로
|
||||
const cursor = getPointFromEvent(lastPoint(context.values), event as DrawEvent);
|
||||
setGhostHelperEntities(config.preview(makeInput(context, cursor)));
|
||||
};
|
||||
|
||||
const pushText = assign(
|
||||
({ context, event }: { context: SequenceContext; event: StateEvent }) => ({
|
||||
values: [...context.values, (event as TextInputEvent).value],
|
||||
picks: [...context.picks, null],
|
||||
})
|
||||
);
|
||||
const pushPoint = (index: number) =>
|
||||
assign(({ context, event }: Ctx) => {
|
||||
const point = getPointFromEvent(lastPoint(context.values), event as PointInputEvent);
|
||||
setAngleGuideOriginPoint(point);
|
||||
return accepted(context, index, point, point);
|
||||
});
|
||||
|
||||
const pushEntity = assign(
|
||||
({ context, event }: { context: SequenceContext; event: StateEvent }) => {
|
||||
const location = (event as MouseClickEvent).worldMouseLocation;
|
||||
const picked = pickEntityAt(location);
|
||||
return picked
|
||||
? { values: [...context.values, picked], picks: [...context.picks, location] }
|
||||
: { values: context.values, picks: context.picks };
|
||||
}
|
||||
);
|
||||
const pushNumber = (index: number) =>
|
||||
assign(({ context, event }: Ctx) =>
|
||||
accepted(context, index, (event as NumberInputEvent).value, null),
|
||||
);
|
||||
|
||||
const pushSelection = assign(({ context }: { context: SequenceContext }) => ({
|
||||
values: [...context.values, getSelectedEntities()],
|
||||
picks: [...context.picks, null],
|
||||
}));
|
||||
const pushDefault = (index: number) =>
|
||||
assign(({ context }: Ctx) =>
|
||||
accepted(context, index, steps[index].defaultValue as SequenceValue, null),
|
||||
);
|
||||
|
||||
// 점만 받는 그리기 명령은 계속 그리게 두고, 그 밖의 명령은 끝나면 선택 도구로 돌아간다
|
||||
const keepRunning =
|
||||
config.restart ?? (steps.every((step) => step.kind === 'point') || !!config.chainFromLastPoint);
|
||||
const pushText = (index: number) =>
|
||||
assign(({ context, event }: Ctx) =>
|
||||
accepted(context, index, (event as TextInputEvent).value, null),
|
||||
);
|
||||
|
||||
const commit = assign(({ context }: { context: SequenceContext }) => {
|
||||
const points = context.values.filter(isPointValue) as Point[];
|
||||
const cursor = points.length ? points[points.length - 1] : cursorPoint(undefined);
|
||||
config.commit(makeInput(context.values, cursor, context.picks));
|
||||
setGhostHelperEntities([]);
|
||||
if (!keepRunning) {
|
||||
// 전이 도중에 액터를 갈아치우지 않도록 다음 틱에 도구를 바꾼다
|
||||
setTimeout(() => setActiveToolActor(new Actor(selectToolStateMachine)), 0);
|
||||
}
|
||||
if (config.chainFromLastPoint && points.length) {
|
||||
const tail = points[points.length - 1];
|
||||
setAngleGuideOriginPoint(tail);
|
||||
return { values: [tail] as SequenceValue[], picks: [tail] as (Point | null)[] };
|
||||
}
|
||||
return { values: [] as SequenceValue[], picks: [] as (Point | null)[] };
|
||||
});
|
||||
const pushEntity = (index: number) =>
|
||||
assign(({ context, event }: Ctx) => {
|
||||
const location = (event as MouseClickEvent).worldMouseLocation;
|
||||
const picked = pickEntityAt(location);
|
||||
return picked ? accepted(context, index, picked, location) : {};
|
||||
});
|
||||
|
||||
const cancel = assign(({ context }: { context: SequenceContext }) => {
|
||||
config.onCancel?.();
|
||||
setGhostHelperEntities([]);
|
||||
setAngleGuideOriginPoint(null);
|
||||
if (context.values.length === 0) {
|
||||
// 입력이 하나도 없는 상태의 ESC → 선택 도구로 빠져나간다 (AutoCAD와 동일)
|
||||
setActiveToolActor(new Actor(selectToolStateMachine));
|
||||
}
|
||||
return { values: [] as SequenceValue[], picks: [] as (Point | null)[] };
|
||||
});
|
||||
const pushSelection = (index: number) =>
|
||||
assign(({ context }: Ctx) => {
|
||||
const selected = getSelectedEntities();
|
||||
rememberSelection(selected.map((entity) => entity.id));
|
||||
return accepted(context, index, selected, null);
|
||||
});
|
||||
|
||||
// biome-ignore lint/suspicious/noExplicitAny: xstate 상태 구성은 동적으로 만든다
|
||||
const states: Record<string, any> = {
|
||||
[INIT_STATE]: {
|
||||
always: { actions: resetTool, target: STEP_STATE(0) },
|
||||
},
|
||||
[COMMIT_STATE]: {
|
||||
always: {
|
||||
actions: commit,
|
||||
target: config.chainFromLastPoint ? STEP_STATE(1) : INIT_STATE,
|
||||
},
|
||||
},
|
||||
};
|
||||
/** 단계 index 가 이 옵션 key 를 처리하는가 */
|
||||
const handles = (index: number, key: string, context: SequenceContext): boolean =>
|
||||
!!steps[index].options?.[key] ||
|
||||
(steps[index].kind === "keyword" &&
|
||||
!!promptOf(index, context)?.options?.some((option) => option.key === key));
|
||||
|
||||
steps.forEach((step, index) => {
|
||||
const target = nextTarget(index);
|
||||
const escTransition = { actions: cancel, target: INIT_STATE };
|
||||
// biome-ignore lint/suspicious/noExplicitAny: 이벤트 맵도 단계 종류마다 달라진다
|
||||
const on: Record<string, any> = { ESC: escTransition };
|
||||
const applyOption = (index: number) =>
|
||||
assign(({ context, event }: Ctx): Partial<SequenceContext> => {
|
||||
const key = optionKey(event) as string;
|
||||
const handler = steps[index].options?.[key];
|
||||
if (handler === "end") return { at: -1 };
|
||||
if (handler === "close") return { closed: true, at: steps.length };
|
||||
if (handler === "undo") {
|
||||
if (!context.values.length) return { at: index };
|
||||
const values = context.values.slice(0, -1);
|
||||
setAngleGuideOriginPoint(lastPoint(values));
|
||||
setGhostHelperEntities([]);
|
||||
return {
|
||||
values,
|
||||
picks: context.picks.slice(0, -1),
|
||||
stepOf: context.stepOf.slice(0, -1),
|
||||
at: context.stepOf[context.stepOf.length - 1],
|
||||
};
|
||||
}
|
||||
if (handler && "run" in handler) {
|
||||
handler.run(inputOf(context));
|
||||
return { at: index };
|
||||
}
|
||||
const mode = handler ? handler.mode : context.mode;
|
||||
const moved = { ...context, mode };
|
||||
const jump = handler?.next !== undefined ? skipWhen(indexOf(handler.next), moved) : null;
|
||||
if (steps[index].kind === "keyword") {
|
||||
const taken = accepted(moved, index, key, null); // 고른 key 가 값
|
||||
return { ...taken, mode, at: jump ?? (taken.at as number) };
|
||||
}
|
||||
return { mode, at: jump ?? skipWhen(index + 1, moved) };
|
||||
});
|
||||
|
||||
if (step.kind === 'point') {
|
||||
on.DRAW = { actions: drawPreview };
|
||||
on.MOUSE_CLICK = { actions: pushPoint, target };
|
||||
on.ABSOLUTE_POINT_INPUT = { actions: pushPoint, target };
|
||||
// 거리 · 상대 좌표는 기준점(앞 점)이 있을 때만 — 첫 점이면 무시(도구가 멈추지 않게)
|
||||
const hasBase = ({ context }: { context: SequenceContext }) => !!lastPoint(context.values);
|
||||
on.RELATIVE_POINT_INPUT = { guard: hasBase, actions: pushPoint, target };
|
||||
on.NUMBER_INPUT = { guard: hasBase, actions: pushPoint, target };
|
||||
if (config.repeatLastStep && index === lastIndex) {
|
||||
on.ENTER = { target: COMMIT_STATE };
|
||||
}
|
||||
} else if (step.kind === 'number') {
|
||||
on.DRAW = { actions: drawPreview };
|
||||
on.NUMBER_INPUT = { actions: pushNumberFromEvent, target };
|
||||
on.TEXT_INPUT = {
|
||||
guard: ({ event }: { event: StateEvent }) =>
|
||||
Number.isFinite(Number.parseFloat((event as TextInputEvent).value)),
|
||||
actions: pushNumberFromEvent,
|
||||
target,
|
||||
};
|
||||
if (step.defaultValue !== undefined) {
|
||||
on.ENTER = { actions: pushDefault(index), target };
|
||||
}
|
||||
} else if (step.kind === 'text') {
|
||||
on.TEXT_INPUT = { actions: pushText, target };
|
||||
on.NUMBER_INPUT = { actions: pushNumberFromEvent, target };
|
||||
if (step.defaultValue !== undefined) {
|
||||
on.ENTER = { actions: pushDefault(index), target };
|
||||
}
|
||||
} else if (step.kind === 'entity') {
|
||||
on.DRAW = { actions: drawPreview };
|
||||
on.MOUSE_CLICK = {
|
||||
guard: ({ event }: { event: StateEvent }) =>
|
||||
!!pickEntityAt((event as MouseClickEvent).worldMouseLocation),
|
||||
actions: pushEntity,
|
||||
target,
|
||||
};
|
||||
}
|
||||
const commitNow = ({ context }: { context: SequenceContext }) => {
|
||||
const points = context.values.filter(isPointValue) as Point[];
|
||||
const cursor = points.length ? points[points.length - 1] : cursorPoint(undefined);
|
||||
config.commit(makeInput(context, cursor));
|
||||
setGhostHelperEntities([]);
|
||||
};
|
||||
|
||||
if (step.kind === 'selection') {
|
||||
const actorId = `selectInside_${index}`;
|
||||
states[STEP_STATE(index)] = {
|
||||
meta: { instructions: step.instructions },
|
||||
invoke: {
|
||||
id: actorId,
|
||||
src: selectToolStateMachine,
|
||||
onDone: { actions: pushSelection, target },
|
||||
},
|
||||
on: {
|
||||
MOUSE_CLICK: { actions: sendTo(actorId, ({ event }) => event) },
|
||||
ENTER: { actions: sendTo(actorId, ({ event }) => event) },
|
||||
DRAW: { actions: sendTo(actorId, ({ event }) => event) },
|
||||
ESC: escTransition,
|
||||
},
|
||||
};
|
||||
return;
|
||||
}
|
||||
/** 확정 뒤 되풀이 — loopFrom 앞 값만 둠(옛 chainFromLastPoint = 마지막 점 이어받음) */
|
||||
const loopAgain = assign(({ context }: Ctx): Partial<SequenceContext> => {
|
||||
const from = loopIndex as number;
|
||||
const tail = lastPoint(context.values);
|
||||
const chain = config.chainFromLastPoint && config.loopFrom === undefined && tail;
|
||||
const keep = context.values.map((_, index) => chain || context.stepOf[index] < from);
|
||||
const values = chain ? [tail] : context.values.filter((_, index) => keep[index]);
|
||||
const next = {
|
||||
...context,
|
||||
values,
|
||||
picks: chain ? [tail] : context.picks.filter((_, index) => keep[index]),
|
||||
stepOf: chain ? [0] : context.stepOf.filter((_, index) => keep[index]),
|
||||
closed: false,
|
||||
loops: context.loops + 1,
|
||||
};
|
||||
setAngleGuideOriginPoint(lastPoint(values));
|
||||
return { ...next, at: skipWhen(from, next) };
|
||||
});
|
||||
|
||||
states[STEP_STATE(index)] = {
|
||||
meta: { instructions: step.instructions, kind: step.kind },
|
||||
entry: () => {
|
||||
// 객체 선택 단계가 아니면 스냅·각도 가이드를 켠다
|
||||
setShouldDrawHelpers(useHelpers && step.kind === 'point');
|
||||
},
|
||||
on,
|
||||
};
|
||||
});
|
||||
const cancel = ({ context }: { context: SequenceContext }) => {
|
||||
config.onCancel?.();
|
||||
setGhostHelperEntities([]);
|
||||
setAngleGuideOriginPoint(null);
|
||||
if (context.stepOf.some((index) => steps[index].kind === "selection")) setSelectedEntityIds([]);
|
||||
};
|
||||
const escCommits = ({ context }: { context: SequenceContext }) =>
|
||||
!!config.escCommits && context.stepOf.includes(lastIndex);
|
||||
const escTransition = [
|
||||
{ guard: escCommits, actions: commitNow, target: END_STATE },
|
||||
{ actions: cancel, target: END_STATE },
|
||||
];
|
||||
|
||||
return createMachine(
|
||||
{
|
||||
types: {} as { context: SequenceContext; events: StateEvent },
|
||||
context: { values: [] as SequenceValue[], picks: [] as (Point | null)[], type: tool },
|
||||
initial: INIT_STATE,
|
||||
states,
|
||||
},
|
||||
{
|
||||
// 하위에서 돌리는 선택 도구의 액션 구현을 함께 넘긴다
|
||||
// biome-ignore lint/suspicious/noExplicitAny: 자식 머신의 컨텍스트 타입이 달라 그대로 넘긴다
|
||||
actions: { ...(selectToolStateMachine.implementations.actions as any) },
|
||||
}
|
||||
);
|
||||
const pickfirst = () =>
|
||||
config.pickfirst !== false &&
|
||||
steps[0]?.kind === "selection" &&
|
||||
getSelectedEntities().length > 0;
|
||||
|
||||
// biome-ignore lint/suspicious/noExplicitAny: xstate 상태 구성은 동적으로 만든다
|
||||
const states: Record<string, any> = {
|
||||
[INIT_STATE]: {
|
||||
always: [
|
||||
{ guard: pickfirst, actions: [resetTool, pushSelection(0)], target: ROUTE_STATE },
|
||||
{
|
||||
actions: [resetTool, assign(({ context }: Ctx) => ({ at: skipWhen(0, context) }))],
|
||||
target: ROUTE_STATE,
|
||||
},
|
||||
],
|
||||
},
|
||||
[ROUTE_STATE]: {
|
||||
always: [
|
||||
{ guard: ({ context }: Ctx) => context.at < 0, target: END_STATE },
|
||||
...steps.map((_, index) => ({
|
||||
guard: ({ context }: Ctx) => context.at === index,
|
||||
target: STEP_STATE(index),
|
||||
})),
|
||||
{ target: COMMIT_STATE },
|
||||
],
|
||||
},
|
||||
[COMMIT_STATE]: {
|
||||
always:
|
||||
loopIndex === null
|
||||
? { actions: commitNow, target: END_STATE }
|
||||
: { actions: [commitNow, loopAgain], target: ROUTE_STATE },
|
||||
},
|
||||
[END_STATE]: { type: "final" },
|
||||
};
|
||||
|
||||
steps.forEach((step, index) => {
|
||||
const toRoute = (actions: unknown) => ({ actions, target: ROUTE_STATE });
|
||||
const wrapPrompt = step.prompt
|
||||
? (context: SequenceContext) => promptOf(index, context ?? {})
|
||||
: undefined;
|
||||
// biome-ignore lint/suspicious/noExplicitAny: 이벤트 맵도 단계 종류마다 달라진다
|
||||
const on: Record<string, any> = {
|
||||
ESC: escTransition,
|
||||
"OPTION.*": {
|
||||
guard: ({ context, event }: Ctx) => handles(index, optionKey(event) as string, context),
|
||||
...toRoute(applyOption(index)),
|
||||
},
|
||||
};
|
||||
// 빈 Enter — 옛 기본값 · 되풀이 점 = 확정 · 되풀이 첫 물음(한 번 이상 돈 뒤) = 끝
|
||||
const enter: unknown[] = [];
|
||||
if (step.defaultValue !== undefined) enter.push(toRoute(pushDefault(index)));
|
||||
if (isRepeat(index)) enter.push({ target: COMMIT_STATE });
|
||||
if (index === loopIndex) {
|
||||
enter.push({ guard: ({ context }: Ctx) => context.loops > 0, target: END_STATE });
|
||||
}
|
||||
if (enter.length && step.kind !== "selection") on.ENTER = enter;
|
||||
|
||||
if (step.kind === "point") {
|
||||
on.DRAW = { actions: drawPreview };
|
||||
on.MOUSE_CLICK = toRoute(pushPoint(index));
|
||||
on.ABSOLUTE_POINT_INPUT = toRoute(pushPoint(index));
|
||||
// 거리 · 상대 좌표는 기준점(앞 점)이 있을 때만 — 첫 점이면 무시(도구가 멈추지 않게)
|
||||
const hasBase = ({ context }: Ctx) => !!lastPoint(context.values);
|
||||
on.RELATIVE_POINT_INPUT = { guard: hasBase, ...toRoute(pushPoint(index)) };
|
||||
on.NUMBER_INPUT = { guard: hasBase, ...toRoute(pushPoint(index)) };
|
||||
} else if (step.kind === "number") {
|
||||
on.DRAW = { actions: drawPreview };
|
||||
on.NUMBER_INPUT = toRoute(pushNumber(index));
|
||||
} else if (step.kind === "text") {
|
||||
on.TEXT_INPUT = toRoute(pushText(index));
|
||||
on.NUMBER_INPUT = toRoute(pushNumber(index));
|
||||
} else if (step.kind === "entity") {
|
||||
on.DRAW = { actions: drawPreview };
|
||||
on.MOUSE_CLICK = {
|
||||
guard: ({ event }: Ctx) => !!pickEntityAt((event as MouseClickEvent).worldMouseLocation),
|
||||
...toRoute(pushEntity(index)),
|
||||
};
|
||||
} else if (step.kind === "keyword") {
|
||||
on.DRAW = { actions: drawPreview };
|
||||
}
|
||||
|
||||
if (step.kind === "selection") {
|
||||
const actorId = `selectInside_${index}`;
|
||||
const forward = { actions: sendTo(actorId, ({ event }) => event) };
|
||||
states[STEP_STATE(index)] = {
|
||||
meta: { kind: "selection", prompt: wrapPrompt ?? SELECT_OBJECTS },
|
||||
entry: () => setShouldDrawHelpers(false),
|
||||
invoke: {
|
||||
id: actorId,
|
||||
src: selectToolStateMachine,
|
||||
onDone: [
|
||||
// 고른 것 없이 Enter = 명령 끝(오토캐드)
|
||||
{ guard: () => getSelectedEntities().length === 0, target: END_STATE },
|
||||
toRoute(pushSelection(index)),
|
||||
],
|
||||
},
|
||||
on: {
|
||||
MOUSE_CLICK: forward,
|
||||
ENTER: forward,
|
||||
DRAW: forward,
|
||||
"OPTION.*": forward,
|
||||
ESC: escTransition,
|
||||
},
|
||||
};
|
||||
return;
|
||||
}
|
||||
|
||||
states[STEP_STATE(index)] = {
|
||||
meta: wrapPrompt
|
||||
? { kind: step.kind, prompt: wrapPrompt }
|
||||
: { kind: step.kind, instructions: step.instructions },
|
||||
entry: () => {
|
||||
// 객체 선택 단계가 아니면 스냅·각도 가이드를 켠다
|
||||
setShouldDrawHelpers(useHelpers && step.kind === "point");
|
||||
},
|
||||
on,
|
||||
};
|
||||
});
|
||||
|
||||
return createMachine(
|
||||
{
|
||||
types: {} as { context: SequenceContext; events: StateEvent },
|
||||
context: {
|
||||
values: [] as SequenceValue[],
|
||||
picks: [] as (Point | null)[],
|
||||
stepOf: [] as number[],
|
||||
at: 0,
|
||||
mode: null,
|
||||
closed: false,
|
||||
loops: 0,
|
||||
type: tool,
|
||||
},
|
||||
initial: INIT_STATE,
|
||||
states,
|
||||
},
|
||||
{
|
||||
// 하위에서 돌리는 선택 도구의 액션 구현을 함께 넘긴다
|
||||
// biome-ignore lint/suspicious/noExplicitAny: 자식 머신의 컨텍스트 타입이 달라 그대로 넘긴다
|
||||
actions: { ...(selectToolStateMachine.implementations.actions as any) },
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
@@ -44,6 +44,8 @@ export interface MouseClickEvent extends EventObject {
|
||||
screenMouseLocation: Point;
|
||||
holdingCtrl: boolean;
|
||||
holdingShift: boolean;
|
||||
/** 객체 스냅 · 한 번 스냅이 고른 점 — 직교가 덮지 않음 */
|
||||
snapped?: boolean;
|
||||
}
|
||||
|
||||
export interface KeyboardEscEvent extends EventObject {
|
||||
@@ -89,6 +91,21 @@ export interface DrawEvent extends EventObject {
|
||||
drawController: ScreenCanvasDrawController;
|
||||
}
|
||||
|
||||
/** 옵션 사건 — 물음 `prompt.options` 의 key 마다 `OPTION.<key>`(설계 2-3) */
|
||||
export interface OptionEvent extends EventObject {
|
||||
type: `OPTION.${string}`;
|
||||
}
|
||||
|
||||
/** 옵션 사건이면 그 key · 아니면 null */
|
||||
export const optionKey = (event: EventObject): string | null =>
|
||||
event.type.startsWith('OPTION.') ? event.type.slice('OPTION.'.length) : null;
|
||||
|
||||
/** 명령 끝 상태 이름 — `type: 'final'` · 들면 쉬는 상태(선택 도구)로 바뀜 */
|
||||
export const END_STATE = 'END';
|
||||
|
||||
/** 물음 종류 — 상태 `meta.kind` */
|
||||
export type StepKind = 'point' | 'number' | 'text' | 'entity' | 'selection' | 'keyword';
|
||||
|
||||
export type PointInputEvent =
|
||||
| DrawEvent
|
||||
| MouseClickEvent
|
||||
@@ -106,7 +123,8 @@ export type StateEvent =
|
||||
| AbsolutePointInputEvent
|
||||
| RelativePointInputEvent
|
||||
| FileSelectedEvent
|
||||
| DrawEvent;
|
||||
| DrawEvent
|
||||
| OptionEvent;
|
||||
|
||||
export interface ToolContext {
|
||||
type: Tool;
|
||||
|
||||
@@ -44,6 +44,16 @@
|
||||
"offsetY": 698,
|
||||
"offsetX": 473
|
||||
},
|
||||
{
|
||||
"type": "keyDown",
|
||||
"target": "main",
|
||||
"key": "Escape"
|
||||
},
|
||||
{
|
||||
"type": "keyUp",
|
||||
"key": "Escape",
|
||||
"target": "main"
|
||||
},
|
||||
{
|
||||
"type": "keyDown",
|
||||
"target": "main",
|
||||
|
||||
@@ -25,4 +25,10 @@ describe("command-catalog.json", () => {
|
||||
expect(new Set(aliases).size).toBe(aliases.length);
|
||||
expect(aliases.filter((a) => ids.has(a))).toEqual([]);
|
||||
});
|
||||
|
||||
it("337 acad.pgp 맞춤 — SE 없음(acad = DSETTINGS) · ED = TEXTEDIT", () => {
|
||||
const owner = (alias: string) => catalog.find((c) => c.aliases.includes(alias))?.id;
|
||||
expect(owner("SE")).toBeUndefined();
|
||||
expect(owner("ED")).toBe("TEXTEDIT");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,131 @@
|
||||
/**
|
||||
* CAD 시험 공용 조작(337 설계 6-2) — 빈 도면 · 쉬는 상태(선택 도구)에서 키 · 마우스를 사람처럼 넣음.
|
||||
* 시험 쪽 flatten-js 는 다른 사본(「Illegal Parameters」) — 점은 앱 코드로만 만들고 숫자로 판정.
|
||||
* 시험 캔버스는 축척 1 · y 위 + · 세계 (x, y) = 화면 (x, 높이 − y).
|
||||
*/
|
||||
import { vi } from "vitest";
|
||||
import type { InputController } from "../../../../B07_DesignDetail/openwebcad/src/inputController/input-controller";
|
||||
import {
|
||||
currentNotice,
|
||||
currentPrompt,
|
||||
} from "../../../../B07_DesignDetail/openwebcad/src/inputController/prompt-input";
|
||||
import {
|
||||
getActiveToolActor,
|
||||
getEntities,
|
||||
setSelectedEntityIds,
|
||||
} from "../../../../B07_DesignDetail/openwebcad/src/state";
|
||||
import { ActorEvent } from "../../../../B07_DesignDetail/openwebcad/src/tools/tool.types";
|
||||
import { initApplication } from "../../../../B07_DesignDetail/openwebcad/test/helpers/init-application";
|
||||
import { CANVAS_HEIGHT } from "../../../../B07_DesignDetail/openwebcad/test/helpers/tests.consts";
|
||||
|
||||
export type Mods = Partial<Pick<KeyboardEvent, "shiftKey" | "ctrlKey" | "altKey" | "metaKey">> & {
|
||||
code?: string;
|
||||
repeat?: boolean;
|
||||
};
|
||||
|
||||
let controller: InputController;
|
||||
|
||||
/** 빈 도면 · 고른 것 없음 · 선택 도구(쉼)로 시작 — 앱 도우미가 켠 LINE 첫 물음에서 Esc */
|
||||
export function initIdle(): InputController {
|
||||
controller = initApplication();
|
||||
getActiveToolActor()?.send({ type: ActorEvent.ESC });
|
||||
setSelectedEntityIds([]);
|
||||
return controller;
|
||||
}
|
||||
|
||||
export const driver = (): InputController => controller;
|
||||
|
||||
/** 키 하나 — `key` 는 KeyboardEvent.key 꼴(「a」 · 「Enter」 · 「Escape」 · 「 」) */
|
||||
export function key(k: string, mods: Mods = {}) {
|
||||
const preventDefault = vi.fn();
|
||||
controller.handleKeyStroke({
|
||||
key: k,
|
||||
code: mods.code ?? "",
|
||||
keyCode: 0,
|
||||
isComposing: false,
|
||||
shiftKey: false,
|
||||
ctrlKey: false,
|
||||
altKey: false,
|
||||
metaKey: false,
|
||||
repeat: false,
|
||||
target: null,
|
||||
preventDefault,
|
||||
stopPropagation: vi.fn(),
|
||||
...mods,
|
||||
} as unknown as KeyboardEvent);
|
||||
return preventDefault;
|
||||
}
|
||||
|
||||
/** 글자만 침(제출 안 함) */
|
||||
export const typed = (text: string) => {
|
||||
for (const ch of text) key(ch);
|
||||
};
|
||||
/** 글자 + Space(= Enter) */
|
||||
export const type = (text: string) => {
|
||||
typed(text);
|
||||
key(" ");
|
||||
};
|
||||
/** 글자 + Enter */
|
||||
export const typeEnter = (text: string) => {
|
||||
typed(text);
|
||||
key("Enter");
|
||||
};
|
||||
|
||||
const mouse = (x: number, y: number, button: number, mods: Mods = {}) =>
|
||||
({
|
||||
button,
|
||||
clientX: x,
|
||||
clientY: CANVAS_HEIGHT - y,
|
||||
target: null,
|
||||
shiftKey: false,
|
||||
ctrlKey: false,
|
||||
preventDefault: () => {},
|
||||
stopPropagation: () => {},
|
||||
...mods,
|
||||
}) as unknown as MouseEvent;
|
||||
|
||||
/** 세계 좌표 (x, y) 로 커서 이동 */
|
||||
export const move = (x: number, y: number) =>
|
||||
controller.handleMouseMove({ clientX: x, clientY: CANVAS_HEIGHT - y } as MouseEvent);
|
||||
export const press = (x: number, y: number, mods: Mods = {}) => {
|
||||
move(x, y);
|
||||
controller.handleMouseDown(mouse(x, y, 0, mods));
|
||||
};
|
||||
export const release = (x: number, y: number, mods: Mods = {}) => {
|
||||
move(x, y);
|
||||
controller.handleMouseUp(mouse(x, y, 0, mods));
|
||||
};
|
||||
/** 왼쪽 누름 한 번(누름 + 뗌 같은 자리) */
|
||||
export const click = (x: number, y: number, mods: Mods = {}) => {
|
||||
press(x, y, mods);
|
||||
release(x, y, mods);
|
||||
};
|
||||
/** a 에서 눌러 b 에서 뗌 */
|
||||
export const drag = (a: [number, number], b: [number, number], mods: Mods = {}) => {
|
||||
press(a[0], a[1], mods);
|
||||
move(b[0], b[1]);
|
||||
release(b[0], b[1], mods);
|
||||
};
|
||||
export const rightClick = (shift = false) =>
|
||||
controller.handleMouseUp(mouse(0, 0, 2, { shiftKey: shift }));
|
||||
/** 가운데 단추 두 번 */
|
||||
export const middleDouble = (x: number, y: number) => {
|
||||
for (let i = 0; i < 2; i++) {
|
||||
controller.handleMouseDown(mouse(x, y, 1));
|
||||
controller.handleMouseUp(mouse(x, y, 1));
|
||||
}
|
||||
};
|
||||
|
||||
/** 도면 객체(유형 이름 거름 — 「Line」 · 「Circle」 …) */
|
||||
export const entities = (type?: string) =>
|
||||
getEntities().filter((entity) => !type || entity.getType() === type);
|
||||
/** 커서 곁 첫 줄(지금 물음) */
|
||||
export const promptLine = () => currentPrompt().line;
|
||||
/** 커서 곁 둘째 줄(거절 · 안내) */
|
||||
export const noticeLine = () => currentNotice();
|
||||
/** 맨 위 도구 이름(Tool) */
|
||||
export const activeTool = () => getActiveToolActor()?.getSnapshot()?.context?.type;
|
||||
export const toolState = () => getActiveToolActor()?.getSnapshot()?.value;
|
||||
/** 친 글(커서 곁 입력 칸) */
|
||||
export const inputText = () => (controller as unknown as { text: string }).text;
|
||||
export const tick = () => new Promise((resolve) => setTimeout(resolve, 0));
|
||||
@@ -76,6 +76,7 @@ describe("내 별칭", () => {
|
||||
it("CAD 키 입력 — 「qq␣」 = 사각형 명령", () => {
|
||||
savePrefs({ cadAliases: { RECTANG: ["QQ"] } });
|
||||
const controller = initApplication();
|
||||
getActiveToolActor()?.send({ type: "ESC" }); // 쉬는 상태에서(337 — 점 묻는 중 명령 이름 거절)
|
||||
for (const k of ["q", "q", " "]) {
|
||||
controller.handleKeyStroke({
|
||||
key: k,
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
/**
|
||||
* CAD 키보드 연속 명령(PLAN 331 · 요구 224-7) — 오토캐드 방식.
|
||||
* CAD 키보드 연속 명령(PLAN 331 · 요구 224-7 · 337 손질) — 오토캐드 방식.
|
||||
* 각도 고정 `<30` → 길이 · 시퀀스 첫 점 숫자 · 이동 · 복사 · 회전 키보드 값 · 선 중 C · U · 한 번 스냅 · 동적 입력 Tab.
|
||||
* 337 — 쉬는 상태에서 시작(cad-drive) · 점 묻는 중 명령 이름 거절 · Esc 한 번 = 명령 끝.
|
||||
* 시험 쪽 flatten-js 는 다른 사본(「Illegal Parameters」) — 점은 앱 코드로만 만들고 숫자로 판정.
|
||||
* 실행: cd B07_DesignDetail/openwebcad && npx vitest run --dir ../../resources/tester/cad keyboard-chain
|
||||
*/
|
||||
@@ -22,7 +23,7 @@ import {
|
||||
setSelectedEntityIds,
|
||||
} from "../../../B07_DesignDetail/openwebcad/src/state";
|
||||
import { runCommandInput } from "../../../B07_DesignDetail/openwebcad/src/commands/run-command";
|
||||
import { initApplication } from "../../../B07_DesignDetail/openwebcad/test/helpers/init-application";
|
||||
import { initIdle, noticeLine } from "./helpers/cad-drive";
|
||||
import { CANVAS_HEIGHT } from "../../../B07_DesignDetail/openwebcad/test/helpers/tests.consts";
|
||||
|
||||
type Xy = { x: number; y: number };
|
||||
@@ -80,7 +81,7 @@ let controller: InputController;
|
||||
|
||||
beforeEach(() => {
|
||||
vi.mocked(toast.error).mockClear();
|
||||
controller = initApplication();
|
||||
controller = initIdle();
|
||||
setEntities([], true);
|
||||
setSelectedEntityIds([]);
|
||||
});
|
||||
@@ -144,6 +145,7 @@ describe("331-1 각도 고정 → 길이", () => {
|
||||
expect(snapshot?.status).toBe("active");
|
||||
expect(snapshot?.value).toBe("STEP_0");
|
||||
expect(toast.error).not.toHaveBeenCalled();
|
||||
expect(noticeLine()).toBe("점 또는 옵션 키워드를 요구함."); // 기준점 없는 거리 · 상대 좌표 = 거절
|
||||
type(controller, "0,0");
|
||||
type(controller, "<0");
|
||||
type(controller, "100");
|
||||
@@ -240,17 +242,20 @@ describe("331-3 선 중 C · U · 한 번 스냅 · 한글 안내", () => {
|
||||
expectXy(drawn[1].end, 20, 0);
|
||||
});
|
||||
|
||||
it("선 중 다른 명령 이름은 그대로 명령", () => {
|
||||
it("선 중 다른 명령 이름은 거절 · 선 그대로(337 · 오토캐드)", () => {
|
||||
drawOpen();
|
||||
type(controller, "circle");
|
||||
expect(getActiveToolActor()?.getSnapshot()?.context.type).toBe("CIRCLE");
|
||||
expect(getActiveToolActor()?.getSnapshot()?.context.type).toBe("LINE");
|
||||
expect(noticeLine()).toBe("점 또는 옵션 키워드를 요구함.");
|
||||
expect(lines()).toHaveLength(2);
|
||||
});
|
||||
|
||||
it("END 한 번 — 둘레 안 끝점으로 · 한 점 뒤 풂", () => {
|
||||
type(controller, "l");
|
||||
type(controller, "0,0");
|
||||
type(controller, "100,0");
|
||||
key(controller, "Escape");
|
||||
key(controller, "Escape"); // 명령 끝
|
||||
type(controller, "l");
|
||||
type(controller, "end");
|
||||
expect(getSnapOverride()).toBe("END");
|
||||
clickAt(controller, 80, 20); // 끝점 (100, 0) 에서 28 — 일반 스냅(15)은 못 닿음
|
||||
@@ -329,7 +334,8 @@ describe("331-4 동적 입력 길이 Tab 각도", () => {
|
||||
typed("12");
|
||||
tab();
|
||||
expect((controller as unknown as { text: string }).text).toBe("12");
|
||||
key(controller, "Escape"); // 친 글만 지움
|
||||
key(controller, "Backspace");
|
||||
key(controller, "Backspace"); // 친 글만 지움(Esc = 명령 끝)
|
||||
type(controller, "0,0");
|
||||
typed("@5,0");
|
||||
tab();
|
||||
|
||||
@@ -0,0 +1,65 @@
|
||||
/**
|
||||
* 337 계약 시험(설계 2-16) — 모든 도구 머신의 평평한 상태가 물음 약속을 지키는지.
|
||||
* ① `prompt.options` key 마다 `OPTION.<key>` 또는 `OPTION.*` 처리 ② 한 물음 안 kw 안 겹침(없는 옵션 포함)
|
||||
* ③ `meta.kind` 있는 상태마다 ESC ④ `prompt` 와 옛 `instructions` 를 같이 두지 않음 ⑤ 함수 꼴 물음은 빈 context 로 그려짐.
|
||||
* 실행: cd B07_DesignDetail/openwebcad && npx vitest run --dir ../../resources/tester/cad prompt-contract
|
||||
*/
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
|
||||
vi.mock("react-toastify", () => ({
|
||||
toast: Object.assign(vi.fn(), { error: vi.fn(), info: vi.fn(), warn: vi.fn(), success: vi.fn() }),
|
||||
}));
|
||||
|
||||
import {
|
||||
type PromptMeta,
|
||||
resolvePrompt,
|
||||
} from "../../../B07_DesignDetail/openwebcad/src/commands/prompt";
|
||||
import { TOOL_STATE_MACHINES } from "../../../B07_DesignDetail/openwebcad/src/commands/registry";
|
||||
|
||||
type StateNode = { config: { meta?: PromptMeta; on?: Record<string, unknown> } };
|
||||
|
||||
const rows = Object.entries(TOOL_STATE_MACHINES).flatMap(([tool, machine]) =>
|
||||
Object.entries((machine as { states: Record<string, StateNode> }).states).map(([name, node]) => ({
|
||||
where: `${tool}.${name}`,
|
||||
meta: node.config.meta,
|
||||
on: node.config.on ?? {},
|
||||
})),
|
||||
);
|
||||
|
||||
describe("물음 약속(2-16)", () => {
|
||||
it("도구 머신을 읽음", () => {
|
||||
expect(rows.length).toBeGreaterThan(100);
|
||||
});
|
||||
|
||||
it("옵션 처리 · kw 안 겹침 · 함수 꼴은 빈 context 로", () => {
|
||||
const problems: string[] = [];
|
||||
for (const { where, meta, on } of rows) {
|
||||
if (!meta?.prompt) continue;
|
||||
let spec: ReturnType<typeof resolvePrompt>;
|
||||
try {
|
||||
spec = resolvePrompt(meta.prompt, {});
|
||||
} catch (error) {
|
||||
problems.push(`${where}: 빈 context 로 못 그림 (${(error as Error).message})`);
|
||||
continue;
|
||||
}
|
||||
for (const option of spec?.options ?? []) {
|
||||
if (!on[`OPTION.${option.key}`] && !on["OPTION.*"]) {
|
||||
problems.push(`${where}: OPTION.${option.key} 처리 없음`);
|
||||
}
|
||||
}
|
||||
const kws = [...(spec?.options ?? []), ...(spec?.absent ?? [])].map((option) => option.kw);
|
||||
const twice = kws.filter((kw, index) => kws.indexOf(kw) !== index);
|
||||
if (twice.length) problems.push(`${where}: kw 겹침 ${twice.join(",")}`);
|
||||
}
|
||||
expect(problems).toEqual([]);
|
||||
});
|
||||
|
||||
it("meta.kind 있는 상태 = ESC 있음 · prompt 와 instructions 같이 없음", () => {
|
||||
const problems: string[] = [];
|
||||
for (const { where, meta, on } of rows) {
|
||||
if (meta?.kind && !on.ESC) problems.push(`${where}: ESC 없음`);
|
||||
if (meta?.prompt && meta.instructions) problems.push(`${where}: prompt + instructions`);
|
||||
}
|
||||
expect(problems).toEqual([]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,558 @@
|
||||
/**
|
||||
* 337 가-1 옵션 틀 — 물음 꼴 · 옵션 맞추기 · 친 글 사다리 · 거절 · Esc · 빈 Enter · 우클릭 · 명령 끝 · 엔진 v2 · 기억 · 덧물음 · Ctrl.
|
||||
* 설계 `docs/raw/조사/2026-10-15_337_CAD오토캐드조작_설계.md` 2 장 · 6-3 가 줄.
|
||||
* 실행: cd B07_DesignDetail/openwebcad && npx vitest run --dir ../../resources/tester/cad prompt-frame
|
||||
*/
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
vi.mock("react-toastify", () => ({
|
||||
toast: Object.assign(vi.fn(), { error: vi.fn(), info: vi.fn(), warn: vi.fn(), success: vi.fn() }),
|
||||
}));
|
||||
|
||||
import { Actor } from "xstate";
|
||||
import {
|
||||
OPT,
|
||||
type OptionSpec,
|
||||
type PromptSpec,
|
||||
SELECT_OBJECTS,
|
||||
matchOption,
|
||||
previousSelection,
|
||||
recall,
|
||||
remember,
|
||||
renderPrompt,
|
||||
} from "../../../B07_DesignDetail/openwebcad/src/commands/prompt";
|
||||
import {
|
||||
getLastCommandId,
|
||||
runCommandInput,
|
||||
} from "../../../B07_DesignDetail/openwebcad/src/commands/run-command";
|
||||
import {
|
||||
pushSubPrompt,
|
||||
routeTyped,
|
||||
} from "../../../B07_DesignDetail/openwebcad/src/inputController/prompt-input";
|
||||
import {
|
||||
getActiveToolActor,
|
||||
getSelectedEntityIds,
|
||||
setActiveToolActor,
|
||||
setSelectedEntityIds,
|
||||
} from "../../../B07_DesignDetail/openwebcad/src/state";
|
||||
import { Tool } from "../../../B07_DesignDetail/openwebcad/src/tools";
|
||||
import { createSequenceTool } from "../../../B07_DesignDetail/openwebcad/src/tools/factories/sequence-tool";
|
||||
import {
|
||||
activeTool,
|
||||
click,
|
||||
driver,
|
||||
entities,
|
||||
initIdle,
|
||||
inputText,
|
||||
key,
|
||||
noticeLine,
|
||||
promptLine,
|
||||
rightClick,
|
||||
toolState,
|
||||
type,
|
||||
typeEnter,
|
||||
typed,
|
||||
} from "./helpers/cad-drive";
|
||||
|
||||
type Xy = { x: number; y: number };
|
||||
const opt = (key: string, kw: string, label: string): OptionSpec => ({ key, kw, en: key, label });
|
||||
const start = (machine: unknown) => setActiveToolActor(new Actor(machine as never), false);
|
||||
const enter = () => key("Enter");
|
||||
const lineIds = () => entities("Line").map((entity) => entity.id);
|
||||
|
||||
beforeEach(() => {
|
||||
initIdle();
|
||||
});
|
||||
|
||||
describe("옵션 맞추기(2-4)", () => {
|
||||
const prompt: PromptSpec = {
|
||||
text: "다음 점 지정",
|
||||
options: [
|
||||
opt("CENTER", "C", "중심"),
|
||||
opt("CLOSE", "CL", "닫기"),
|
||||
{ key: "LTYPE", kw: "LT", en: "LTYPE", label: "선종류" },
|
||||
OPT.UNDO,
|
||||
OPT.ALL,
|
||||
],
|
||||
absent: [{ key: "ELEVATION", kw: "E", en: "ELEVATION", label: "고도" }],
|
||||
};
|
||||
const keyOf = (text: string) => {
|
||||
const hit = matchOption(text, prompt);
|
||||
return hit && "option" in hit
|
||||
? hit.option.key
|
||||
: hit && "absent" in hit
|
||||
? `없음:${hit.absent.key}`
|
||||
: null;
|
||||
};
|
||||
|
||||
it("대소문자 · 앞 `_` · 빈칸 · kw · en · 사이 길이", () => {
|
||||
expect(keyOf("c")).toBe("CENTER");
|
||||
expect(keyOf(" _center ")).toBe("CENTER");
|
||||
expect(keyOf("CE")).toBe("CENTER");
|
||||
expect(keyOf("cl")).toBe("CLOSE"); // kw 딱 맞음 우선
|
||||
expect(keyOf("clo")).toBe("CLOSE");
|
||||
expect(keyOf("u")).toBe("UNDO");
|
||||
expect(keyOf("und")).toBe("UNDO");
|
||||
expect(keyOf("lty")).toBe("LTYPE");
|
||||
});
|
||||
|
||||
it("kw 보다 짧으면 안 맞음 · 숨김도 맞음 · 없는 옵션 안내 · 엉뚱한 글", () => {
|
||||
expect(keyOf("l")).toBeNull(); // LT 의 L 은 모자람(initget)
|
||||
expect(keyOf("all")).toBe("ALL");
|
||||
expect(keyOf("e")).toBe("없음:ELEVATION");
|
||||
expect(keyOf("zz")).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("물음 그림(2-3)", () => {
|
||||
it("옵션 · 기본 · 숨김", () => {
|
||||
expect(renderPrompt({ text: "다음 점 지정", options: [OPT.CLOSE, OPT.UNDO] })).toBe(
|
||||
"다음 점 지정 또는 [닫기(C)/명령 취소(U)]:",
|
||||
);
|
||||
expect(
|
||||
renderPrompt({
|
||||
text: "기준점 지정",
|
||||
options: [OPT.DISPLACEMENT],
|
||||
default: { kind: "option", key: "DISPLACEMENT" },
|
||||
}),
|
||||
).toBe("기준점 지정 또는 [변위(D)] <변위>:");
|
||||
expect(renderPrompt(SELECT_OBJECTS)).toBe("객체 선택:");
|
||||
expect(
|
||||
renderPrompt({
|
||||
text: "두 번째 점 지정",
|
||||
default: { kind: "enter", label: "첫 번째 점을 변위로 사용" },
|
||||
}),
|
||||
).toBe("두 번째 점 지정 <첫 번째 점을 변위로 사용>:");
|
||||
});
|
||||
|
||||
it("값 기본 — 소수 넷 · 정수 · 각 · 좌표 · 기억이 처음 값을 이김 · null 은 안 그림", () => {
|
||||
const length: PromptSpec = {
|
||||
text: "직사각형의 길이 지정",
|
||||
default: { kind: "value", remember: "T.RECT", initial: 10 },
|
||||
};
|
||||
expect(renderPrompt(length)).toBe("직사각형의 길이 지정 <10.0000>:");
|
||||
remember("T.RECT", 12.5);
|
||||
expect(renderPrompt(length)).toBe("직사각형의 길이 지정 <12.5000>:");
|
||||
expect(recall("T.RECT")).toBe(12.5);
|
||||
expect(
|
||||
renderPrompt({
|
||||
text: "변의 수 입력",
|
||||
default: { kind: "value", remember: "T.INT", initial: 4, format: "int" },
|
||||
}),
|
||||
).toBe("변의 수 입력 <4>:");
|
||||
expect(
|
||||
renderPrompt({
|
||||
text: "각 지정",
|
||||
default: { kind: "value", remember: "T.ANG", initial: 22.5, format: "ang" },
|
||||
}),
|
||||
).toBe("각 지정 <22.5>:");
|
||||
expect(
|
||||
renderPrompt({
|
||||
text: "변위 지정",
|
||||
default: { kind: "value", remember: "T.XY", initial: { x: 0, y: 0 } },
|
||||
}),
|
||||
).toBe("변위 지정 <0.0000, 0.0000>:");
|
||||
expect(
|
||||
renderPrompt({
|
||||
text: "반지름 지정",
|
||||
default: { kind: "value", remember: "T.NULL", initial: null },
|
||||
}),
|
||||
).toBe("반지름 지정:");
|
||||
});
|
||||
});
|
||||
|
||||
/** 빈 Enter 넷 갈래 · 숫자 검사 · 키워드 — 변의 수 → 중심 → 내접/외접 → 점 되풀이 */
|
||||
const commits: unknown[][] = [];
|
||||
const polygonLike = createSequenceTool({
|
||||
tool: Tool.POLYGON,
|
||||
repeatLastStep: true,
|
||||
steps: [
|
||||
{
|
||||
kind: "number",
|
||||
id: "sides",
|
||||
prompt: {
|
||||
text: "변의 수 입력",
|
||||
default: { kind: "value", remember: "T.SIDES", initial: 4, format: "int" },
|
||||
number: { int: true, min: 3, max: 1024 },
|
||||
},
|
||||
},
|
||||
{
|
||||
kind: "point",
|
||||
id: "center",
|
||||
prompt: {
|
||||
text: "중심 지정",
|
||||
default: { kind: "value", remember: "T.CENTER", initial: { x: 5, y: 6 } },
|
||||
},
|
||||
},
|
||||
{
|
||||
kind: "keyword",
|
||||
id: "ic",
|
||||
prompt: {
|
||||
text: "옵션 입력",
|
||||
options: [opt("INSCRIBED", "I", "원에 내접"), opt("CIRCUMSCRIBED", "C", "원에 외접")],
|
||||
default: { kind: "option", key: "INSCRIBED", label: "I" },
|
||||
},
|
||||
},
|
||||
{
|
||||
kind: "point",
|
||||
id: "pts",
|
||||
prompt: { text: "다음 점 지정", default: { kind: "enter", label: "끝" } },
|
||||
},
|
||||
],
|
||||
commit: (input) => {
|
||||
const center = input.get<Xy>("center");
|
||||
commits.push([input.get("sides"), center.x, center.y, input.get("ic"), input.points().length]);
|
||||
},
|
||||
});
|
||||
|
||||
describe("빈 Enter 넷 갈래 · 거절 · 기억 · 끝(2-5 · 2-6 · 2-8 · 2-9)", () => {
|
||||
beforeEach(() => {
|
||||
commits.length = 0;
|
||||
});
|
||||
|
||||
it("값(숫자) · 값(좌표) · 옵션 · enter → 확정 → 같은 틱에 쉬는 상태", () => {
|
||||
start(polygonLike);
|
||||
expect(promptLine()).toBe("변의 수 입력 <4>:");
|
||||
enter(); // 4
|
||||
expect(promptLine()).toBe("중심 지정 <5.0000, 6.0000>:");
|
||||
enter(); // (5, 6)
|
||||
expect(promptLine()).toBe("옵션 입력 [원에 내접(I)/원에 외접(C)] <I>:");
|
||||
enter(); // INSCRIBED
|
||||
type("1,1");
|
||||
expect(promptLine()).toBe("다음 점 지정 <끝>:");
|
||||
enter(); // 되풀이 점 = 확정
|
||||
expect(commits).toEqual([[4, 5, 6, "INSCRIBED", 2]]);
|
||||
expect(activeTool()).toBe(Tool.SELECT); // await 없이
|
||||
expect(toolState()).toBe("WAITING_FOR_FIRST_SELECT_POINT");
|
||||
});
|
||||
|
||||
it("숫자 물음 — 식 · 단위 · 정수 · 범위 · 명령 이름 거절 · 받은 값은 다음 <기본>", () => {
|
||||
start(polygonLike);
|
||||
const rejects: Array<[string, string]> = [
|
||||
["3.2*1.5", "숫자 값을 요구함."],
|
||||
["2m", "숫자 값을 요구함."],
|
||||
["l", "숫자 값을 요구함."],
|
||||
["2.5", "정수 값을 요구함."],
|
||||
["2", "3에서 1024 사이의 정수를 요구함."],
|
||||
];
|
||||
for (const [text, message] of rejects) {
|
||||
type(text);
|
||||
expect(noticeLine()).toBe(message);
|
||||
expect(toolState()).toBe("STEP_0");
|
||||
expect(inputText()).toBe("");
|
||||
}
|
||||
type("6");
|
||||
type("0,0");
|
||||
type("c"); // 소문자 키워드
|
||||
type("1,0");
|
||||
enter();
|
||||
expect(commits).toEqual([[6, 0, 0, "CIRCUMSCRIBED", 2]]);
|
||||
start(polygonLike);
|
||||
expect(promptLine()).toBe("변의 수 입력 <6>:"); // 세션 기억
|
||||
enter();
|
||||
expect(promptLine()).toBe("중심 지정 <0.0000, 0.0000>:"); // 누른 점도 기억
|
||||
});
|
||||
|
||||
it("점 물음 — 명령 이름 · 엉뚱한 글 거절(도구 그대로) · 키워드 물음 거절 글", () => {
|
||||
start(polygonLike);
|
||||
enter();
|
||||
type("circle");
|
||||
expect(noticeLine()).toBe("점 또는 옵션 키워드를 요구함.");
|
||||
expect(activeTool()).toBe(Tool.POLYGON);
|
||||
type("0,0");
|
||||
type("x");
|
||||
expect(noticeLine()).toBe("옵션 키워드가 잘못됨.");
|
||||
expect(toolState()).toBe("STEP_2");
|
||||
});
|
||||
|
||||
it("Esc 한 번 = 명령 끝(확정 없음) · 기록 줄", () => {
|
||||
start(polygonLike);
|
||||
enter();
|
||||
type("0,0");
|
||||
key("Escape");
|
||||
expect(activeTool()).toBe(Tool.SELECT);
|
||||
expect(commits).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("엔진 v2 — loopFrom · when · mode · next · escCommits · undo · close · pickfirst(2-13)", () => {
|
||||
it("loopFrom = 앞 값 둠 · mode · when · next · 되풀이 첫 물음 Enter = 끝", () => {
|
||||
const seen: unknown[] = [];
|
||||
const tool = createSequenceTool({
|
||||
tool: Tool.DIST,
|
||||
loopFrom: "at",
|
||||
steps: [
|
||||
{
|
||||
kind: "number",
|
||||
id: "d",
|
||||
prompt: { text: "거리 지정", default: { kind: "value", remember: "T.D", initial: 1 } },
|
||||
},
|
||||
{
|
||||
kind: "point",
|
||||
id: "at",
|
||||
prompt: { text: "점 지정", options: [opt("MODE", "M", "모드")] },
|
||||
options: { MODE: { mode: "m", next: "extra" } },
|
||||
},
|
||||
{
|
||||
kind: "number",
|
||||
id: "extra",
|
||||
when: (input) => input.mode === "m" && !input.has("extra"),
|
||||
next: "at",
|
||||
prompt: { text: "덧 값" },
|
||||
},
|
||||
],
|
||||
commit: (input) =>
|
||||
seen.push([
|
||||
input.get("d"),
|
||||
input.get<Xy>("at").x,
|
||||
input.has("extra") ? input.get("extra") : null,
|
||||
input.mode,
|
||||
]),
|
||||
});
|
||||
start(tool);
|
||||
enter(); // d = 1
|
||||
type("1,1");
|
||||
expect(seen).toEqual([[1, 1, null, null]]);
|
||||
expect(promptLine()).toBe("점 지정 또는 [모드(M)]:"); // 거리는 둠 · 점부터 다시
|
||||
type("m");
|
||||
expect(promptLine()).toBe("덧 값:");
|
||||
type("7");
|
||||
type("2,2");
|
||||
expect(seen[1]).toEqual([1, 2, 7, "m"]);
|
||||
enter(); // 한 번 돈 뒤 첫 되풀이 물음 Enter = 끝
|
||||
expect(activeTool()).toBe(Tool.SELECT);
|
||||
});
|
||||
|
||||
const polys: Array<{ points: number; closed: boolean }> = [];
|
||||
const plineLike = createSequenceTool({
|
||||
tool: Tool.DIST,
|
||||
repeatLastStep: true,
|
||||
escCommits: true,
|
||||
steps: [
|
||||
{ kind: "point", prompt: { text: "시작점 지정" } },
|
||||
{
|
||||
kind: "point",
|
||||
prompt: (input) => ({
|
||||
text: "다음 점 지정",
|
||||
options: input.points().length >= 3 ? [OPT.CLOSE, OPT.UNDO] : [OPT.UNDO],
|
||||
}),
|
||||
options: { CLOSE: "close", UNDO: "undo" },
|
||||
},
|
||||
],
|
||||
commit: (input) => polys.push({ points: input.points().length, closed: input.closed }),
|
||||
});
|
||||
|
||||
it("undo · close(셋째 점부터) · escCommits · 점 하나 Esc = 버림", () => {
|
||||
polys.length = 0;
|
||||
start(plineLike);
|
||||
type("0,0");
|
||||
type("10,0");
|
||||
type("c"); // 아직 닫기 없음
|
||||
expect(noticeLine()).toBe("점 또는 옵션 키워드를 요구함.");
|
||||
type("10,10");
|
||||
expect(promptLine()).toBe("다음 점 지정 또는 [닫기(C)/명령 취소(U)]:");
|
||||
type("u");
|
||||
type("20,0");
|
||||
key("Escape"); // 모은 값으로 확정 뒤 끝
|
||||
expect(polys).toEqual([{ points: 3, closed: false }]);
|
||||
expect(activeTool()).toBe(Tool.SELECT);
|
||||
|
||||
start(plineLike);
|
||||
type("0,0");
|
||||
type("10,0");
|
||||
type("10,10");
|
||||
type("c");
|
||||
expect(polys[1]).toEqual({ points: 3, closed: true });
|
||||
|
||||
start(plineLike);
|
||||
type("0,0");
|
||||
key("Escape");
|
||||
expect(polys).toHaveLength(2);
|
||||
});
|
||||
|
||||
it("고르기 첫 단계 — 고른 것 없이 Enter = 끝 · 고른 채 시작 = 바로 다음 물음(PICKFIRST) · 이전(P) 기억", () => {
|
||||
const picked: number[] = [];
|
||||
const tool = createSequenceTool({
|
||||
tool: Tool.DIST,
|
||||
steps: [{ kind: "selection" }, { kind: "point", prompt: { text: "기준점 지정" } }],
|
||||
commit: (input) => picked.push(input.entities(0).length),
|
||||
});
|
||||
start(tool);
|
||||
expect(promptLine()).toBe("객체 선택:");
|
||||
enter();
|
||||
expect(activeTool()).toBe(Tool.SELECT);
|
||||
expect(picked).toEqual([]);
|
||||
|
||||
type("l");
|
||||
type("0,0");
|
||||
type("10,0");
|
||||
key("Escape");
|
||||
const ids = lineIds();
|
||||
setSelectedEntityIds(ids);
|
||||
start(tool);
|
||||
expect(promptLine()).toBe("기준점 지정:");
|
||||
expect(previousSelection()).toEqual(ids);
|
||||
type("5,5");
|
||||
expect(picked).toEqual([1]);
|
||||
});
|
||||
|
||||
it("고르기 명령 Esc = 끝 + 고름 풂", () => {
|
||||
const tool = createSequenceTool({
|
||||
tool: Tool.DIST,
|
||||
steps: [{ kind: "selection" }, { kind: "point", prompt: { text: "기준점 지정" } }],
|
||||
commit: () => {},
|
||||
});
|
||||
type("l");
|
||||
type("0,0");
|
||||
type("10,0");
|
||||
key("Escape");
|
||||
setSelectedEntityIds(lineIds());
|
||||
start(tool);
|
||||
key("Escape");
|
||||
expect(activeTool()).toBe(Tool.SELECT);
|
||||
expect(getSelectedEntityIds()).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("쉼 · 명령 이름 · 별칭(2-5 ⑨ · 2-17 · N18)", () => {
|
||||
it("딱 맞음만 — R · LE · CIR · SE = 알 수 없는 명령 · l = LINE · ed = TEXTEDIT", () => {
|
||||
for (const text of ["r", "le", "cir", "se"]) {
|
||||
type(text);
|
||||
expect(noticeLine()).toBe(`알 수 없는 명령 「${text.toUpperCase()}」.`);
|
||||
expect(activeTool()).toBe(Tool.SELECT);
|
||||
}
|
||||
type("ed");
|
||||
expect(activeTool()).toBe(Tool.TEXTEDIT);
|
||||
key("Escape");
|
||||
type("l");
|
||||
expect(activeTool()).toBe(Tool.LINE);
|
||||
});
|
||||
|
||||
it("빈 Enter · Space · 우클릭(쉴 때) = 앞 명령 반복", () => {
|
||||
type("l");
|
||||
key("Escape");
|
||||
expect(activeTool()).toBe(Tool.SELECT);
|
||||
rightClick();
|
||||
expect(activeTool()).toBe(Tool.LINE);
|
||||
key("Escape");
|
||||
key(" ");
|
||||
expect(activeTool()).toBe(Tool.LINE);
|
||||
});
|
||||
|
||||
it("Esc 한 번 = 선 끝(그린 토막 남음) · 쉴 때 Esc = 고름 풂", () => {
|
||||
type("l");
|
||||
type("0,0");
|
||||
type("10,0");
|
||||
typed("12");
|
||||
key("Escape");
|
||||
expect(activeTool()).toBe(Tool.SELECT);
|
||||
expect(inputText()).toBe("");
|
||||
expect(entities("Line")).toHaveLength(1);
|
||||
setSelectedEntityIds(lineIds());
|
||||
key("Escape");
|
||||
expect(getSelectedEntityIds()).toEqual([]);
|
||||
});
|
||||
|
||||
it("명령행 소문자 옵션(N6) — 선 c = 닫기", () => {
|
||||
type("l");
|
||||
type("0,0");
|
||||
type("10,0");
|
||||
type("10,10");
|
||||
driver().submitText("c");
|
||||
expect(entities("Line")).toHaveLength(3);
|
||||
});
|
||||
});
|
||||
|
||||
describe("명령 바꿈 · 되돌림 · 덧물음 · 투명(2-8 · 2-11 · N1 · N11)", () => {
|
||||
const drawSelected = () => {
|
||||
type("l");
|
||||
type("0,0");
|
||||
type("20,0");
|
||||
key("Escape");
|
||||
setSelectedEntityIds(lineIds());
|
||||
};
|
||||
|
||||
it("N1 — 이동 기준점 뒤 딴 명령 = 끊고(원본 되살림) 켬 · 선 1 개", () => {
|
||||
drawSelected();
|
||||
type("move");
|
||||
type("0,0");
|
||||
runCommandInput("CIRCLE");
|
||||
expect(activeTool()).toBe(Tool.CIRCLE);
|
||||
expect(entities("Line")).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("N1 — 이동 도중 Ctrl+Z = 끊고 뒤 되돌림 · 같은 id 두 벌 없음", () => {
|
||||
drawSelected();
|
||||
type("move");
|
||||
type("0,0");
|
||||
key("z", { ctrlKey: true, code: "KeyZ" });
|
||||
const ids = entities().map((entity) => entity.id);
|
||||
expect(new Set(ids).size).toBe(ids.length);
|
||||
expect(activeTool()).toBe(Tool.SELECT);
|
||||
});
|
||||
|
||||
it("쉴 때 U = UNDO · 명령 중 U 는 옵션일 때만", () => {
|
||||
type("l");
|
||||
type("0,0");
|
||||
type("10,0");
|
||||
key("Escape");
|
||||
expect(entities("Line")).toHaveLength(1);
|
||||
type("u");
|
||||
expect(entities("Line")).toHaveLength(0);
|
||||
start(polygonLike);
|
||||
type("u");
|
||||
expect(noticeLine()).toBe("숫자 값을 요구함.");
|
||||
});
|
||||
|
||||
it("덧물음 — 친 점 · 누른 점이 덧물음 먼저 · {point} = 원래 물음에", () => {
|
||||
const seen: Xy[] = [];
|
||||
type("l");
|
||||
pushSubPrompt({
|
||||
prompt: { text: "덧 점 지정" },
|
||||
kind: "point",
|
||||
onPoint: (point) => {
|
||||
seen.push({ x: point.x, y: point.y });
|
||||
return seen.length === 1 ? "stay" : { point };
|
||||
},
|
||||
});
|
||||
expect(promptLine()).toBe("덧 점 지정:");
|
||||
type("3,4");
|
||||
expect(promptLine()).toBe("덧 점 지정:"); // stay
|
||||
click(30, 40);
|
||||
expect(seen).toEqual([
|
||||
{ x: 3, y: 4 },
|
||||
{ x: 30, y: 40 },
|
||||
]);
|
||||
expect(toolState()).toBe("WAITING_FOR_END_POINT"); // (30, 40) 이 선 첫 점으로
|
||||
type("@10,0");
|
||||
const [line] = entities("Line") as unknown as Array<{ segment: { start: Xy; end: Xy } }>;
|
||||
expect(line.segment.start.x).toBeCloseTo(30, 3);
|
||||
expect(line.segment.end.x).toBeCloseTo(40, 3);
|
||||
});
|
||||
|
||||
it("투명 `'` — 도구형은 거절 · 즉시형은 도구 둔 채", () => {
|
||||
type("l");
|
||||
type("'line");
|
||||
expect(noticeLine()).toBe("「LINE」 명령은 투명하게 쓸 수 없음.");
|
||||
expect(activeTool()).toBe(Tool.LINE);
|
||||
routeTyped("'zoomin");
|
||||
routeTyped("'zoomout");
|
||||
expect(getLastCommandId()).toBe("ZOOMOUT");
|
||||
expect(activeTool()).toBe(Tool.LINE);
|
||||
});
|
||||
});
|
||||
|
||||
describe("Ctrl · Alt 글자(H1)", () => {
|
||||
it("Ctrl+C = COPYCLIP · Ctrl+글자 · Alt+글자는 쌓지 않음", () => {
|
||||
key("c", { ctrlKey: true, code: "KeyC" });
|
||||
expect(getLastCommandId()).toBe("COPYCLIP");
|
||||
expect(activeTool()).toBe(Tool.SELECT); // 원(C) 아님
|
||||
key("ㅌ", { ctrlKey: true, code: "KeyX" });
|
||||
expect(getLastCommandId()).toBe("CUTCLIP");
|
||||
key("l", { altKey: true });
|
||||
key("q", { ctrlKey: true, code: "KeyQ" });
|
||||
expect(inputText()).toBe("");
|
||||
typeEnter("l");
|
||||
expect(activeTool()).toBe(Tool.LINE);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user