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
@@ -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 에 없음.`);