Merge remote-tracking branch 'origin/sub_laptop_8' into main_laptop_1
This commit is contained in:
@@ -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 '';
|
||||
},
|
||||
},
|
||||
{
|
||||
|
||||
@@ -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 = () => {
|
||||
<div className="cad-view-controls controls">
|
||||
{showUndo && (
|
||||
<>
|
||||
<button type="button" onClick={() => runCommandInput('UNDO')} title="실행 취소 (Ctrl+Z)">
|
||||
<button type="button" onClick={undoCommand} title="실행 취소 (Ctrl+Z)">
|
||||
↶
|
||||
</button>
|
||||
<button type="button" onClick={() => runCommandInput('REDO')} title="다시 실행 (Ctrl+Y)">
|
||||
<button type="button" onClick={redoCommand} title="다시 실행 (Ctrl+Y)">
|
||||
↷
|
||||
</button>
|
||||
</>
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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';
|
||||
@@ -168,10 +169,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
|
||||
|
||||
@@ -63,6 +63,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 };
|
||||
@@ -377,6 +379,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);
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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 },
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
@@ -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() &&
|
||||
|
||||
@@ -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]: {
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user