feat(B07 CAD): 337-2 마-1 오토캐드 마우스 — 누름 더함 · Shift 뺌 · 상자 더함/뺌 · 명령 중 끌기 상자 · 스침 · 그립 누르고-옮기고-누르기 · 그립 숫자 · 보기 전용 문 · 직교 강제 · Shift 임시 직교 · 휠 기본 밂 · 휠 두 번 범위

- 고르기: 누름 = 더함 · Shift = 뺌 · Ctrl = 넣고 빼기 · 상자 = 앞 고름 ∪ 상자 · Shift 상자 = 뺌(H4 · H5) · 명령 안 고르기 단계도 누를 때 첫 점 · 스침 강조(ic/selecting.ts)
- 그립: 쉴 때만 집음(N7) · 누르고 떼면 대기 → 다음 누름에 놓음 · 끌어 놓기(H3) · 거리 숫자(N8) · 확정 · 읽기 전용 도면은 그립 · Delete 막음(H2)
- 직교: F8 = 기준점에서 가로 · 세로 강제 · Shift 누른 동안 뒤집음 · 객체 스냅 점 · 좌표 · 각도 잠금은 안 덮음 · 클릭 사건 snapped 채움(ic/ortho.ts · H8) · 가이드는 F3 와 뗌 · 가이드 잡는 거리 = 화면 픽셀(H9)
- 휠: 기본 밂(앞으로 굴리면 확대 · 오토캐드 · 모든 화면 · ☰ 설정 뒤집기 그대로) · 가운데 단추 두 번 = 범위 줌 · ZOOM · ⛶ 앞 화면 기억(ic/zoom-prompt.ts)
- 시험: mouse-acad 새로 · 휠 방향 시험 손질(input-wheel · input-prefs-cad · input_prefs · app_settings · 164 · 220_1 · 221_1 · 244_8 기대값 뒤집음 · 266 pull 박음)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Se7ytqbohmBQnfYpwYFnYf
This commit is contained in:
2026-10-15 23:41:46 +09:00
co-authored by Claude Opus 5.5
parent 33207a450c
commit db8462c6d4
27 changed files with 655 additions and 198 deletions
@@ -1,7 +1,6 @@
import { Point } from '@flatten-js/core';
import { compact, round } from 'es-toolkit';
import { Actor } from 'xstate';
import { SNAP_POINT_DISTANCE } from '../App.consts.ts';
import { MouseButton } from '../App.types.ts';
import {
describeCommand,
@@ -13,13 +12,13 @@ import { requestHostSave } from '../integration/aislo-drawing-bridge.ts';
import type { ScreenCanvasDrawController } from '../drawControllers/screenCanvas.drawController.ts';
import { calculateAngleGuidesAndSnapPoints } from '../helpers/calculate-angle-guides-and-snap-points.ts';
import { findClosestEntity } from '../helpers/find-closest-entity.ts';
import { getClosestSnapPointWithinRadius } from '../helpers/get-closest-snap-point.ts';
import { pickRadius } from '../helpers/pick-radius.ts';
import {
getActiveToolActor,
getAngleGuideOriginPoint,
getAngleStep,
getCanvas,
getHighlightedEntityIds,
getPickableEntities,
getGridEnabled,
getLastStateInstructions,
@@ -58,6 +57,9 @@ import {
import { drawInputField, dynamicInputLine } from './input-field-draw.ts';
import { 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 { isMiddleDouble, zoomExtents } from './zoom-prompt.ts';
import { attachTouch } from './input-touch.ts';
import {
ANGLE_LOCK_REGEXP,
@@ -193,16 +195,16 @@ export class InputController {
return;
}
if (evt.button === MouseButton.Left) {
if (this.isSelectToolActive()) {
// 선택 도구는 누를 때 첫 점을 이미 보냈다. 끌었으면 놓는 자리로 사각형을 닫는다.
const pressLocation = this.leftPressLocation;
const pressLocation = this.leftPressLocation;
if (pressLocation) {
// 고르기는 누를 때 첫 점을 이미 보냈다 — 끌었으면 놓는 자리로 상자를 닫거나 집은 그립을 놓음 ·
// 안 끌고 떼면 그대로(상자 클릭-클릭 · 뜨거운 그립은 다음 누름에 놓음 — H3)
this.leftPressLocation = null;
const releaseLocation = this.getCanvasPoint(evt);
const dragged =
!!pressLocation &&
Math.hypot(releaseLocation.x - pressLocation.x, releaseLocation.y - pressLocation.y) >
DRAG_SELECT_MIN_PX;
if (dragged && this.isWaitingForSecondSelectPoint()) {
DRAG_SELECT_MIN_PX;
if (dragged && (this.isGripEditActive() || isWaitingForSecondSelectPoint())) {
this.sendMouseClick(evt);
}
return;
@@ -217,29 +219,25 @@ export class InputController {
return getActiveToolActor()?.getSnapshot()?.context?.type === Tool.SELECT;
}
/** 선택 도구가 선택 사각형의 두 번째 점을 기다리는 중인가 */
private isWaitingForSecondSelectPoint(): boolean {
return (
getActiveToolActor()?.getSnapshot()?.value === SelectState.WAITING_FOR_SECOND_SELECT_POINT
);
/** 집은 그립을 옮기는 중인가 */
private isGripEditActive(): boolean {
return getActiveToolActor()?.getSnapshot()?.context?.type === Tool.GRIP_EDIT;
}
/** 스냅을 반영한 클릭 한 번을 활성 도구에 보낸다 */
/** 스냅을 반영한 클릭 한 번을 활성 도구에 보낸다 — snapped = 객체 스냅에 잡힘(직교가 안 덮음) */
private sendMouseClick(evt: MouseEvent) {
const screenCanvasDrawController = getScreenCanvasDrawController();
const closestSnapPoint = getClosestSnapPointWithinRadius(
compact([getSnapPoint(), getSnapPointOnAngleGuide()]),
screenCanvasDrawController.getWorldMouseLocation(),
SNAP_POINT_DISTANCE / screenCanvasDrawController.getScreenScale()
);
const closestSnapPoint = snapAt(screenCanvasDrawController.getWorldMouseLocation());
const worldMouseLocationTemp = screenCanvasDrawController.targetToWorld(
this.getCanvasPoint(evt)
);
let worldMouseLocation = closestSnapPoint ? closestSnapPoint.point : worldMouseLocationTemp;
let snapped = isObjectSnap(closestSnapPoint);
if (getSnapOverride()) {
const snapped = snapOverridePoint(worldMouseLocationTemp, getAngleGuideOriginPoint());
if (!snapped) return; // 맞는 점 없음 — 다시 누르기를 기다림
worldMouseLocation = snapped;
const overridden = snapOverridePoint(worldMouseLocationTemp, getAngleGuideOriginPoint());
if (!overridden) return; // 맞는 점 없음 — 다시 누르기를 기다림
worldMouseLocation = overridden;
snapped = true;
}
this.sendPointEvent({
@@ -248,6 +246,7 @@ export class InputController {
screenMouseLocation: screenCanvasDrawController.worldToTarget(worldMouseLocation),
holdingCtrl: evt.ctrlKey,
holdingShift: evt.shiftKey,
snapped,
} as MouseClickEvent);
}
@@ -257,6 +256,7 @@ export class InputController {
public handleMouseMove(evt: MouseEvent) {
setShouldDrawCursor(true);
noteShift(evt);
const screenCanvasDrawController = getScreenCanvasDrawController();
const newScreenMouseLocation = this.getCanvasPoint(evt);
screenCanvasDrawController.setScreenMouseLocation(newScreenMouseLocation);
@@ -274,13 +274,11 @@ export class InputController {
// 미리보기는 보기만 — 스냅 · 가까운 개체 강조를 하지 않는다.
if (isViewOnly()) return;
// Calculate angle guides and snap points
if (getSnapEnabled()) {
calculateAngleGuidesAndSnapPoints();
}
// 가이드 · 스냅 — 가이드(직교 · 극)는 늘 · 객체 스냅점은 F3 일 때만(안에서 가름 · H8)
calculateAngleGuidesAndSnapPoints();
// Highlight the entity closest to the mouse when the select tool is active
if (getActiveToolActor()?.getSnapshot()?.context.type === Tool.SELECT) {
// 스침 강조 — 쉴 때 · 명령 안 고르기 단계 모두(H5)
if (selectingActor()) {
const closestEntityInfo = findClosestEntity(
screenCanvasDrawController.targetToWorld(newScreenMouseLocation),
getPickableEntities()
@@ -290,6 +288,8 @@ export class InputController {
} else {
setHighlightedEntityIds([]);
}
} else if (getHighlightedEntityIds().length) {
setHighlightedEntityIds([]);
}
}
@@ -309,8 +309,15 @@ export class InputController {
}
public handleMouseDown(evt: MouseEvent) {
noteShift(evt);
if (evt.button === MouseButton.Middle) {
evt.preventDefault(); // 자동 스크롤로 부모 페이지가 같이 굴러가던 것
// 휠 두 번 = 범위 줌(오토캐드)
if (isMiddleDouble(this.getCanvasPoint(evt))) {
setPanStartLocation(null);
zoomExtents();
return;
}
setPanStartLocation(this.getCanvasPoint(evt));
return;
}
@@ -322,9 +329,9 @@ export class InputController {
setPanStartLocation(this.getCanvasPoint(evt));
return;
}
// AutoCAD처럼 누른 자리에서 사각형이 시작되도록 선택 도구에만 첫 점을 미리 보낸다.
// 다른 도구는 예전처럼 놓을 때 한 점을 받는다 (끌다가 점이 두 개 찍히지 않게).
if (!this.isSelectToolActive()) return;
// AutoCAD처럼 누른 자리에서 사각형이 시작되도록 고르기(쉼 · 명령 안 고르기 단계)에만 첫 점을 미리 보낸다.
// 다른 물음은 예전처럼 놓을 때 한 점을 받는다 (끌다가 점이 두 개 찍히지 않게).
if (!selectingActor()) return;
this.leftPressLocation = this.getCanvasPoint(evt);
this.sendMouseClick(evt);
}
@@ -12,14 +12,15 @@ const wheel = (deltaY: number, deltaMode = 0, ctrlKey = false) =>
describe('CAD 휠 = 늘 줌(153-3)', () => {
it('100 · 120 배수가 아닌 델타(확대율 90 % · 110 % · 트랙패드 잔 델타)도 줌', () => {
// 기본 밂(337) — 앞으로(deltaY < 0) 굴리면 확대(음수 delta)
for (const deltaY of [100, 120, 111.1, 90.9, 4, -100, -111.1, -4]) {
expect(wheel(deltaY)).toEqual({ delta: -deltaY, exponent: WHEEL_ZOOM_EXPONENT });
expect(wheel(deltaY)).toEqual({ delta: deltaY, exponent: WHEEL_ZOOM_EXPONENT });
}
});
it('줄 · 쪽 단위는 픽셀로 환산', () => {
expect(wheel(3, 1)).toEqual({ delta: -3 * WHEEL_LINE_PX, exponent: WHEEL_ZOOM_EXPONENT });
expect(wheel(-1, 2)).toEqual({ delta: WHEEL_PAGE_PX, exponent: WHEEL_ZOOM_EXPONENT });
expect(wheel(3, 1)).toEqual({ delta: 3 * WHEEL_LINE_PX, exponent: WHEEL_ZOOM_EXPONENT });
expect(wheel(-1, 2)).toEqual({ delta: -WHEEL_PAGE_PX, exponent: WHEEL_ZOOM_EXPONENT });
});
it('ctrl+휠(핀치)은 핀치 지수 · 벌리면(deltaY < 0) 확대(332)', () => {
@@ -18,7 +18,7 @@ export function wheelZoom(
const unitPx = evt.deltaMode === 1 ? WHEEL_LINE_PX : evt.deltaMode === 2 ? WHEEL_PAGE_PX : 1;
const deltaY = evt.deltaY * unitPx;
if (deltaY === 0) return null;
// 방향 = ☰ [설정] 휠 확대 방향(기본 당김 = 몸 쪽으로 굴리면 확대 · 모든 화면 일체) ·
// 방향 = ☰ [설정] 휠 확대 방향(기본 밂 = 앞으로 굴리면 확대 · 오토캐드 · 모든 화면 일체) ·
// 트랙패드 핀치는 늘 벌리면 확대. zoomScreen 은 음수 = 확대.
// 핀치(ctrl+휠)는 델타가 잘아 지수만 크게.
const size = Math.abs(deltaY);
@@ -0,0 +1,45 @@
/**
* 직교(337 마 · H8) — F8 이면 기준점에서 가로 · 세로로 늘 내림(커서 · 누름 · 거리 숫자 방향) ·
* Shift 누른 동안 뒤집음(오토캐드 임시 우선 키 · 마우스 사건 shiftKey 로 · keyup 안 씀) ·
* 객체 스냅 점 · 친 좌표 · 각도 잠금은 안 덮음. 직교인 동안 극 추적 가이드 점은 스냅으로 안 침.
*/
import { Point } from '@flatten-js/core';
import { compact } from 'es-toolkit';
import { SNAP_POINT_DISTANCE } from '../App.consts.ts';
import { type SnapPoint, SnapPointType } from '../App.types.ts';
import { getClosestSnapPointWithinRadius } from '../helpers/get-closest-snap-point.ts';
import {
getAngleStep,
getScreenCanvasDrawController,
getSnapPoint,
getSnapPointOnAngleGuide,
} from '../state.ts';
let shiftHeld = false;
/** 마우스 사건마다 Shift 상태를 적어 둠(커서 미리보기 · 거리 숫자 방향이 읽음) */
export const noteShift = (evt: { shiftKey?: boolean }): void => {
shiftHeld = !!evt.shiftKey;
};
/** 직교 켬 = F8(각 간격 90) XOR Shift */
export const orthoOn = (shift: boolean = shiftHeld): boolean => (getAngleStep() === 90) !== shift;
/** 기준점에서 가로 · 세로 중 커서에 가까운 쪽으로 내림 */
export const orthoPoint = (base: Point, point: Point): Point =>
Math.abs(point.x - base.x) >= Math.abs(point.y - base.y)
? new Point(point.x, base.y)
: new Point(base.x, point.y);
/** 그 자리에서 누르면 잡힐 스냅 점(누름과 같은 셈 · 반경 = 화면 픽셀) */
export function snapAt(world: Point): SnapPoint | null {
return getClosestSnapPointWithinRadius(
compact([getSnapPoint(), getSnapPointOnAngleGuide()]),
world,
SNAP_POINT_DISTANCE / getScreenCanvasDrawController().getScreenScale()
);
}
/** 스냅 점이 직교를 막는가 — 극 추적 가이드 점은 직교 중엔 안 막음 */
export const isObjectSnap = (snap: SnapPoint | null): boolean =>
!!snap && snap.type !== SnapPointType.AngleGuide;
@@ -0,0 +1,26 @@
/**
* 고르기 중인 선택 도구 찾기(337 마) — 쉴 때 맨 위 선택 도구 · 명령 안 고르기 단계 = 그 명령이 부른 자식 선택 도구.
* 누를 때 첫 점(끌기 상자) · 스침 강조 · 고르기 키워드가 이 한 곳으로 같은 선택 도구를 찾는다.
*/
import type { AnyActorRef } from 'xstate';
import { getActiveToolActor } from '../state.ts';
import { Tool } from '../tools.ts';
import { SelectState } from '../tools/select-tool.ts';
const isSelect = (actor: AnyActorRef | undefined): boolean =>
actor?.getSnapshot()?.context?.type === Tool.SELECT;
/** 지금 고르기를 받는 선택 도구 — 없으면 null(점 · 숫자 물음) */
export function selectingActor(): AnyActorRef | null {
const top = getActiveToolActor() as AnyActorRef | null;
if (!top) return null;
if (isSelect(top)) return top;
const children = Object.values(top.getSnapshot()?.children ?? {}) as AnyActorRef[];
return (
children.find((child) => isSelect(child) && child.getSnapshot()?.status === 'active') ?? null
);
}
/** 선택 상자의 맞은 모서리를 기다리는 중 */
export const isWaitingForSecondSelectPoint = (): boolean =>
selectingActor()?.getSnapshot()?.value === SelectState.WAITING_FOR_SECOND_SELECT_POINT;
@@ -0,0 +1,48 @@
/**
* 보기 바꿈(337 마) — 범위 줌 · 앞 화면(ZOOM 이전(P)) · 휠 두 번 = 범위.
* 앞 화면 기억 = ZOOM · 휠 두 번 · ⛶ 로 바꾼 것만(휠 굴림 · 팬은 안 쌓음) · 오토캐드처럼 열 개.
*/
import type { Point } from '@flatten-js/core';
import { getScreenCanvasDrawController } from '../state.ts';
type View = { scale: number; offset: Point };
const MAX_VIEWS = 10;
/** 휠 두 번 — 이 안에 같은 자리(화면 픽셀)를 다시 누르면 */
const DOUBLE_MS = 400;
const DOUBLE_PX = 5;
const views: View[] = [];
let lastMiddle: { time: number; x: number; y: number } | null = null;
/** 지금 화면을 앞 화면으로 적고 바꿈 */
export function changeView(change: () => void): void {
const controller = getScreenCanvasDrawController();
views.push({ scale: controller.getScreenScale(), offset: controller.getScreenOffset() });
if (views.length > MAX_VIEWS) views.shift();
change();
}
/** 범위 줌 — 도면 전체를 화면에 */
export const zoomExtents = (): void =>
changeView(() => getScreenCanvasDrawController().zoomToFitScreen());
/** 앞 화면으로 — 없으면 false */
export function zoomPrevious(): boolean {
const view = views.pop();
if (!view) return false;
const controller = getScreenCanvasDrawController();
controller.setScreenScale(view.scale);
controller.setScreenOffset(view.offset);
return true;
}
/** 가운데 단추 누름 — 두 번째 누름이면 true(그 뒤 다시 처음부터) */
export function isMiddleDouble(point: { x: number; y: number }, time = Date.now()): boolean {
const double =
!!lastMiddle &&
time - lastMiddle.time <= DOUBLE_MS &&
Math.hypot(point.x - lastMiddle.x, point.y - lastMiddle.y) <= DOUBLE_PX;
lastMiddle = double ? null : { time, x: point.x, y: point.y };
return double;
}