diff --git a/B07_DesignDetail/openwebcad/src/commands/command-catalog.json b/B07_DesignDetail/openwebcad/src/commands/command-catalog.json index b430c9d19..634771019 100644 --- a/B07_DesignDetail/openwebcad/src/commands/command-catalog.json +++ b/B07_DesignDetail/openwebcad/src/commands/command-catalog.json @@ -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": []}, diff --git a/B07_DesignDetail/openwebcad/src/commands/commands.annotate.ts b/B07_DesignDetail/openwebcad/src/commands/commands.annotate.ts index e2583de55..0d76c9744 100644 --- a/B07_DesignDetail/openwebcad/src/commands/commands.annotate.ts +++ b/B07_DesignDetail/openwebcad/src/commands/commands.annotate.ts @@ -81,7 +81,7 @@ export const TEXT_COMMANDS: CadCommand[] = [ { id: 'TEXTEDIT', label: '문자 편집', - aliases: ['TEDIT'], + aliases: ['ED', 'TEDIT'], glyph: '✎', hint: '기존 문자의 내용을 고친다', tool: Tool.TEXTEDIT, diff --git a/B07_DesignDetail/openwebcad/src/commands/commands.file.ts b/B07_DesignDetail/openwebcad/src/commands/commands.file.ts index f5978a9bd..6c9992909 100644 --- a/B07_DesignDetail/openwebcad/src/commands/commands.file.ts +++ b/B07_DesignDetail/openwebcad/src/commands/commands.file.ts @@ -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 '다시 실행'; }, }, diff --git a/B07_DesignDetail/openwebcad/src/commands/commands.view.ts b/B07_DesignDetail/openwebcad/src/commands/commands.view.ts index 006dfb571..7550c0ba0 100644 --- a/B07_DesignDetail/openwebcad/src/commands/commands.view.ts +++ b/B07_DesignDetail/openwebcad/src/commands/commands.view.ts @@ -33,7 +33,6 @@ export const VIEW_COMMANDS: CadCommand[] = [ { id: 'SELECT', label: '선택', - aliases: ['SE'], glyph: '↖', hint: '객체를 클릭하거나 선택 사각형으로 고른다', tool: Tool.SELECT, diff --git a/B07_DesignDetail/openwebcad/src/commands/prompt.ts b/B07_DesignDetail/openwebcad/src/commands/prompt.ts new file mode 100644 index 000000000..dc38a47aa --- /dev/null +++ b/B07_DesignDetail/openwebcad/src/commands/prompt.ts @@ -0,0 +1,179 @@ +/** + * 물음 꼴(337 설계 2-3 · 2-4 · 2-9) — 오토캐드 명령행 한 줄 「글 또는 [이름(키)/…] <기본>:」 · + * 옵션 맞추기(initget 줄임 규칙) · 세션 기억(앞에 쓴 값이 다음 <기본>). + * 도구는 상태 `meta: { kind, prompt }` 로 물음을 선언하고 `OPTION.` 사건으로 옵션을 받는다. + * 이 파일은 상태(state.ts)를 읽지 않음 — 기억은 탭 수명 Map 하나. + */ +import type { StepKind } from '../tools/tool.types'; + +export type XY = { x: number; y: number }; + +/** 옵션 하나 — 그림 「label(kw)」 · 사건 `OPTION.` · 맞추기는 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.` */ + | { 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(); +let lastPicked: string[] = []; +let lastPointValue: XY | null = null; + +export const remember = (key: string, value: unknown): void => { + memory.set(key, value); +}; +export const recall = (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(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>:」 · 키워드 물음은 「또는」 없이 「옵션 입력 [..] :」 */ +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], +}; diff --git a/B07_DesignDetail/openwebcad/src/commands/registry.ts b/B07_DesignDetail/openwebcad/src/commands/registry.ts index 581c2716b..c64008db0 100644 --- a/B07_DesignDetail/openwebcad/src/commands/registry.ts +++ b/B07_DesignDetail/openwebcad/src/commands/registry.ts @@ -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) ); } diff --git a/B07_DesignDetail/openwebcad/src/commands/run-command.ts b/B07_DesignDetail/openwebcad/src/commands/run-command.ts index f8dae7a06..41bebbe94 100644 --- a/B07_DesignDetail/openwebcad/src/commands/run-command.ts +++ b/B07_DesignDetail/openwebcad/src/commands/run-command.ts @@ -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; } diff --git a/B07_DesignDetail/openwebcad/src/components/CommandLine.tsx b/B07_DesignDetail/openwebcad/src/components/CommandLine.tsx index 9d354a25a..c149ff424 100644 --- a/B07_DesignDetail/openwebcad/src/components/CommandLine.tsx +++ b/B07_DesignDetail/openwebcad/src/components/CommandLine.tsx @@ -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(null); const inputRef = useRef(null); const history = getCommandHistory(); - const instruction = getLastStateInstructions() || '명령을 입력하거나 도구를 선택하십시오.'; + const instruction = currentPrompt().line || '명령을 입력하거나 도구를 선택하십시오.'; const suggestions = useMemo(() => matchCommandPrefixes(text).slice(0, 8), [text]); diff --git a/B07_DesignDetail/openwebcad/src/inputController/ime-sink.test.ts b/B07_DesignDetail/openwebcad/src/inputController/ime-sink.test.ts index 47feaa2a7..89a1de161 100644 --- a/B07_DesignDetail/openwebcad/src/inputController/ime-sink.test.ts +++ b/B07_DesignDetail/openwebcad/src/inputController/ime-sink.test.ts @@ -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); }); }); diff --git a/B07_DesignDetail/openwebcad/src/inputController/ime-sink.ts b/B07_DesignDetail/openwebcad/src/inputController/ime-sink.ts index ae60c87a3..38c8c9bc2 100644 --- a/B07_DesignDetail/openwebcad/src/inputController/ime-sink.ts +++ b/B07_DesignDetail/openwebcad/src/inputController/ime-sink.ts @@ -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 diff --git a/B07_DesignDetail/openwebcad/src/inputController/input-controller.ts b/B07_DesignDetail/openwebcad/src/inputController/input-controller.ts index cf711aad0..a90301703 100644 --- a/B07_DesignDetail/openwebcad/src/inputController/input-controller.ts +++ b/B07_DesignDetail/openwebcad/src/inputController/input-controller.ts @@ -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 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(); } } diff --git a/B07_DesignDetail/openwebcad/src/inputController/input-shortcut.ts b/B07_DesignDetail/openwebcad/src/inputController/input-shortcut.ts index aad585a8c..f8fb80b06 100644 --- a/B07_DesignDetail/openwebcad/src/inputController/input-shortcut.ts +++ b/B07_DesignDetail/openwebcad/src/inputController/input-shortcut.ts @@ -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 & + Partial> +): 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; + } +} diff --git a/B07_DesignDetail/openwebcad/src/inputController/prompt-input.ts b/B07_DesignDetail/openwebcad/src/inputController/prompt-input.ts new file mode 100644 index 000000000..414d961bc --- /dev/null +++ b/B07_DesignDetail/openwebcad/src/inputController/prompt-input.ts @@ -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); + 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; + 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 = { + 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; + 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([]); +} diff --git a/B07_DesignDetail/openwebcad/src/state.ts b/B07_DesignDetail/openwebcad/src/state.ts index 559e75731..f243d5d1e 100644 --- a/B07_DesignDetail/openwebcad/src/state.ts +++ b/B07_DesignDetail/openwebcad/src/state.ts @@ -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: newToolActor: Actor, @@ -297,8 +302,14 @@ export const setActiveToolActor = ( activeToolActor.subscribe({ // biome-ignore lint/suspicious/noExplicitAny: next: (state: MachineSnapshot) => { - const stateInstructions = Object.values(state?.getMeta() as Record)[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)[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; diff --git a/B07_DesignDetail/openwebcad/src/tools/factories/end-command.ts b/B07_DesignDetail/openwebcad/src/tools/factories/end-command.ts new file mode 100644 index 000000000..31915b79f --- /dev/null +++ b/B07_DesignDetail/openwebcad/src/tools/factories/end-command.ts @@ -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)); +}); diff --git a/B07_DesignDetail/openwebcad/src/tools/factories/sequence-tool.ts b/B07_DesignDetail/openwebcad/src/tools/factories/sequence-tool.ts index 78596b9e8..2100d7062 100644 --- a/B07_DesignDetail/openwebcad/src/tools/factories/sequence-tool.ts +++ b/B07_DesignDetail/openwebcad/src/tools/factories/sequence-tool.ts @@ -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.` 사건(물음 `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.` 를 받았을 때 */ +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.` 처리 — keyword 단계는 없어도 고른 key 가 값 */ + options?: Record; } /** 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(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 = (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(index, '점', isPointValue), - points: () => values.filter((value) => isPointValue(value)) as Point[], - number: (index: number) => expect(index, '숫자', (v) => typeof v === 'number'), - text: (index: number) => expect(index, '문자', (v) => typeof v === 'string'), - entity: (index: number) => - expect(index, '객체', (v) => !!v && typeof (v as Entity).getType === 'function'), - entities: (index: number) => expect(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, cursor: Point): SequenceInput => { + const values = context.values ?? []; + const picks = context.picks ?? []; + const stepOf = context.stepOf ?? []; + const expect = (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(index, "점", isPointValue), + points: () => values.filter((value) => isPointValue(value)) as Point[], + number: (index: number) => expect(index, "숫자", (v) => typeof v === "number"), + text: (index: number) => expect(index, "문자", (v) => typeof v === "string"), + entity: (index: number) => + expect(index, "객체", (v) => !!v && typeof (v as Entity).getType === "function"), + entities: (index: number) => expect(index, "선택 목록", Array.isArray), + get: (id: string) => values[slot(id)] as T, + has: (id: string) => slot(id) >= 0, + mode: context.mode ?? null, + closed: !!context.closed, + }; + }; + const inputOf = (context: Partial) => + makeInput(context, lastPoint(context.values ?? []) ?? ORIGIN); + const promptOf = (index: number, context: Partial): 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): number => { + let at = index; + while (at <= lastIndex && steps[at].when && !steps[at].when?.(inputOf(context))) at++; + return at; + }; + const after = (index: number, context: Partial): 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 => { + 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 = { - [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 = { ESC: escTransition }; + const applyOption = (index: number) => + assign(({ context, event }: Ctx): Partial => { + 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 => { + 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 = { + [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 = { + 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) }, + }, + ); } diff --git a/B07_DesignDetail/openwebcad/src/tools/tool.types.ts b/B07_DesignDetail/openwebcad/src/tools/tool.types.ts index 757e8d395..22b61a2f8 100644 --- a/B07_DesignDetail/openwebcad/src/tools/tool.types.ts +++ b/B07_DesignDetail/openwebcad/src/tools/tool.types.ts @@ -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.`(설계 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; diff --git a/B07_DesignDetail/openwebcad/test/tools/eraser/eraser.recording.json b/B07_DesignDetail/openwebcad/test/tools/eraser/eraser.recording.json index 6c1761710..8e45f2f0b 100644 --- a/B07_DesignDetail/openwebcad/test/tools/eraser/eraser.recording.json +++ b/B07_DesignDetail/openwebcad/test/tools/eraser/eraser.recording.json @@ -44,6 +44,16 @@ "offsetY": 698, "offsetX": 473 }, + { + "type": "keyDown", + "target": "main", + "key": "Escape" + }, + { + "type": "keyUp", + "key": "Escape", + "target": "main" + }, { "type": "keyDown", "target": "main", diff --git a/resources/tester/cad/command-catalog.test.ts b/resources/tester/cad/command-catalog.test.ts index 69da00b2a..ab6736cdb 100644 --- a/resources/tester/cad/command-catalog.test.ts +++ b/resources/tester/cad/command-catalog.test.ts @@ -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"); + }); }); diff --git a/resources/tester/cad/helpers/cad-drive.ts b/resources/tester/cad/helpers/cad-drive.ts new file mode 100644 index 000000000..4a4418a34 --- /dev/null +++ b/resources/tester/cad/helpers/cad-drive.ts @@ -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> & { + 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)); diff --git a/resources/tester/cad/input-prefs-cad.test.ts b/resources/tester/cad/input-prefs-cad.test.ts index da03c5386..c6ad55a20 100644 --- a/resources/tester/cad/input-prefs-cad.test.ts +++ b/resources/tester/cad/input-prefs-cad.test.ts @@ -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, diff --git a/resources/tester/cad/keyboard-chain.test.ts b/resources/tester/cad/keyboard-chain.test.ts index 4f0004e26..eccaba6e8 100644 --- a/resources/tester/cad/keyboard-chain.test.ts +++ b/resources/tester/cad/keyboard-chain.test.ts @@ -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(); diff --git a/resources/tester/cad/prompt-contract.test.ts b/resources/tester/cad/prompt-contract.test.ts new file mode 100644 index 000000000..634b321d1 --- /dev/null +++ b/resources/tester/cad/prompt-contract.test.ts @@ -0,0 +1,65 @@ +/** + * 337 계약 시험(설계 2-16) — 모든 도구 머신의 평평한 상태가 물음 약속을 지키는지. + * ① `prompt.options` key 마다 `OPTION.` 또는 `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 } }; + +const rows = Object.entries(TOOL_STATE_MACHINES).flatMap(([tool, machine]) => + Object.entries((machine as { states: Record }).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; + 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([]); + }); +}); diff --git a/resources/tester/cad/prompt-frame.test.ts b/resources/tester/cad/prompt-frame.test.ts new file mode 100644 index 000000000..dfeeabc1a --- /dev/null +++ b/resources/tester/cad/prompt-frame.test.ts @@ -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("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)] :"); + 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("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); + }); +});