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:
@@ -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 에 없음.`);
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user