/** * 친 글 사다리 한 곳(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 { setSnapOverride } from './input-snap-override'; import { tryPointModifier } from './point-modifiers'; // ---- 덧물음(설계 2-11) ---- export interface SubPrompt { prompt: PromptSpec; kind: StepKind; /** 상대 좌표 · 거리의 기준(FROM 간격띄우기 · M2P 둘째 점) — 없으면 지금 기준점 */ base?: Point; 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; }; /** 덧물음 sub 의 답 — 'done' · {point} 면 그 덧물음만 닫음(답 안에서 다음 덧물음을 열어도 됨) */ function settle(sub: SubPrompt, result: SubResult | undefined) { if (!result || result === 'stay') return; const at = subs.lastIndexOf(sub); if (at >= 0) subs.splice(at, 1); 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(); /** base 에서 to 쪽으로 distance 간 점 */ const toward = (base: Point, to: Point, distance: number): Point => { const length = Math.hypot(to.x - base.x, to.y - base.y) || 1; return new Point( base.x + ((to.x - base.x) / length) * distance, base.y + ((to.y - base.y) / length) * distance ); }; /** 점 하나 — 덧물음 먼저(마우스 누름도) · 각도 고정 · 한 번 스냅은 이 한 점으로 끝 */ export function sendPoint(event: PointTextEvent | MouseClickEvent): void { const sub = subPrompt(); if (sub) { const last = lastPoint(); const base = sub.base ?? getAngleGuideOriginPoint() ?? (last && new Point(last.x, last.y)); if (event.type === ActorEvent.NUMBER_INPUT) { if (sub.onNumber) settle(sub, sub.onNumber(event.value)); else if (base) settle(sub, sub.onPoint?.(toward(base, event.worldMouseLocation, event.value))); else reject(currentPrompt()); } else if (event.type === ActorEvent.MOUSE_CLICK) { settle(sub, sub.onPoint?.(event.worldMouseLocation)); } else if (event.type === ActorEvent.RELATIVE_POINT_INPUT) { if (base) settle(sub, sub.onPoint?.(new Point(base.x + event.value.x, base.y + event.value.y))); else reject(currentPrompt()); } else { settle(sub, sub.onPoint?.(event.value)); } } else { getActiveToolActor()?.send(event); } releaseOnePoint(); setSnapOverride(null); } function sendOption(key: string) { const sub = subPrompt(); if (sub) settle(sub, sub.onOption?.(key)); else send({ type: `OPTION.${key}` }); } function sendEnter() { const sub = subPrompt(); if (sub) settle(sub, 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') { const sub = subPrompt(); if (sub?.onNumber) settle(sub, sub.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; } return tryPointModifier(text); // 스냅 이름 · FROM · M2P · @ · # · * } /** ④ 좌표 · 거리 */ function routePointText(text: string, prompt: CurrentPrompt) { const event = parsePointText(text, distanceCursor); if (!event) return reject(prompt); const hasBase = !!getAngleGuideOriginPoint() || !!subPrompt(); if (event.type === ActorEvent.RELATIVE_POINT_INPUT && !hasBase) { // 기준점 없는 첫 점 `@dx,dy` = 마지막 점(LASTPOINT) 기준 const last = lastPoint(); if (!last) return reject(prompt); const value = new Point(last.x + event.value.x, last.y + event.value.y); return sendPoint({ type: ActorEvent.ABSOLUTE_POINT_INPUT, value }); } 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, 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; } const hit = matchOption(text, prompt.spec); // 옵션 kw · 영문 전체와 딱 같으면 옵션 먼저(ARC 「CENTER」) · 줄임은 스냅 먼저(「CEN」 = 스냅) const exact = hit && 'option' in hit && [hit.option.kw, hit.option.en].includes(normalizeKeyword(text)); if (prompt.kind === 'point' && !exact && routePointAid(text)) return; // ⑤ 옵션(숨김 포함) · 없는 옵션 안내 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([]); }