feat(CAD): 337-1 가-2 점 물음 보조 · ↑↓ 기록 · Tab 후보 — FROM · M2P · @ · # · * · 스냅 이름 늘림 · H11

- 새 inputController/input-history.ts — 쉴 때 = 친 명령 기록 · 명령 중 = 그 물음 최근 값 20 · 알림 줄 안 섞음(H11) · 후보 돌림
- 새 inputController/point-modifiers.ts — FROM(기준점 → <간격띄우기>) · M2P/MTP · @ = 마지막 점 · #·* 절대 좌표 · 스냅 이름
- 스냅 이름 — 세 글자 ~ 영문 전체(END · ENDP · ENDPOINT) · QUA · NOD 더함 · TAN · EXT · INS · APP · PAR · GCE 는 「이 CAD 에 없음」 안내(N14)
- 사다리 — 기준점 없는 첫 점 @dx,dy = 마지막 점 기준 · 옵션 영문 전체 딱 맞음은 옵션 먼저(CENTER) · 줄임은 스냅(CEN) · 덧물음 base
- ↑↓ = 기록 · 후보 · Shift+↑↓ = 화면 옮김 · Tab(쉼 + 친 글) = 후보 돌림 · 명령행도 같은 함수
- 시험 — prompt-frame 가-2 8 · keyboard-chain PLINE 첫 점 줄 손질

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QfHKQs7J3hQELKMWQMUJEy
This commit is contained in:
2026-10-16 00:04:30 +09:00
co-authored by Claude Opus 5.5
parent 83e246deb4
commit 8661501dae
8 changed files with 447 additions and 47 deletions
@@ -3,17 +3,20 @@ 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 { commandCandidate, recallInput } from '../inputController/input-history';
import { currentPrompt } from '../inputController/prompt-input';
import { getInputController } from '../state';
export const CommandLine: FC = () => {
const [text, setText] = useState('');
const [historyIndex, setHistoryIndex] = useState<number | null>(null);
/** Tab · ↑↓ 후보 돌림 — 처음 친 글 · 몇째(null = 안 돌림) */
const [pick, setPick] = useState<{ base: string; index: number } | null>(null);
const [fromHistory, setFromHistory] = useState(false);
const inputRef = useRef<HTMLInputElement>(null);
const history = getCommandHistory();
const instruction = currentPrompt().line || '명령을 입력하거나 도구를 선택하십시오.';
const suggestions = useMemo(() => matchCommandPrefixes(text).slice(0, 8), [text]);
const suggestions = useMemo(() => matchCommandPrefixes(pick?.base ?? text).slice(0, 8), [text, pick]);
const submit = (event: FormEvent) => {
event.preventDefault();
@@ -26,17 +29,28 @@ export const CommandLine: FC = () => {
// 진행 중인 명령이 문자·숫자를 기다리면 그쪽으로, 아니면 명령으로 해석된다
getInputController().submitText(value);
setText('');
setHistoryIndex(null);
setPick(null);
setFromHistory(false);
};
const recallHistory = (direction: -1 | 1) => {
if (!history.length) return;
const nextIndex =
historyIndex === null
? history.length - 1
: Math.min(history.length - 1, Math.max(0, historyIndex + direction));
setHistoryIndex(nextIndex);
setText(history[nextIndex].split(' ')[0]);
/** 후보 보이면 후보 돌림 · 아니면 친 명령 · 최근 값 기록(캔버스와 같은 함수 · 알림 줄 안 섞음) */
const recall = (direction: -1 | 1) => {
if (pick || (!fromHistory && text.trim() && suggestions.length)) {
cycle(direction);
return;
}
const value = recallInput(direction, text);
if (value === null) return;
setText(value);
setFromHistory(true);
};
const cycle = (direction: -1 | 1) => {
const base = pick?.base ?? text;
const index = pick ? pick.index + direction : direction > 0 ? 0 : -1;
const name = commandCandidate(base, index);
if (!name) return;
setPick({ base, index });
setText(name);
};
return (
@@ -53,7 +67,11 @@ export const CommandLine: FC = () => {
id="cad-command"
ref={inputRef}
value={text}
onChange={(event) => setText(event.target.value)}
onChange={(event) => {
setText(event.target.value);
setPick(null);
setFromHistory(false);
}}
onKeyDown={(event) => {
if (event.nativeEvent.isComposing) return; // 한글 조합 중 키는 IME 몫
if (event.key === ' ' && !activeToolWantsFreeText()) {
@@ -68,17 +86,13 @@ export const CommandLine: FC = () => {
event.preventDefault();
getInputController().handleEscapeKey();
}
if (event.key === 'ArrowUp') {
if (event.key === 'ArrowUp' || event.key === 'ArrowDown') {
event.preventDefault();
recallHistory(-1);
}
if (event.key === 'ArrowDown') {
event.preventDefault();
recallHistory(1);
recall(event.key === 'ArrowUp' ? -1 : 1);
}
if (event.key === 'Tab' && suggestions.length) {
event.preventDefault();
setText(suggestions[0].id);
cycle(1);
}
}}
placeholder="명령 입력 (예: L, LINE, REC, MOVE)"
@@ -41,6 +41,7 @@ import { cadCtrlCommand, ctrlShortcut } from './input-shortcut.ts';
import { wheelZoom } from './input-wheel.ts';
import { attachTouch } from './input-touch.ts';
import { NUMBER_REGEXP, type PointTextEvent } from './input-patterns.ts';
import { commandCandidate, pushInput, recallInput } from './input-history.ts';
import {
angleLockHint,
getAngleLock,
@@ -74,6 +75,10 @@ const DRAG_SELECT_MIN_PX = 4;
export class InputController {
private text = '';
/** ↑↓ · Tab 돌림 — 기록인가 후보인가 · 후보의 처음 친 글 · 몇째 */
private browsing: 'history' | 'candidates' | null = null;
private candidateBase = '';
private candidateIndex = 0;
/** 왼쪽 버튼을 누른 화면 좌표 — 놓을 때 끌었는지 판단한다 */
private leftPressLocation: Point | null = null;
public readonly ime = new ImeSink((data) => {
@@ -403,7 +408,12 @@ export class InputController {
return;
}
if (evt.key === 'Tab') {
// 점 단계(기준점 있음) = 동적 입력 칸 넘김 · 그 밖 = 브라우저 초점 이동
// 쉼 + 친 글 = 명령 후보 돌림 · 점 단계(기준점 있음) = 동적 입력 칸 넘김 · 그 밖 = 브라우저 초점 이동
if (isIdle() && this.text) {
evt.preventDefault();
this.cycleCandidate(1);
return;
}
if (!dynamicInputApplies()) return;
evt.preventDefault();
const isNumber = NUMBER_REGEXP.test(this.text);
@@ -440,6 +450,7 @@ export class InputController {
// Remove the last character from the input field
evt.preventDefault();
this.text = this.text.slice(0, this.text.length - 1);
this.browsing = null;
} else if (evt.key === 'Delete') {
// User wants to delete the current selection
evt.preventDefault();
@@ -459,6 +470,9 @@ export class InputController {
this.handleEnterKey(true);
this.ime.sync(activeToolWantsIme());
}
} else if ((evt.key === 'ArrowUp' || evt.key === 'ArrowDown') && !evt.shiftKey) {
// ↑↓ = 기록 · 최근 값 · 후보(오토캐드) — 화면 옮김은 Shift+↑↓ · ← →
this.recall(evt.key === 'ArrowUp' ? -1 : 1);
} else if (evt.key === 'ArrowDown') {
// Move the screen down
getScreenCanvasDrawController().setScreenOffset(this.getScreenPanStep('down', evt.shiftKey));
@@ -478,12 +492,40 @@ export class InputController {
// 글 단계(TEXT·MTEXT)는 친 그대로 — 명령 입력일 때만 대문자로 바꾼다(보이는 글만 · 맞추기는 prompt.ts).
// Ctrl · Alt · ⌘ 와 함께 누른 글자는 쌓지 않음(Ctrl+C → 원이 되던 것).
this.text += activeToolWantsFreeText() ? evt.key : evt.key.toUpperCase();
this.browsing = null;
}
}
/** ↑↓ — 후보 보일 때 = 후보 돌림 · 아니면 기록(쉼 = 친 명령 · 명령 중 = 그 물음 최근 값) */
private recall(dir: -1 | 1) {
const candidatesShown = isIdle() && !!this.text && !!commandCandidate(this.text, 0);
if (this.browsing === 'candidates' || (this.browsing === null && candidatesShown)) {
this.cycleCandidate(dir);
return;
}
const value = recallInput(dir, this.text);
if (value === null) return;
this.text = value;
this.browsing = 'history';
}
/** 명령 후보 돌림 — 처음 친 글로 시작하는 명령 이름을 차례로 친 글 자리에 */
private cycleCandidate(dir: -1 | 1) {
if (this.browsing !== 'candidates') {
this.candidateBase = this.text;
this.candidateIndex = dir > 0 ? -1 : 0;
}
const name = commandCandidate(this.candidateBase, this.candidateIndex + dir);
if (!name) return;
this.candidateIndex += dir;
this.text = name;
this.browsing = 'candidates';
}
/** Esc 한 번 = 명령 끝(오토캐드) — 친 글 · 조합 · 각도 잠금 · 한 번 스냅 · 덧물음도 함께 풂 */
public handleEscapeKey() {
this.text = '';
this.browsing = null;
this.ime.composing = '';
const boxing =
this.isSelectToolActive() &&
@@ -506,7 +548,12 @@ export class InputController {
if (this.text === '' || !isIdle()) {
return [];
}
return matchCommandPrefixes(this.text).slice(0, 6).map(describeCommand);
const picking = this.browsing === 'candidates';
const candidates = matchCommandPrefixes(picking ? this.candidateBase : this.text);
const picked = picking ? candidates.findIndex((command) => command.id === this.text) : -1;
return candidates
.slice(0, 6)
.map((command, index) => `${index === picked ? '▸ ' : ''}${describeCommand(command)}`);
}
/**
@@ -516,6 +563,8 @@ export class InputController {
public handleEnterKey(_repeatIfIdle = false) {
const text = this.text;
this.text = '';
this.browsing = null;
pushInput(text); // ↑↓ 기록(지금 물음 묶음 — 해석 전에)
routeTyped(text);
}
@@ -0,0 +1,52 @@
/**
* ↑↓ 기록(337 설계 2-12 · H11) — 쉴 때 = 친 명령 기록 · 명령 중 = 그 물음(`prompt.history` · 없으면 글)의 최근 값 20.
* 보이는 기록 줄(run-command 의 log · 알림 섞임)과 나눔. 캔버스 · 명령행이 같이 씀. 글 물음(TEXT 등)은 안 남김.
*/
import { matchCommandPrefixes, resolveCommandInput } from '../commands/registry';
import { currentPrompt } from './prompt-input';
const COMMANDS = '\u0000명령';
const LIMIT = { command: 50, value: 20 };
const groups = new Map<string, string[]>();
let browsing: { group: string; index: number } | null = null;
/** 지금 물음의 기록 묶음 · 글 물음 · 모름 = null */
function groupNow(): string | null {
const prompt = currentPrompt();
if (prompt.kind === 'idle') return COMMANDS;
if (prompt.kind === 'text' || prompt.kind === null) return null;
return prompt.spec?.history ?? prompt.spec?.text ?? prompt.line;
}
/** 제출한 글 하나를 지금 물음 기록에 — 쉴 때는 명령이 된 글만 */
export function pushInput(text: string): void {
browsing = null;
const value = text.trim();
const group = groupNow();
if (!value || !group) return;
if (group === COMMANDS && !resolveCommandInput(value)) return;
const list = groups.get(group) ?? [];
if (list[list.length - 1] !== value) list.push(value);
list.splice(0, Math.max(0, list.length - (group === COMMANDS ? LIMIT.command : LIMIT.value)));
groups.set(group, list);
}
/** ↑(-1) · ↓(+1) — 지금 물음 기록에서 한 칸 · 끝을 지나면 빈 글 · 기록 없으면 null(친 글 그대로) */
export function recallInput(dir: -1 | 1, _text: string): string | null {
const group = groupNow();
const list = group ? groups.get(group) : undefined;
if (!group || !list?.length) return null;
const from = browsing?.group === group ? browsing.index : list.length;
const index = Math.min(list.length, Math.max(0, from + dir));
browsing = { group, index };
return index === list.length ? '' : list[index];
}
/** 명령 후보 돌림(Tab · 후보 보일 때 ↑↓) — base 로 시작하는 명령 중 index 번째(돌아감) */
export function commandCandidate(base: string, index: number): string | null {
const candidates = matchCommandPrefixes(base);
if (!candidates.length) return null;
const n = candidates.length;
return candidates[((index % n) + n) % n].id;
}
@@ -1,5 +1,6 @@
/**
* 한 번 스냅 — 점 단계에서 END · MID · CEN · INT · PER · NEA · NON 을 치면 다음 한 점만 그 스냅(오토캐드).
* 한 번 스냅 — 점 단계에서 END · MID · CEN · QUA · INT · PER · NEA · NOD · NON 을 치면 다음 한 점만 그 스냅(오토캐드).
* 이름 = 앞 세 글자 ~ 영문 전체(END · ENDP · ENDPOINT) · 우리에 없는 스냅(TAN · EXT …)은 안내.
* F3(객체 스냅) 꺼져 있어도 먹음. 커서 둘레에 맞는 점이 없으면 클릭을 버리고 기다림 · Esc 로 풂.
*/
import type { Point } from '@flatten-js/core';
@@ -14,9 +15,11 @@ const SNAP_LABELS = {
END: '끝점',
MID: '중간점',
CEN: '중심',
QUA: '사분점',
INT: '교차점',
PER: '수직',
NEA: '근처점',
NOD: '노드',
NON: '스냅 없음',
} as const;
type SnapName = keyof typeof SNAP_LABELS;
@@ -25,15 +28,52 @@ const SNAP_TYPES: Partial<Record<SnapName, SnapPointType>> = {
END: SnapPointType.LineEndPoint,
MID: SnapPointType.LineMidPoint,
CEN: SnapPointType.CircleCenter,
QUA: SnapPointType.CircleCardinal,
NOD: SnapPointType.Point,
};
/** 오토캐드 영문 전체 이름 — 세 글자 줄임부터 받음 */
const FULL_NAMES: Record<SnapName, string> = {
END: 'ENDPOINT',
MID: 'MIDPOINT',
CEN: 'CENTER',
QUA: 'QUADRANT',
INT: 'INTERSECTION',
PER: 'PERPENDICULAR',
NEA: 'NEAREST',
NOD: 'NODE',
NON: 'NONE',
};
/** 오토캐드엔 있고 우리엔 없는 스냅 */
const ABSENT_NAMES: Record<string, string> = {
TAN: 'TANGENT',
EXT: 'EXTENSION',
INS: 'INSERTION',
APP: 'APPINT',
PAR: 'PARALLEL',
GCE: 'GCEN',
};
const byName = <K extends string>(table: Record<K, string>, text: string): K | null => {
const typed = text.trim().toUpperCase().replace(/^_/, '');
if (typed.length < 3) return null;
const hit = (Object.keys(table) as K[]).find(
(short) => typed.startsWith(short) && table[short].startsWith(typed)
);
return hit ?? null;
};
/** 친 글 → 스냅 이름(END · ENDP · ENDPOINT → END) · 아니면 null */
export const snapName = (text: string): SnapName | null => byName(FULL_NAMES, text);
/** 우리에 없는 오토캐드 스냅 이름(세 글자 꼴) · 아니면 null */
export const absentSnapName = (text: string): string | null => byName(ABSENT_NAMES, text);
let override: SnapName | null = null;
export const getSnapOverride = (): SnapName | null => override;
export const setSnapOverride = (name: SnapName | null) => {
override = name;
};
export const isSnapName = (text: string): text is SnapName => text in SNAP_LABELS;
export const isSnapName = (text: string): text is SnapName => snapName(text) === text;
/** 커서 옆 안내 줄 */
export const snapOverrideHint = (): string | null =>
@@ -0,0 +1,83 @@
/**
* 점 물음 보조(337 설계 1-4 · 2-5 ④ · 가-2) — 한 번 스냅 이름 · FROM · M2P(MTP) · `@`(마지막 점) · `#` · `*`(절대 좌표).
* FROM · M2P 는 덧물음 두 개를 잇고 끝에 고른 점을 원래 물음에 넘김(도구 머신은 모름 → 투명).
*/
import { Point } from '@flatten-js/core';
import { lastPoint, normalizeKeyword } from '../commands/prompt';
import { ActorEvent } from '../tools/tool.types';
import { parsePointText } from './input-patterns';
import { absentSnapName, setSnapOverride, snapName } from './input-snap-override';
import { distanceCursor, notice, pushSubPrompt, sendPoint } from './prompt-input';
const point = (x: number, y: number) => new Point(x, y);
/** FROM — 「기준점:」 → 「<간격띄우기>:」(`@dx,dy` · 거리 = 기준점에서 커서 쪽) */
function startFrom() {
pushSubPrompt({
prompt: { text: '기준점' },
kind: 'point',
onPoint: (base) => {
pushSubPrompt({
prompt: { text: '<간격띄우기>' },
kind: 'point',
base,
onPoint: (target) => ({ point: target }),
});
return 'done';
},
});
}
/** M2P · MTP — 두 점의 가운데 */
function startMidBetween() {
pushSubPrompt({
prompt: { text: '중간점의 첫 번째 점' },
kind: 'point',
onPoint: (first) => {
pushSubPrompt({
prompt: { text: '중간점의 두 번째 점' },
kind: 'point',
base: first,
onPoint: (second) => ({ point: point((first.x + second.x) / 2, (first.y + second.y) / 2) }),
});
return 'done';
},
});
}
/** 점 물음에서 친 글이 보조면 처리하고 true · 아니면 false(옵션 · 좌표 차례로) */
export function tryPointModifier(text: string): boolean {
const typed = normalizeKeyword(text);
const snap = snapName(typed);
if (snap) {
setSnapOverride(snap);
return true;
}
const absent = absentSnapName(typed);
if (absent) {
notice(`「${absent}」 스냅은 이 CAD 에 없음.`);
return true;
}
if (typed === 'FROM' || typed === 'FRO') {
startFrom();
return true;
}
if (typed === 'M2P' || typed === 'MTP') {
startMidBetween();
return true;
}
if (typed === '@') {
const last = lastPoint();
if (!last) return false;
sendPoint({ type: ActorEvent.ABSOLUTE_POINT_INPUT, value: point(last.x, last.y) });
return true;
}
if (typed.startsWith('#') || typed.startsWith('*')) {
// 절대 좌표 강제 — `#x,y` · `*d<a`
const event = parsePointText(typed.slice(1), distanceCursor);
if (event?.type !== ActorEvent.ABSOLUTE_POINT_INPUT) return false;
sendPoint(event);
return true;
}
return false;
}
@@ -49,13 +49,16 @@ import {
parsePointText,
type PointTextEvent,
} from './input-patterns';
import { isSnapName, setSnapOverride } from './input-snap-override';
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;
@@ -75,9 +78,11 @@ export const closeSubPrompt = (): void => {
subs.length = 0;
};
function settle(result: SubResult | undefined) {
/** 덧물음 sub 의 답 — 'done' · {point} 면 그 덧물음만 닫음(답 안에서 다음 덧물음을 열어도 됨) */
function settle(sub: SubPrompt, result: SubResult | undefined) {
if (!result || result === 'stay') return;
subs.pop();
const at = subs.lastIndexOf(sub);
if (at >= 0) subs.splice(at, 1);
if (result !== 'done') sendPoint({ type: ActorEvent.ABSOLUTE_POINT_INPUT, value: result.point });
}
@@ -181,21 +186,32 @@ export const distanceEvent = (value: number): PointTextEvent => ({
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.onNumber(event.value));
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.onPoint?.(event.worldMouseLocation));
settle(sub, 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)));
if (base) settle(sub, sub.onPoint?.(new Point(base.x + event.value.x, base.y + event.value.y)));
else reject(currentPrompt());
} else {
settle(sub.onPoint?.(event.value));
settle(sub, sub.onPoint?.(event.value));
}
} else {
getActiveToolActor()?.send(event);
@@ -206,13 +222,13 @@ export function sendPoint(event: PointTextEvent | MouseClickEvent): void {
function sendOption(key: string) {
const sub = subPrompt();
if (sub) settle(sub.onOption?.(key));
if (sub) settle(sub, sub.onOption?.(key));
else send({ type: `OPTION.${key}` });
}
function sendEnter() {
const sub = subPrompt();
if (sub) settle(sub.onEnter?.());
if (sub) settle(sub, sub.onEnter?.());
else send({ type: ActorEvent.ENTER });
}
@@ -275,7 +291,8 @@ function routeEmpty(prompt: CurrentPrompt) {
if (fallback?.kind === 'value') {
const value = defaultValue(fallback);
if (typeof value === 'number') {
if (subPrompt()?.onNumber) settle(subPrompt()?.onNumber?.(value));
const sub = subPrompt();
if (sub?.onNumber) settle(sub, sub.onNumber(value));
else sendPoint(distanceEvent(value));
return;
}
@@ -316,12 +333,7 @@ function routePointAid(text: string): boolean {
setAngleLock(Number.parseFloat(lock[1]));
return true;
}
const upper = normalizeKeyword(text);
if (isSnapName(upper)) {
setSnapOverride(upper);
return true;
}
return false;
return tryPointModifier(text); // 스냅 이름 · FROM · M2P · @ · # · *
}
/** ④ 좌표 · 거리 */
@@ -329,6 +341,13 @@ 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);
@@ -344,7 +363,7 @@ function routeNumber(text: string, prompt: CurrentPrompt) {
const problem = numberProblem(value, prompt.spec?.number);
if (problem) return reject(prompt, problem);
const sub = subPrompt();
if (sub) settle(sub.onNumber?.(value));
if (sub) settle(sub, sub.onNumber?.(value));
else send({ type: ActorEvent.NUMBER_INPUT, value, worldMouseLocation: distanceCursor() });
}
@@ -365,9 +384,12 @@ export function routeTyped(raw: string): void {
else notice(`알 수 없는 명령 「${normalizeKeyword(text)}」.`);
return;
}
if (prompt.kind === 'point' && routePointAid(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 에 없음.`);
+2 -3
View File
@@ -137,15 +137,14 @@ describe("331-1 각도 고정 → 길이", () => {
expectXy(lines()[1].end, -10, -0.5);
});
it("PLINE 첫 점에 숫자 · @x,y → 무시 · 도구 살아 있음 · 알림 0", () => {
it("PLINE 첫 점에 숫자 → 거절 · 도구 살아 있음 · 알림 0(첫 점 @x,y = 마지막 점 기준 — prompt-frame)", () => {
type(controller, "pline");
type(controller, "100");
type(controller, "@10,0");
const snapshot = getActiveToolActor()?.getSnapshot();
expect(snapshot?.status).toBe("active");
expect(snapshot?.value).toBe("STEP_0");
expect(toast.error).not.toHaveBeenCalled();
expect(noticeLine()).toBe("점 또는 옵션 키워드를 요구함."); // 기준점 없는 거리 · 상대 좌표 = 거절
expect(noticeLine()).toBe("점 또는 옵션 키워드를 요구함."); // 기준점 없는 거리 = 거절
type(controller, "0,0");
type(controller, "<0");
type(controller, "100");
+142 -1
View File
@@ -1,5 +1,5 @@
/**
* 337 가-1 옵션 틀 — 물음 꼴 · 옵션 맞추기 · 친 글 사다리 · 거절 · Esc · 빈 Enter · 우클릭 · 명령 끝 · 엔진 v2 · 기억 · 덧물음 · Ctrl.
* 337 가-1 · 가-2 옵션 틀 — 물음 꼴 · 옵션 맞추기 · 친 글 사다리 · 거절 · 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
*/
@@ -35,6 +35,9 @@ import {
setActiveToolActor,
setSelectedEntityIds,
} from "../../../B07_DesignDetail/openwebcad/src/state";
import { commandCandidate } from "../../../B07_DesignDetail/openwebcad/src/inputController/input-history";
import { getSnapOverride } from "../../../B07_DesignDetail/openwebcad/src/inputController/input-snap-override";
import { getScreenCanvasDrawController } 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 {
@@ -556,3 +559,141 @@ describe("Ctrl · Alt 글자(H1)", () => {
expect(activeTool()).toBe(Tool.LINE);
});
});
// ---- 가-2 — ↑↓ · Tab · FROM · M2P · @ · # · 스냅 이름 · H11 ----
const segs = () =>
(entities("Line") as unknown as Array<{ segment: { start: Xy; end: Xy } }>).map(
(line) => line.segment,
);
const expectSeg = (seg: { start: Xy; end: Xy }, x1: number, y1: number, x2: number, y2: number) => {
expect(
[seg.start.x, seg.start.y, seg.end.x, seg.end.y].map((v) => Math.round(v * 1000) / 1000),
).toEqual([x1, y1, x2, y2]);
};
describe("가-2 ↑↓ 기록 · 최근 값 · 후보(2-12 · H11)", () => {
it("쉴 때 ↑↓ = 친 명령만(알 수 없는 글 · 알림 줄 안 섞임) · 끝을 지나면 빈 글", () => {
type("l");
key("Escape");
type("zz"); // 알 수 없는 명령 — 기록 안 됨
type("circle");
key("Escape");
key("ArrowUp");
expect(inputText()).toBe("CIRCLE");
key("ArrowUp");
expect(inputText()).toBe("L");
key("ArrowDown");
expect(inputText()).toBe("CIRCLE");
key("ArrowDown");
expect(inputText()).toBe("");
});
it("명령 중 ↑ = 그 물음 최근 값", () => {
start(polygonLike);
type("7");
key("Escape");
start(polygonLike);
key("ArrowUp");
expect(inputText()).toBe("7");
key("Escape");
});
it("Tab · ↑↓ = 후보 돌림(처음 친 글 기준) · Shift+↑ = 화면 옮김", () => {
typed("ci");
key("Tab");
expect(inputText()).toBe(commandCandidate("CI", 0));
key("Tab");
expect(inputText()).toBe(commandCandidate("CI", 1));
key("ArrowUp");
expect(inputText()).toBe(commandCandidate("CI", 0));
key("Escape");
const before = getScreenCanvasDrawController().getScreenOffset().y;
key("ArrowUp", { shiftKey: true });
expect(getScreenCanvasDrawController().getScreenOffset().y).not.toBe(before);
key("ArrowDown", { shiftKey: true });
});
});
describe("가-2 점 물음 보조(1-4)", () => {
it("FROM — 기준점 · <간격띄우기> @dx,dy → 그 점이 원래 물음에", () => {
type("l");
type("from");
expect(promptLine()).toBe("기준점:");
type("10,10");
expect(promptLine()).toBe("<간격띄우기>:");
type("@5,0");
type("@10,0");
expectSeg(segs()[0], 15, 10, 25, 10);
});
it("M2P — 두 점의 가운데", () => {
type("l");
type("m2p");
expect(promptLine()).toBe("중간점의 첫 번째 점:");
type("0,0");
expect(promptLine()).toBe("중간점의 두 번째 점:");
type("10,0");
type("@0,5");
expectSeg(segs()[0], 5, 0, 5, 5);
});
it("@ = 마지막 점 · 기준점 없는 첫 점 @dx,dy = 마지막 점 기준 · # = 절대", () => {
type("l");
type("0,0");
type("10,0");
key("Escape");
type("l");
type("@");
type("@0,10");
expectSeg(segs()[1], 10, 0, 10, 10);
key("Escape");
type("l");
type("@1,1"); // 마지막 점 (10, 10) 기준
type("#0,0");
expectSeg(segs()[2], 11, 11, 0, 0);
});
it("스냅 이름 — 세 글자~영문 전체 · QUA · NOD · 없는 스냅 안내 · 두 글자는 아님", () => {
type("l");
for (const [text, snap] of [
["endp", "END"],
["endpoint", "END"],
["qua", "QUA"],
["node", "NOD"],
["_non", "NON"],
]) {
type(text);
expect(getSnapOverride()).toBe(snap);
}
type("tangent");
expect(noticeLine()).toBe("「TAN」 스냅은 이 CAD 에 없음.");
type("ext");
expect(noticeLine()).toBe("「EXT」 스냅은 이 CAD 에 없음.");
expect(activeTool()).toBe(Tool.LINE); // EXT · INS 가 명령으로 안 돎(N14)
key("Escape");
expect(getSnapOverride()).toBeNull();
});
it("옵션 영문 전체 = 옵션 · 줄임 CEN = 스냅", () => {
const seen: string[] = [];
const tool = createSequenceTool({
tool: Tool.DIST,
steps: [
{
kind: "point",
prompt: { text: "호의 두 번째 점 지정", options: [opt("CENTER", "C", "중심")] },
options: { CENTER: { run: () => seen.push("CENTER") } },
},
],
commit: () => {},
});
start(tool);
type("cen");
expect(getSnapOverride()).toBe("CEN");
expect(seen).toEqual([]);
type("center");
expect(seen).toEqual(["CENTER"]);
key("Escape");
});
});