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:
@@ -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;
|
||||
}
|
||||
Reference in New Issue
Block a user