diff --git a/B07_DesignDetail/openwebcad/src/components/CommandLine.tsx b/B07_DesignDetail/openwebcad/src/components/CommandLine.tsx index c149ff424..5d878f70d 100644 --- a/B07_DesignDetail/openwebcad/src/components/CommandLine.tsx +++ b/B07_DesignDetail/openwebcad/src/components/CommandLine.tsx @@ -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(null); + /** Tab · ↑↓ 후보 돌림 — 처음 친 글 · 몇째(null = 안 돌림) */ + const [pick, setPick] = useState<{ base: string; index: number } | null>(null); + const [fromHistory, setFromHistory] = useState(false); const inputRef = useRef(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)" diff --git a/B07_DesignDetail/openwebcad/src/inputController/input-controller.ts b/B07_DesignDetail/openwebcad/src/inputController/input-controller.ts index 1e98d3d86..1dc4c6033 100644 --- a/B07_DesignDetail/openwebcad/src/inputController/input-controller.ts +++ b/B07_DesignDetail/openwebcad/src/inputController/input-controller.ts @@ -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); } diff --git a/B07_DesignDetail/openwebcad/src/inputController/input-history.ts b/B07_DesignDetail/openwebcad/src/inputController/input-history.ts new file mode 100644 index 000000000..1067864fd --- /dev/null +++ b/B07_DesignDetail/openwebcad/src/inputController/input-history.ts @@ -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(); +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; +} diff --git a/B07_DesignDetail/openwebcad/src/inputController/input-snap-override.ts b/B07_DesignDetail/openwebcad/src/inputController/input-snap-override.ts index 276f017c5..ad5ba7d74 100644 --- a/B07_DesignDetail/openwebcad/src/inputController/input-snap-override.ts +++ b/B07_DesignDetail/openwebcad/src/inputController/input-snap-override.ts @@ -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> = { END: SnapPointType.LineEndPoint, MID: SnapPointType.LineMidPoint, CEN: SnapPointType.CircleCenter, + QUA: SnapPointType.CircleCardinal, + NOD: SnapPointType.Point, }; +/** 오토캐드 영문 전체 이름 — 세 글자 줄임부터 받음 */ +const FULL_NAMES: Record = { + END: 'ENDPOINT', + MID: 'MIDPOINT', + CEN: 'CENTER', + QUA: 'QUADRANT', + INT: 'INTERSECTION', + PER: 'PERPENDICULAR', + NEA: 'NEAREST', + NOD: 'NODE', + NON: 'NONE', +}; +/** 오토캐드엔 있고 우리엔 없는 스냅 */ +const ABSENT_NAMES: Record = { + TAN: 'TANGENT', + EXT: 'EXTENSION', + INS: 'INSERTION', + APP: 'APPINT', + PAR: 'PARALLEL', + GCE: 'GCEN', +}; + +const byName = (table: Record, 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 => diff --git a/B07_DesignDetail/openwebcad/src/inputController/point-modifiers.ts b/B07_DesignDetail/openwebcad/src/inputController/point-modifiers.ts new file mode 100644 index 000000000..e9ee029e4 --- /dev/null +++ b/B07_DesignDetail/openwebcad/src/inputController/point-modifiers.ts @@ -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 { 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 에 없음.`); diff --git a/resources/tester/cad/keyboard-chain.test.ts b/resources/tester/cad/keyboard-chain.test.ts index eccaba6e8..51c75f739 100644 --- a/resources/tester/cad/keyboard-chain.test.ts +++ b/resources/tester/cad/keyboard-chain.test.ts @@ -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"); diff --git a/resources/tester/cad/prompt-frame.test.ts b/resources/tester/cad/prompt-frame.test.ts index dfeeabc1a..986366abc 100644 --- a/resources/tester/cad/prompt-frame.test.ts +++ b/resources/tester/cad/prompt-frame.test.ts @@ -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"); + }); +});