From 66279024d88da7e66b76938e5377db378f4bb7f7 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Fri, 16 Oct 2026 00:11:53 +0900 Subject: [PATCH] =?UTF-8?q?feat(B07=20CAD):=20337-2=20=EB=A7=88-2=20ZOOM?= =?UTF-8?q?=20=EB=8D=A7=EB=AC=BC=EC=9D=8C=20=C2=B7=20Shift+=EC=9A=B0?= =?UTF-8?q?=ED=81=B4=EB=A6=AD=20=EC=8A=A4=EB=83=85=20=EB=A9=94=EB=89=B4=20?= =?UTF-8?q?=C2=B7=20=EA=B3=A0=EB=A5=B4=EA=B8=B0=20ALL/L/P=20=C2=B7=20?= =?UTF-8?q?=EA=B7=B8=EB=A6=BD=20=EC=A2=85=EB=A3=8C(X)=20=C2=B7=20=E2=86=B6?= =?UTF-8?q?=E2=86=B7=20=EB=81=8A=EA=B3=A0=20=EB=90=98=EB=8F=8C=EB=A6=BC=20?= =?UTF-8?q?=C2=B7=20snapped=20=EC=BA=90=EC=8A=A4=ED=8A=B8=20=EC=A7=80?= =?UTF-8?q?=EC=9B=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - ZOOM(Z): 「윈도우 구석 지정, 축척 비율 (nX 또는 nXP) 입력 또는 [전체(A)/범위(E)/이전(P)/윈도우(W)] <실시간>:」 덧물음 · E · A = 범위 · P = 앞 화면 · W · 누름 = 두 구석 윈도우 · 2X = 배율 곱 · 빈 Enter = 실시간 안내 · 중심 · 동적 · 축척 · 객체 = 없음 안내 · 'Z 투명 · ⛶ 단추 = 바로 범위 - 약속 더함: SubPrompt.onText(꼴이 정해지지 않은 글 · nX) — routeTyped 가 덧물음에 먼저 물음 · undefined = 사다리 그대로(브레인 알림) - Shift+우클릭: 점 물음에서만 한 번 스냅 메뉴(시작점에서 · 두 점 사이의 중간 · 끝점 · 중간점 · 교차점 · 중심 · 사분점 · 수직 · 노드 · 근처점 · 없음) → 고른 이름을 친 것과 같은 길 · Esc · 바깥 누름 = 메뉴만 닫음(ic/snap-menu.ts) - 고르기 숨은 키워드 ALL · L · P(선택 도구 첫 점 대기 · 쉼 · 명령 안 자식) — 앞 고름에 더함 · 「n개를 찾음, 총 m개」 - 그립 물음 새 꼴 「** 신축 ** 신축점 지정 또는 [종료(X)]:」 · X = 놓음 · 기준점(B) · 복사(C) · 명령 취소(U) 없음 안내 - ↶ ↷ 단추 = undoCommand · redoCommand(끊고 되돌림) · 우클릭 = 명령 중 Enter · 쉴 때 반복(가-1 길 · 시험) - 시험: mouse-acad 마-2 더함(21) Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01Se7ytqbohmBQnfYpwYFnYf --- .../openwebcad/src/commands/commands.view.ts | 8 +- .../src/components/ViewControls.tsx | 5 +- .../src/helpers/get-point-from-event.ts | 2 +- .../src/inputController/input-controller.ts | 9 +- .../src/inputController/prompt-input.ts | 4 + .../src/inputController/selecting.ts | 5 + .../src/inputController/snap-menu.css | 29 ++++ .../src/inputController/snap-menu.ts | 76 +++++++++ .../src/inputController/zoom-prompt.ts | 114 ++++++++++++- .../openwebcad/src/tools/grip-edit-tool.ts | 24 ++- .../src/tools/select-tool.helpers.ts | 21 +++ .../openwebcad/src/tools/select-tool.ts | 5 + resources/tester/cad/mouse-acad.test.ts | 154 +++++++++++++++++- 13 files changed, 438 insertions(+), 18 deletions(-) create mode 100644 B07_DesignDetail/openwebcad/src/inputController/snap-menu.css create mode 100644 B07_DesignDetail/openwebcad/src/inputController/snap-menu.ts diff --git a/B07_DesignDetail/openwebcad/src/commands/commands.view.ts b/B07_DesignDetail/openwebcad/src/commands/commands.view.ts index 7550c0ba0..044259c38 100644 --- a/B07_DesignDetail/openwebcad/src/commands/commands.view.ts +++ b/B07_DesignDetail/openwebcad/src/commands/commands.view.ts @@ -6,7 +6,7 @@ import { bumpSceneVersion } from '../helpers/scene-version'; import { getScreenCanvasDrawController, setEntities, getEntities } from '../state'; import { Tool } from '../tools'; import { selectToolStateMachine } from '../tools/select-tool'; -import { zoomExtents } from '../inputController/zoom-prompt'; +import { startZoomPrompt } from '../inputController/zoom-prompt'; const zoomBy = (factor: number): string => { const controller = getScreenCanvasDrawController(); @@ -43,10 +43,10 @@ export const VIEW_COMMANDS: CadCommand[] = [ label: '범위 줌', aliases: ['Z'], glyph: '⛶', - hint: '도면 전체가 보이도록 화면을 맞춘다', + hint: '범위(E) · 전체(A) · 이전(P) · 윈도우(W) · 2X — 오토캐드 ZOOM(⛶ 단추 = 바로 범위)', run: () => { - zoomExtents(); // 앞 화면 기억(이전(P)) - return '도면 전체 보기'; + startZoomPrompt(); // 덧물음 — 명령 중 'Z 도 같은 길 + return ''; }, }, { diff --git a/B07_DesignDetail/openwebcad/src/components/ViewControls.tsx b/B07_DesignDetail/openwebcad/src/components/ViewControls.tsx index 75d0baa20..679be9f7b 100644 --- a/B07_DesignDetail/openwebcad/src/components/ViewControls.tsx +++ b/B07_DesignDetail/openwebcad/src/components/ViewControls.tsx @@ -1,6 +1,7 @@ /** 화면 우하단 탐색 막대 (줌 표시·전체 보기) — 제목줄이 없는 화면(M02)은 되돌리기 · 다시 하기도 */ import type { FC } from 'react'; import { runCommandInput } from '../commands/run-command'; +import { redoCommand, undoCommand } from '../inputController/prompt-input'; import { zoomExtents } from '../inputController/zoom-prompt'; import { getScreenCanvasDrawController, @@ -18,10 +19,10 @@ export const ViewControls: FC = () => {
{showUndo && ( <> - - diff --git a/B07_DesignDetail/openwebcad/src/helpers/get-point-from-event.ts b/B07_DesignDetail/openwebcad/src/helpers/get-point-from-event.ts index e715c829f..1bebb63db 100644 --- a/B07_DesignDetail/openwebcad/src/helpers/get-point-from-event.ts +++ b/B07_DesignDetail/openwebcad/src/helpers/get-point-from-event.ts @@ -29,7 +29,7 @@ export function getPointFromEvent(startPoint: Point | null, event: PointInputEve : mouse; } if (event.type === ActorEvent.MOUSE_CLICK) { - const clickEvent = event as MouseClickEvent & { snapped?: boolean }; + const clickEvent = event as MouseClickEvent; const click = clickEvent.worldMouseLocation; if (locked) return projectOnLock(startPoint, click); return startPoint && !clickEvent.snapped && orthoOn(!!clickEvent.holdingShift) diff --git a/B07_DesignDetail/openwebcad/src/inputController/input-controller.ts b/B07_DesignDetail/openwebcad/src/inputController/input-controller.ts index a90301703..0268bfcf7 100644 --- a/B07_DesignDetail/openwebcad/src/inputController/input-controller.ts +++ b/B07_DesignDetail/openwebcad/src/inputController/input-controller.ts @@ -38,6 +38,7 @@ import { cadCtrlCommand, ctrlShortcut } from './input-shortcut.ts'; import { wheelZoom } from './input-wheel.ts'; import { isObjectSnap, noteShift, snapAt } from './ortho.ts'; import { isWaitingForSecondSelectPoint, selectingActor } from './selecting.ts'; +import { openSnapMenu } from './snap-menu.ts'; import { isMiddleDouble, zoomExtents } from './zoom-prompt.ts'; import { attachTouch } from './input-touch.ts'; import { NUMBER_REGEXP, type PointTextEvent } from './input-patterns.ts'; @@ -163,10 +164,14 @@ export class InputController { public handleMouseUp(evt: MouseEvent) { if (evt.button === MouseButton.Right) { - // 우클릭 = Enter(명령 중 확정 · 쉴 때 반복) + // 우클릭 = Enter(명령 중 확정 · 쉴 때 반복) · Shift+우클릭 = 점 물음에서 한 번 스냅 메뉴 evt.preventDefault(); evt.stopPropagation(); - this.handleEnterKey(true); + if (evt.shiftKey && currentPrompt().kind === 'point') { + openSnapMenu(evt.clientX, evt.clientY, (name) => this.submitText(name)); + } else { + this.handleEnterKey(true); + } } // If ancestor parent exist with class .controls => ignore clicks, since a button was clicked instead of the canvas diff --git a/B07_DesignDetail/openwebcad/src/inputController/prompt-input.ts b/B07_DesignDetail/openwebcad/src/inputController/prompt-input.ts index 414d961bc..f387dadc3 100644 --- a/B07_DesignDetail/openwebcad/src/inputController/prompt-input.ts +++ b/B07_DesignDetail/openwebcad/src/inputController/prompt-input.ts @@ -60,6 +60,8 @@ export interface SubPrompt { onNumber?(n: number): SubResult; onOption?(key: string): SubResult; onEnter?(): SubResult; + /** 꼴이 정해지지 않은 글(ZOOM 「2X」) — undefined = 안 먹음 · 사다리 그대로 */ + onText?(text: string): SubResult | undefined; } /** `{point}` = 닫고 그 점을 원래 물음에 ABSOLUTE_POINT_INPUT 로 */ export type SubResult = 'stay' | 'done' | { point: Point }; @@ -358,6 +360,8 @@ export function routeTyped(raw: string): void { return; } if (text.startsWith("'")) return routeTransparent(text.slice(1)); + const own = subPrompt()?.onText?.(text); + if (own) return settle(own); if (prompt.kind === 'idle') { // ⑨ 이름 > 내 별칭 > 기본 별칭 딱 맞음만 const command = resolveCommandInput(text); diff --git a/B07_DesignDetail/openwebcad/src/inputController/selecting.ts b/B07_DesignDetail/openwebcad/src/inputController/selecting.ts index f095a6ccb..3205b0869 100644 --- a/B07_DesignDetail/openwebcad/src/inputController/selecting.ts +++ b/B07_DesignDetail/openwebcad/src/inputController/selecting.ts @@ -6,6 +6,11 @@ import type { AnyActorRef } from 'xstate'; import { getActiveToolActor } from '../state.ts'; import { Tool } from '../tools.ts'; import { SelectState } from '../tools/select-tool.ts'; +import { setKeywordNotice } from '../tools/select-tool.helpers.ts'; +import { notice } from './prompt-input.ts'; + +// 고르기 키워드(ALL · L · P) 「n개를 찾음」 → 알림 줄 +setKeywordNotice(notice); const isSelect = (actor: AnyActorRef | undefined): boolean => actor?.getSnapshot()?.context?.type === Tool.SELECT; diff --git a/B07_DesignDetail/openwebcad/src/inputController/snap-menu.css b/B07_DesignDetail/openwebcad/src/inputController/snap-menu.css new file mode 100644 index 000000000..8d313cc24 --- /dev/null +++ b/B07_DesignDetail/openwebcad/src/inputController/snap-menu.css @@ -0,0 +1,29 @@ +/* Shift+우클릭 한 번 스냅 메뉴(`snap-menu.ts`) — 커서 자리에 세로 목록 */ +.cad-snap-menu { + position: fixed; + z-index: 60; + display: flex; + flex-direction: column; + min-width: 160px; + padding: 4px; + border: 1px solid var(--color-border); + border-radius: 6px; + background: var(--color-surface-raised); + box-shadow: var(--shadow-sm); +} + +.cad-snap-menu > button { + padding: 4px 10px; + border: 0; + border-radius: 4px; + background: transparent; + color: var(--color-text); + font-size: 13px; + text-align: left; + cursor: pointer; +} + +.cad-snap-menu > button:hover, +.cad-snap-menu > button:focus-visible { + background: var(--color-surface-hover, rgba(127, 127, 127, 0.15)); +} diff --git a/B07_DesignDetail/openwebcad/src/inputController/snap-menu.ts b/B07_DesignDetail/openwebcad/src/inputController/snap-menu.ts new file mode 100644 index 000000000..bfb33b789 --- /dev/null +++ b/B07_DesignDetail/openwebcad/src/inputController/snap-menu.ts @@ -0,0 +1,76 @@ +/** + * Shift+우클릭 한 번 스냅 메뉴(337 마 · 오토캐드) — 점 물음일 때만 · 고르면 그 이름을 친 것과 같은 길(`submitText`). + * 스냅 이름 · FROM · M2P 의 뜻은 친 글 사다리(`prompt-input.ts`) 몫 · 메뉴는 고르기만. + */ +import './snap-menu.css'; + +/** [친 글, 이름] — 오토캐드 객체 스냅 메뉴 차례 */ +export const SNAP_MENU: readonly [string, string][] = [ + ['FROM', '시작점에서'], + ['M2P', '두 점 사이의 중간'], + ['END', '끝점'], + ['MID', '중간점'], + ['INT', '교차점'], + ['CEN', '중심'], + ['QUA', '사분점'], + ['PER', '수직'], + ['NOD', '노드'], + ['NEA', '근처점'], + ['NON', '없음'], +]; + +let current: { onPick: (name: string) => void; element: HTMLElement | null } | null = null; + +const onKey = (evt: KeyboardEvent) => { + if (evt.key !== 'Escape') return; + evt.preventDefault(); + evt.stopImmediatePropagation(); // Esc = 메뉴만 닫음(명령은 그대로) + closeSnapMenu(); +}; +const onOutside = (evt: MouseEvent) => { + if (!current?.element?.contains(evt.target as Node)) closeSnapMenu(); +}; + +export const isSnapMenuOpen = (): boolean => current !== null; + +export function closeSnapMenu(): void { + if (!current) return; + current.element?.remove(); + current = null; + if (typeof window === 'undefined') return; + window.removeEventListener('keydown', onKey, true); + window.removeEventListener('mousedown', onOutside, true); +} + +/** 메뉴에서 하나 고름 — 닫고 그 이름을 넘김 */ +export function pickSnapMenu(name: string): void { + const picked = current; + closeSnapMenu(); + picked?.onPick(name); +} + +/** 화면 (x, y) 에 메뉴를 띄움 — DOM 없는 시험에선 상태만 */ +export function openSnapMenu(x: number, y: number, onPick: (name: string) => void): void { + closeSnapMenu(); + current = { onPick, element: null }; + if (typeof document === 'undefined') return; + const menu = document.createElement('div'); + menu.className = 'cad-snap-menu controls'; + menu.setAttribute('role', 'menu'); + for (const [name, label] of SNAP_MENU) { + const item = document.createElement('button'); + item.type = 'button'; + item.setAttribute('role', 'menuitem'); + item.textContent = `${label} (${name})`; + item.addEventListener('click', () => pickSnapMenu(name)); + menu.append(item); + } + document.body.append(menu); + // 화면 밖으로 안 나가게 + const box = menu.getBoundingClientRect(); + menu.style.left = `${Math.min(x, window.innerWidth - box.width - 4)}px`; + menu.style.top = `${Math.min(y, window.innerHeight - box.height - 4)}px`; + current.element = menu; + window.addEventListener('keydown', onKey, true); + window.addEventListener('mousedown', onOutside, true); +} diff --git a/B07_DesignDetail/openwebcad/src/inputController/zoom-prompt.ts b/B07_DesignDetail/openwebcad/src/inputController/zoom-prompt.ts index d8146e02e..f17b6b848 100644 --- a/B07_DesignDetail/openwebcad/src/inputController/zoom-prompt.ts +++ b/B07_DesignDetail/openwebcad/src/inputController/zoom-prompt.ts @@ -1,9 +1,12 @@ /** - * 보기 바꿈(337 마) — 범위 줌 · 앞 화면(ZOOM 이전(P)) · 휠 두 번 = 범위. + * 보기 바꿈(337 마) — 범위 줌 · 앞 화면(ZOOM 이전(P)) · 휠 두 번 = 범위 · ZOOM 덧물음. * 앞 화면 기억 = ZOOM · 휠 두 번 · ⛶ 로 바꾼 것만(휠 굴림 · 팬은 안 쌓음) · 오토캐드처럼 열 개. + * ZOOM 은 덧물음 — 도구를 안 바꿔 명령 중 `'Z` 도 같은 길(투명). */ -import type { Point } from '@flatten-js/core'; +import { Point } from '@flatten-js/core'; +import type { OptionSpec, PromptSpec } from '../commands/prompt.ts'; import { getScreenCanvasDrawController } from '../state.ts'; +import { notice, pushSubPrompt, type SubPrompt, type SubResult } from './prompt-input.ts'; type View = { scale: number; offset: Point }; @@ -46,3 +49,110 @@ export function isMiddleDouble(point: { x: number; y: number }, time = Date.now( lastMiddle = double ? null : { time, x: point.x, y: point.y }; return double; } + +/** 배율만 바꿈 — 화면 가운데 자리를 붙잡음 */ +function setScaleAboutCenter(scale: number): void { + const controller = getScreenCanvasDrawController(); + const size = controller.getCanvasSize(); + const offset = controller.getScreenOffset(); + const old = controller.getScreenScale(); + const cx = offset.x + size.x / 2 / old; + const cy = offset.y + size.y / 2 / old; + controller.setScreenScale(scale); + controller.setScreenOffset(new Point(cx - size.x / 2 / scale, cy - size.y / 2 / scale)); +} + +/** 윈도우 줌 — 두 구석 상자를 화면에 꽉(종횡비 유지) */ +export function zoomWindow(a: Point, b: Point): void { + const controller = getScreenCanvasDrawController(); + const size = controller.getCanvasSize(); + const width = Math.abs(a.x - b.x); + const height = Math.abs(a.y - b.y); + const scale = Math.min( + width > 0 ? size.x / width : Number.POSITIVE_INFINITY, + height > 0 ? size.y / height : Number.POSITIVE_INFINITY + ); + if (!Number.isFinite(scale)) return; // 같은 점 두 번 — 안 바꿈 + const cx = (a.x + b.x) / 2; + const cy = (a.y + b.y) / 2; + changeView(() => { + controller.setScreenScale(scale); + controller.setScreenOffset(new Point(cx - size.x / 2 / scale, cy - size.y / 2 / scale)); + }); +} + +const zoomOption = (key: string, kw: string, label: string): OptionSpec => ({ + key, + kw, + en: key, + label, +}); + +/** 오토캐드 ZOOM 첫 줄 — 넣은 넷 · 뺀 넷 · <실시간> */ +const ZOOM_PROMPT: PromptSpec = { + text: '윈도우 구석 지정, 축척 비율 (nX 또는 nXP) 입력', + options: [ + zoomOption('ALL', 'A', '전체'), + zoomOption('EXTENTS', 'E', '범위'), + zoomOption('PREVIOUS', 'P', '이전'), + zoomOption('WINDOW', 'W', '윈도우'), + ], + absent: [ + zoomOption('CENTER', 'C', '중심'), + zoomOption('DYNAMIC', 'D', '동적'), + zoomOption('SCALE', 'S', '축척'), + zoomOption('OBJECT', 'O', '객체'), + ], + default: { kind: 'enter', label: '실시간' }, +}; +const FIRST_CORNER: PromptSpec = { text: '첫 번째 구석 지정' }; +const OTHER_CORNER: PromptSpec = { text: '반대 구석 지정' }; +const SCALE_REGEXP = /^(\d*\.?\d+)\s*XP?$/i; + +/** ZOOM — 덧물음 하나가 단계(첫 줄 → 첫 구석 → 반대 구석)를 품음 */ +export function startZoomPrompt(): void { + let stage: 'main' | 'first' | 'other' = 'main'; + let corner: Point | null = null; + const sub: SubPrompt = { + kind: 'point', + get prompt() { + return stage === 'main' ? ZOOM_PROMPT : stage === 'first' ? FIRST_CORNER : OTHER_CORNER; + }, + onPoint(point): SubResult { + if (stage === 'other' && corner) { + zoomWindow(corner, point); + return 'done'; + } + corner = point; // 첫 줄에서 누름 = 윈도우 첫 구석(오토캐드) + stage = 'other'; + return 'stay'; + }, + onOption(key): SubResult { + if (key === 'WINDOW') { + stage = 'first'; + return 'stay'; + } + if (key === 'PREVIOUS') { + if (!zoomPrevious()) notice('앞 화면이 없음.'); + return 'done'; + } + zoomExtents(); // 전체(A) = 범위(이 CAD 엔 도면 한계 없음) + return 'done'; + }, + onEnter(): SubResult { + if (stage !== 'main') return 'stay'; + notice('실시간 줌은 휠로.'); + return 'done'; + }, + onText(text) { + const match = stage === 'main' ? SCALE_REGEXP.exec(text) : null; + if (!match) return undefined; + const factor = Number.parseFloat(match[1]); + if (factor <= 0) return undefined; + const scale = getScreenCanvasDrawController().getScreenScale() * factor; + changeView(() => setScaleAboutCenter(scale)); + return 'done'; + }, + }; + pushSubPrompt(sub); +} diff --git a/B07_DesignDetail/openwebcad/src/tools/grip-edit-tool.ts b/B07_DesignDetail/openwebcad/src/tools/grip-edit-tool.ts index 9845aa846..0f60ca6d2 100644 --- a/B07_DesignDetail/openwebcad/src/tools/grip-edit-tool.ts +++ b/B07_DesignDetail/openwebcad/src/tools/grip-edit-tool.ts @@ -1,10 +1,11 @@ /** * 그립 편집 도구 — 선택 도구에서 그립을 집으면 켜진다(뜨거운 그립). * 오토캐드처럼 누르고-옮기고-누르기 · 끌어 놓기 둘 다(누름 · 뗌 가름은 input-controller) · - * 거리 숫자 = 그립 점에서 커서 쪽(N8) · 좌표 · Esc = 그립 놓음. + * 거리 숫자 = 그립 점에서 커서 쪽(N8) · 좌표 · Esc · 종료(X) = 그립 놓음. */ import type { Point } from '@flatten-js/core'; import { Actor, assign, createMachine } from 'xstate'; +import type { OptionSpec, PromptSpec } from '../commands/prompt'; import type { Entity } from '../entities/Entity'; import { getPointFromEvent } from '../helpers/get-point-from-event'; import { type Grip, applyGrip } from '../helpers/grips'; @@ -20,6 +21,24 @@ import { Tool } from '../tools'; import { selectToolStateMachine } from './select-tool'; import { ActorEvent, type PointInputEvent, type StateEvent, type ToolContext } from './tool.types'; +const gripOption = (key: string, kw: string, label: string): OptionSpec => ({ + key, + kw, + en: key, + label, +}); + +/** 오토캐드 그립 신축 줄 — 종료(X)만 · 기준점(B) · 복사(C) · 명령 취소(U) 는 뺌 */ +const GRIP_PROMPT: PromptSpec = { + text: '** 신축 ** 신축점 지정', + options: [gripOption('EXIT', 'X', '종료')], + absent: [ + gripOption('BASE', 'B', '기준점'), + gripOption('COPY', 'C', '복사'), + gripOption('UNDO', 'U', '명령 취소'), + ], +}; + let editedEntity: Entity | null = null; let editedGrip: Grip | null = null; @@ -64,7 +83,7 @@ export const gripEditToolStateMachine = createMachine( }, [GripEditState.WAITING_FOR_TARGET_POINT]: { description: '그립을 옮길 위치를 지정한다', - meta: { instructions: '** 신축 ** 신축점 지정' }, + meta: { kind: 'point', prompt: GRIP_PROMPT }, on: { [ActorEvent.DRAW]: { actions: GripEditAction.DRAW_TEMP_GRIP_EDIT }, [ActorEvent.MOUSE_CLICK]: { @@ -80,6 +99,7 @@ export const gripEditToolStateMachine = createMachine( actions: [GripEditAction.APPLY_GRIP_EDIT, GripEditAction.SWITCH_TO_SELECT_TOOL], }, [ActorEvent.ESC]: { actions: GripEditAction.SWITCH_TO_SELECT_TOOL }, + 'OPTION.EXIT': { actions: GripEditAction.SWITCH_TO_SELECT_TOOL }, }, }, }, diff --git a/B07_DesignDetail/openwebcad/src/tools/select-tool.helpers.ts b/B07_DesignDetail/openwebcad/src/tools/select-tool.helpers.ts index 4ec7b98ed..97decd56b 100644 --- a/B07_DesignDetail/openwebcad/src/tools/select-tool.helpers.ts +++ b/B07_DesignDetail/openwebcad/src/tools/select-tool.helpers.ts @@ -31,6 +31,7 @@ import { setSelectedEntityIds, } from '../state'; import { Tool } from '../tools'; +import { previousSelection } from '../commands/prompt'; import { startGripEdit } from './grip-edit-tool'; import type { SelectContext } from './select-tool'; import type { MouseClickEvent } from './tool.types'; @@ -113,6 +114,26 @@ export function combineSelection(ids: string[], mode: 'add' | 'remove' | 'toggle return [...before, ...ids.filter((id) => !before.includes(id))]; } +/** 알림 줄 — 입력 층이 붙임(`ic/selecting.ts` · prompt-input 을 바로 부르면 불러오기 고리) */ +let notice: (text: string) => void = () => {}; +export const setKeywordNotice = (sink: (text: string) => void): void => { + notice = sink; +}; + +/** 고르기 키워드 — 전체(ALL = Ctrl+A 와 같은 목록) · 최종(LAST = 집을 수 있는 마지막) · 이전(P) · 앞 고름에 더함 */ +export function selectByKeyword(key: 'ALL' | 'LAST' | 'PREVIOUS'): void { + const pickable = getPickableEntities(); + const found = + key === 'ALL' + ? pickable.map((entity) => entity.id) + : key === 'LAST' + ? pickable.slice(-1).map((entity) => entity.id) + : previousSelection().filter((id) => pickable.some((entity) => entity.id === id)); + const ids = combineSelection(expandSelectionWithGroups(found), 'add'); + setSelectedEntityIds(ids); + notice(`${found.length}개를 찾음, 총 ${ids.length}개`); +} + /** 그립 집기 · 그리기 — 쉴 때 · 고칠 수 있는 도면만 */ export const gripsEditable = (): boolean => !isViewOnly() && diff --git a/B07_DesignDetail/openwebcad/src/tools/select-tool.ts b/B07_DesignDetail/openwebcad/src/tools/select-tool.ts index 91d8770d1..f529d08d7 100644 --- a/B07_DesignDetail/openwebcad/src/tools/select-tool.ts +++ b/B07_DesignDetail/openwebcad/src/tools/select-tool.ts @@ -14,6 +14,7 @@ import { assign, createMachine } from 'xstate'; import { drawTempSelectionRectangle, handleFirstSelectionPoint, + selectByKeyword, selectEntitiesInsideRectangle, } from './select-tool.helpers'; @@ -76,6 +77,10 @@ export const selectToolStateMachine = createMachine( actions: SelectAction.DELETE_SELECTED_ENTITIES, target: SelectState.INIT, }, + // 고르기 숨은 키워드(SELECT_OBJECTS) — 전체 · 최종 · 이전 + 'OPTION.ALL': { actions: () => selectByKeyword('ALL') }, + 'OPTION.LAST': { actions: () => selectByKeyword('LAST') }, + 'OPTION.PREVIOUS': { actions: () => selectByKeyword('PREVIOUS') }, }, }, [SelectState.CHECK_SELECTION]: { diff --git a/resources/tester/cad/mouse-acad.test.ts b/resources/tester/cad/mouse-acad.test.ts index d901f1d05..64d2f25c3 100644 --- a/resources/tester/cad/mouse-acad.test.ts +++ b/resources/tester/cad/mouse-acad.test.ts @@ -1,7 +1,8 @@ /** * CAD 마우스 · 고르기 · 그립 · 직교 · 휠 — 오토캐드 조작(PLAN 337-2 마 · 요구 229-3 · 설계 4 장 · 6-3 마). * 누름 더함 · Shift 뺌 · 상자 더함/뺌 · 명령 중 끌기 상자 · 스침 · N7 · 그립 누름-뗌 대기 · 끌어 놓음 · 그립 숫자 · - * H2 문 · 직교 강제 · Shift 임시 직교 · 스냅 점 안 덮음 · F3 꺼도 직교 · H9 · 휠 두 번 범위 · 휠 기본 밂. + * H2 문 · 직교 강제 · Shift 임시 직교 · 스냅 점 안 덮음 · F3 꺼도 직교 · H9 · 휠 두 번 범위 · 휠 기본 밂 · + * 마-2: 우클릭(쉼 반복 · 명령 중 Enter) · ZOOM(E · A · W · P · 2X · 'Z) · Shift+우클릭 스냅 메뉴 · 고르기 ALL/L/P · 그립 X · Esc. * 시험 쪽 flatten-js 는 다른 사본 — 점은 앱 코드(LINE 도구)로만 만들고 숫자로 판정. * 실행: cd B07_DesignDetail/openwebcad && npx vitest run --dir ../../resources/tester/cad mouse-acad */ @@ -14,6 +15,17 @@ vi.mock("react-toastify", () => ({ import { Actor } from "xstate"; import { InputController } from "../../../B07_DesignDetail/openwebcad/src/inputController/input-controller"; import { zoomPrevious } from "../../../B07_DesignDetail/openwebcad/src/inputController/zoom-prompt"; +import { + isSnapMenuOpen, + pickSnapMenu, +} from "../../../B07_DesignDetail/openwebcad/src/inputController/snap-menu"; +import { getSnapOverride } from "../../../B07_DesignDetail/openwebcad/src/inputController/input-snap-override"; +import { selectingActor } from "../../../B07_DesignDetail/openwebcad/src/inputController/selecting"; +import { + currentNotice, + currentPrompt, +} from "../../../B07_DesignDetail/openwebcad/src/inputController/prompt-input"; +import { rememberSelection } from "../../../B07_DesignDetail/openwebcad/src/commands/prompt"; import { wheelZoom } from "../../../B07_DesignDetail/openwebcad/src/inputController/input-wheel"; import { getActiveToolActor, @@ -66,6 +78,11 @@ const release = (x: number, y: number, mods?: Mods) => { move(x, y, mods); controller.handleMouseUp(ev(x, y, mods)); }; +/** 가운데 단추 누름 + 뗌(뗌 = 팬 끝) */ +const middle = (x: number, y: number) => { + press(x, y, { button: 1 }); + controller.handleMouseUp(ev(x, y, { button: 1 })); +}; const click = (x: number, y: number, mods?: Mods) => { press(x, y, mods); release(x, y, mods); @@ -85,6 +102,8 @@ const key = (k: string) => preventDefault: vi.fn(), stopPropagation: vi.fn(), } as unknown as KeyboardEvent); +const rightClick = (shift = false) => + controller.handleMouseUp(ev(0, 0, { button: 2, shiftKey: shift })); const typeEnter = (text: string) => { for (const ch of text) key(ch); key("Enter"); @@ -184,6 +203,20 @@ describe("그립 — 누르고-옮기고-누르기 · 끌기 · 숫자 · N7 · expect(toolType()).toBe(Tool.SELECT); }); + it("뜨거운 그립 Esc · 종료(X) = 놓음(안 바뀜) · 물음 줄", () => { + click(50, 0); + click(100, 0); + expect(currentPrompt().line).toBe("** 신축 ** 신축점 지정 또는 [종료(X)]:"); + move(150, 90); + key("Escape"); + expect(toolType()).toBe(Tool.SELECT); + expectXy(segOf(a).end, 100, 0); + click(100, 0); // 고름은 남음 — 다시 그립 + typeEnter("x"); + expect(toolType()).toBe(Tool.SELECT); + expectXy(segOf(a).end, 100, 0); + }); + it("그립 끌어 놓음 = 놓은 자리", () => { click(50, 0); press(100, 0); @@ -292,17 +325,128 @@ describe("휠", () => { view.zoomToFitScreen = vi.fn(() => view.setScreenScale(3)); const now = vi.spyOn(Date, "now"); now.mockReturnValue(1000); - press(10, 10, { button: 1 }); + middle(10, 10); now.mockReturnValue(1300); - press(11, 11, { button: 1 }); + middle(11, 11); expect(view.zoomToFitScreen).toHaveBeenCalledTimes(1); expect(view.getScreenScale()).toBe(3); expect(zoomPrevious()).toBe(true); expect(view.getScreenScale()).toBe(1); now.mockReturnValue(5000); - press(10, 10, { button: 1 }); + middle(10, 10); now.mockReturnValue(5600); - press(10, 10, { button: 1 }); + middle(10, 10); expect(view.zoomToFitScreen).toHaveBeenCalledTimes(1); }); }); + +describe("우클릭 · 고르기 키워드(마-2)", () => { + it("우클릭 = 쉴 때 앞 명령 반복 · 명령 중 Enter(고르기 끝)", () => { + typeEnter("DRAWORDER"); + expect(toolType()).toBe(Tool.DRAWORDER_FRONT); + key("Escape"); + expect(toolType()).toBe(Tool.SELECT); + rightClick(); + expect(toolType()).toBe(Tool.DRAWORDER_FRONT); + expect(selectingActor()).not.toBeNull(); + click(50, 0); + rightClick(); // 고르기 끝 → 명령 확정 + expect(selectingActor()?.getSnapshot()?.context?.type).toBe(Tool.SELECT); + expect(toolType()).toBe(Tool.SELECT); // 쉼으로 + }); + + it("고르기 ALL · L(최종) · P(이전) — 앞 고름에 더함 · 「n개를 찾음」", () => { + typeEnter("DRAWORDER"); + typeEnter("all"); + expect(getSelectedEntityIds().sort()).toEqual([a, b].sort()); + expect(currentNotice()).toBe("2개를 찾음, 총 2개"); + key("Escape"); + setSelectedEntityIds([]); + typeEnter("DRAWORDER"); + typeEnter("L"); + expect(getSelectedEntityIds()).toEqual([b]); + key("Escape"); + setSelectedEntityIds([]); + rememberSelection([a]); + typeEnter("DRAWORDER"); + typeEnter("p"); + expect(getSelectedEntityIds()).toEqual([a]); + }); +}); + +describe("ZOOM 덧물음 · 스냅 메뉴(마-2)", () => { + const view = () => + getScreenCanvasDrawController() as unknown as { + zoomToFitScreen: () => void; + setScreenScale: (s: number) => void; + getScreenScale: () => number; + getCanvasSize: () => Xy; + }; + + it("Z 물음 줄 · E = 범위 · P = 앞 화면 · Enter = 실시간 안내", () => { + view().zoomToFitScreen = vi.fn(() => view().setScreenScale(4)); + typeEnter("z"); + expect(currentPrompt().line).toBe( + "윈도우 구석 지정, 축척 비율 (nX 또는 nXP) 입력 또는 [전체(A)/범위(E)/이전(P)/윈도우(W)] <실시간>:", + ); + typeEnter("e"); + expect(view().getScreenScale()).toBe(4); + expect(toolType()).toBe(Tool.SELECT); + typeEnter("Z"); + typeEnter("P"); + expect(view().getScreenScale()).toBe(1); + typeEnter("Z"); + typeEnter("C"); + expect(currentNotice()).toBe("「중심(C)」 옵션은 이 CAD 에 없음."); + key("Enter"); + expect(currentNotice()).toBe("실시간 줌은 휠로."); + expect(currentPrompt().kind).toBe("idle"); + }); + + it("2X = 배율 두 배(가운데 붙잡음) · W = 두 구석 · 누름 = 바로 윈도우", () => { + typeEnter("Z"); + typeEnter("2x"); + expect(view().getScreenScale()).toBe(2); + expect(zoomPrevious()).toBe(true); // 배율 · 원점 되돌림 + const size = view().getCanvasSize(); + typeEnter("Z"); + typeEnter("W"); + expect(currentPrompt().line).toBe("첫 번째 구석 지정:"); + click(0, 0); + expect(currentPrompt().line).toBe("반대 구석 지정:"); + click(100, 50); + expect(view().getScreenScale()).toBeCloseTo(Math.min(size.x / 100, size.y / 50), 6); + expect(getSelectedEntityIds()).toEqual([]); // 고르기로 안 샘 + zoomPrevious(); + typeEnter("Z"); + click(0, 0); + click(50, 100); + expect(view().getScreenScale()).toBeCloseTo(Math.min(size.x / 50, size.y / 100), 6); + zoomPrevious(); + }); + + it("'Z 투명 — 선 그리는 중 2X 뒤 선 이어 감", () => { + lineTool(); + click(300, 300); + typeEnter("'z"); + typeEnter("2X"); + expect(view().getScreenScale()).toBe(2); + expect(toolType()).toBe(Tool.LINE); + zoomPrevious(); + }); + + it("Shift+우클릭 = 점 물음에서만 스냅 메뉴 → 한 번 스냅", () => { + rightClick(true); // 쉼 — 메뉴 없음(앞 명령 반복) + expect(isSnapMenuOpen()).toBe(false); + key("Escape"); // 반복된 명령 끝 + lineTool(); + click(300, 300); + rightClick(true); + expect(isSnapMenuOpen()).toBe(true); + pickSnapMenu("END"); + expect(isSnapMenuOpen()).toBe(false); + expect(getSnapOverride()).toBe("END"); + click(96, 4); + expectXy(lastSeg().end, 100, 0); + }); +});