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:
@@ -6,6 +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';
|
||||
|
||||
const zoomBy = (factor: number): string => {
|
||||
const controller = getScreenCanvasDrawController();
|
||||
@@ -45,7 +46,7 @@ export const VIEW_COMMANDS: CadCommand[] = [
|
||||
glyph: '⛶',
|
||||
hint: '도면 전체가 보이도록 화면을 맞춘다',
|
||||
run: () => {
|
||||
getScreenCanvasDrawController().zoomToFitScreen();
|
||||
zoomExtents(); // 앞 화면 기억(이전(P))
|
||||
return '도면 전체 보기';
|
||||
},
|
||||
},
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
/** 화면 우하단 탐색 막대 (줌 표시·전체 보기) — 제목줄이 없는 화면(M02)은 되돌리기 · 다시 하기도 */
|
||||
import type { FC } from 'react';
|
||||
import { runCommandInput } from '../commands/run-command';
|
||||
import { zoomExtents } from '../inputController/zoom-prompt';
|
||||
import {
|
||||
getScreenCanvasDrawController,
|
||||
isDrawingReadOnly,
|
||||
@@ -28,7 +29,7 @@ export const ViewControls: FC = () => {
|
||||
<button
|
||||
type="button"
|
||||
className="cad-view-controls__fit"
|
||||
onClick={() => runCommandInput('ZOOM')}
|
||||
onClick={() => zoomExtents()}
|
||||
title="범위 줌 (Z) — 도면 전체를 화면에 맞춘다"
|
||||
>
|
||||
⛶
|
||||
|
||||
@@ -5,12 +5,17 @@ import {
|
||||
getLayerById,
|
||||
getScreenCanvasDrawController,
|
||||
getShouldDrawHelpers,
|
||||
getSnapEnabled,
|
||||
getSnapTrackingEnabled,
|
||||
setAngleGuideEntities,
|
||||
setSnapPoint,
|
||||
setSnapPointOnAngleGuide,
|
||||
} from '../state.ts';
|
||||
import { HOVERED_SNAP_POINT_TIME, SNAP_POINT_DISTANCE } from '../App.consts.ts';
|
||||
import {
|
||||
HOVERED_SNAP_POINT_TIME,
|
||||
SNAP_ANGLE_DISTANCE,
|
||||
SNAP_POINT_DISTANCE,
|
||||
} from '../App.consts.ts';
|
||||
import { getDrawHelpers } from './get-draw-guides.ts';
|
||||
import { queryEntitiesNearPoint } from './spatial-index.ts';
|
||||
import { compact } from 'es-toolkit';
|
||||
@@ -26,19 +31,24 @@ export function calculateAngleGuidesAndSnapPoints() {
|
||||
// 스냅 후보: 공간 인덱스로 마우스 주변만 조회 (전 엔티티 O(n²) 교차 계산 제거),
|
||||
// 잠금 레이어(b08-frame 등 참조용)는 스냅 대상에서 제외한다.
|
||||
const maxSnapDistance = SNAP_POINT_DISTANCE / screenScale;
|
||||
const entities = queryEntitiesNearPoint(
|
||||
worldMouseLocation.x,
|
||||
worldMouseLocation.y,
|
||||
maxSnapDistance * 2
|
||||
).filter((entity) => !getLayerById(entity.layerId)?.isLocked);
|
||||
// 객체 스냅(F3)을 끄면 객체 스냅점 · 추적만 빠짐 — 직교 · 극 가이드는 늘(H8)
|
||||
const snapEnabled = getSnapEnabled();
|
||||
const entities = snapEnabled
|
||||
? queryEntitiesNearPoint(
|
||||
worldMouseLocation.x,
|
||||
worldMouseLocation.y,
|
||||
maxSnapDistance * 2
|
||||
).filter((entity) => !getLayerById(entity.layerId)?.isLocked)
|
||||
: [];
|
||||
const hoveredSnapPoints = getHoveredSnapPoints();
|
||||
|
||||
// 객체 스냅 추적(F11)을 끄면 머문 스냅점에서 정렬 가이드를 뻗지 않는다
|
||||
const eligibleHoveredSnapPoints = getSnapTrackingEnabled()
|
||||
? hoveredSnapPoints.filter(
|
||||
(hoveredSnapPoint) => hoveredSnapPoint.milliSecondsHovered > HOVERED_SNAP_POINT_TIME
|
||||
)
|
||||
: [];
|
||||
const eligibleHoveredSnapPoints =
|
||||
snapEnabled && getSnapTrackingEnabled()
|
||||
? hoveredSnapPoints.filter(
|
||||
(hoveredSnapPoint) => hoveredSnapPoint.milliSecondsHovered > HOVERED_SNAP_POINT_TIME
|
||||
)
|
||||
: [];
|
||||
|
||||
const eligibleHoveredPoints = eligibleHoveredSnapPoints.map(
|
||||
(hoveredSnapPoint) => hoveredSnapPoint.snapPoint.point
|
||||
@@ -50,7 +60,8 @@ export function calculateAngleGuidesAndSnapPoints() {
|
||||
compact([getAngleGuideOriginPoint(), ...eligibleHoveredPoints]),
|
||||
worldMouseLocation,
|
||||
angleStep,
|
||||
maxSnapDistance
|
||||
maxSnapDistance,
|
||||
SNAP_ANGLE_DISTANCE / screenScale
|
||||
);
|
||||
setAngleGuideEntities(angleGuides);
|
||||
setSnapPoint(entitySnapPoint);
|
||||
|
||||
@@ -13,6 +13,7 @@ import {
|
||||
getShouldDrawCursor,
|
||||
getSnapPoint,
|
||||
getSnapPointOnAngleGuide,
|
||||
isDrawingReadOnly,
|
||||
isOriginMarker,
|
||||
isViewOnly,
|
||||
} from '../state';
|
||||
@@ -53,7 +54,8 @@ export function draw(drawController: ScreenCanvasDrawController) {
|
||||
|
||||
if (isOriginMarker()) drawOriginMarker(drawController);
|
||||
drawGuide(drawController); // 작도 영역 점선(169-1) — 겹판이라 저장 · 인쇄 안 됨
|
||||
drawGrips(drawController, getSelectedEntities());
|
||||
// 보기 전용 · 확정 도면은 그립 없음(H2 — 집지도 못함)
|
||||
if (!isViewOnly() && !isDrawingReadOnly()) drawGrips(drawController, getSelectedEntities());
|
||||
drawHelpers(drawController, getAngleGuideEntities());
|
||||
drawEntities(drawController, getGhostHelperEntities());
|
||||
drawDebugEntities(drawController, getDebugEntities());
|
||||
|
||||
@@ -16,13 +16,15 @@ import { getIntersectionPoints } from './get-intersection-points';
|
||||
* @param worldMouseLocation the current mouse location
|
||||
* @param angleStep the angle in degrees at which the angle guides should be drawn
|
||||
* @param maxSnapDistance The distance that the mouse can snap to a snap point or angle guide
|
||||
* @param angleSnapDistance 가이드를 잡는 거리(도면 단위) — 화면 픽셀 / 배율로 넘김(H9)
|
||||
*/
|
||||
export function getDrawHelpers(
|
||||
entities: Entity[],
|
||||
anglePoints: Point[],
|
||||
worldMouseLocation: Point,
|
||||
angleStep: number,
|
||||
maxSnapDistance: number
|
||||
maxSnapDistance: number,
|
||||
angleSnapDistance: number = SNAP_ANGLE_DISTANCE
|
||||
): {
|
||||
angleGuides: LineEntity[];
|
||||
entitySnapPoint: SnapPoint | null;
|
||||
@@ -39,7 +41,7 @@ export function getDrawHelpers(
|
||||
|
||||
const closestLineInfo = findClosestEntity<LineEntity>(worldMouseLocation, angleGuideLines);
|
||||
|
||||
if (closestLineInfo.distance < SNAP_ANGLE_DISTANCE) {
|
||||
if (closestLineInfo.distance < angleSnapDistance) {
|
||||
angleGuides.push(closestLineInfo.entity);
|
||||
nearestAngleSnapPoints.push({
|
||||
point: closestLineInfo.segment.start,
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { type Point, Vector } from '@flatten-js/core';
|
||||
import { getAngleLock, pointAtLock, projectOnLock } from '../inputController/input-angle-lock.ts';
|
||||
import { isObjectSnap, orthoOn, orthoPoint, snapAt } from '../inputController/ortho.ts';
|
||||
import {
|
||||
type AbsolutePointInputEvent,
|
||||
ActorEvent,
|
||||
@@ -13,7 +14,7 @@ import {
|
||||
/**
|
||||
* Various tools need to convert user input into a point
|
||||
* This function handles mouse click event, number events and in the future absoluteCoordinates and relativeCoordinate events
|
||||
* 각도 고정(`<30`) 중이면 커서 · 클릭 · 숫자를 기준점에서 고정 방향으로.
|
||||
* 각도 고정(`<30`) 중이면 커서 · 클릭 · 숫자를 기준점에서 고정 방향으로 · 직교(F8 · Shift)면 가로 · 세로로.
|
||||
* @param startPoint
|
||||
* @param event
|
||||
*/
|
||||
@@ -21,11 +22,19 @@ export function getPointFromEvent(startPoint: Point | null, event: PointInputEve
|
||||
const locked = startPoint !== null && getAngleLock() !== null;
|
||||
if (event.type === ActorEvent.DRAW) {
|
||||
const mouse = (event as DrawEvent).drawController.getWorldMouseLocation();
|
||||
return locked ? projectOnLock(startPoint, mouse) : mouse;
|
||||
if (locked) return projectOnLock(startPoint, mouse);
|
||||
// 직교 — 누르면 객체 스냅에 잡힐 자리는 그대로(누름과 같게)
|
||||
return startPoint && orthoOn() && !isObjectSnap(snapAt(mouse))
|
||||
? orthoPoint(startPoint, mouse)
|
||||
: mouse;
|
||||
}
|
||||
if (event.type === ActorEvent.MOUSE_CLICK) {
|
||||
const click = (event as MouseClickEvent).worldMouseLocation;
|
||||
return locked ? projectOnLock(startPoint, click) : click;
|
||||
const clickEvent = event as MouseClickEvent & { snapped?: boolean };
|
||||
const click = clickEvent.worldMouseLocation;
|
||||
if (locked) return projectOnLock(startPoint, click);
|
||||
return startPoint && !clickEvent.snapped && orthoOn(!!clickEvent.holdingShift)
|
||||
? orthoPoint(startPoint, click)
|
||||
: click;
|
||||
}
|
||||
if (event.type === ActorEvent.NUMBER_INPUT) {
|
||||
if (!startPoint) {
|
||||
@@ -33,11 +42,11 @@ export function getPointFromEvent(startPoint: Point | null, event: PointInputEve
|
||||
}
|
||||
const distance = (event as NumberInputEvent).value;
|
||||
if (locked) return pointAtLock(startPoint, distance);
|
||||
// Direction indicated by the startPoint and the mouse location
|
||||
const direction = new Vector(
|
||||
event.worldMouseLocation.x - startPoint.x,
|
||||
event.worldMouseLocation.y - startPoint.y
|
||||
);
|
||||
// Direction indicated by the startPoint and the mouse location — 직교면 가로 · 세로
|
||||
const toward = orthoOn()
|
||||
? orthoPoint(startPoint, event.worldMouseLocation)
|
||||
: event.worldMouseLocation;
|
||||
const direction = new Vector(toward.x - startPoint.x, toward.y - startPoint.y);
|
||||
const unitDirection = direction.normalize();
|
||||
return startPoint.translate(unitDirection.multiply(distance));
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
/**
|
||||
* 그립 편집 도구 — 선택 도구에서 그립을 집으면 켜진다.
|
||||
* 클릭 한 번으로 그 그립을 옮긴다 (AutoCAD처럼 끌지 않고 집어서 놓는 방식).
|
||||
* 그립 편집 도구 — 선택 도구에서 그립을 집으면 켜진다(뜨거운 그립).
|
||||
* 오토캐드처럼 누르고-옮기고-누르기 · 끌어 놓기 둘 다(누름 · 뗌 가름은 input-controller) ·
|
||||
* 거리 숫자 = 그립 점에서 커서 쪽(N8) · 좌표 · Esc = 그립 놓음.
|
||||
*/
|
||||
import type { Point } from '@flatten-js/core';
|
||||
import { Actor, assign, createMachine } from 'xstate';
|
||||
@@ -63,7 +64,7 @@ export const gripEditToolStateMachine = createMachine(
|
||||
},
|
||||
[GripEditState.WAITING_FOR_TARGET_POINT]: {
|
||||
description: '그립을 옮길 위치를 지정한다',
|
||||
meta: { instructions: '그립을 옮길 위치를 지정하십시오' },
|
||||
meta: { instructions: '** 신축 ** 신축점 지정' },
|
||||
on: {
|
||||
[ActorEvent.DRAW]: { actions: GripEditAction.DRAW_TEMP_GRIP_EDIT },
|
||||
[ActorEvent.MOUSE_CLICK]: {
|
||||
@@ -75,6 +76,9 @@ export const gripEditToolStateMachine = createMachine(
|
||||
[ActorEvent.RELATIVE_POINT_INPUT]: {
|
||||
actions: [GripEditAction.APPLY_GRIP_EDIT, GripEditAction.SWITCH_TO_SELECT_TOOL],
|
||||
},
|
||||
[ActorEvent.NUMBER_INPUT]: {
|
||||
actions: [GripEditAction.APPLY_GRIP_EDIT, GripEditAction.SWITCH_TO_SELECT_TOOL],
|
||||
},
|
||||
[ActorEvent.ESC]: { actions: GripEditAction.SWITCH_TO_SELECT_TOOL },
|
||||
},
|
||||
},
|
||||
|
||||
@@ -22,11 +22,15 @@ import {
|
||||
getLayers,
|
||||
getSelectedEntities,
|
||||
getSelectedEntityIds,
|
||||
getActiveToolActor,
|
||||
isDrawingReadOnly,
|
||||
isEntitySelected,
|
||||
isViewOnly,
|
||||
setEntities,
|
||||
setGhostHelperEntities,
|
||||
setSelectedEntityIds,
|
||||
} from '../state';
|
||||
import { Tool } from '../tools';
|
||||
import { startGripEdit } from './grip-edit-tool';
|
||||
import type { SelectContext } from './select-tool';
|
||||
import type { MouseClickEvent } from './tool.types';
|
||||
@@ -52,8 +56,11 @@ export function handleFirstSelectionPoint(
|
||||
context: SelectContext,
|
||||
event: MouseClickEvent
|
||||
): SelectContext {
|
||||
// 선택된 객체의 그립이 먼저다 — 집으면 그립 편집으로 넘어간다
|
||||
const gripHit = findGripAt(getSelectedEntities(), event.worldMouseLocation, pickRadius());
|
||||
// 선택된 객체의 그립이 먼저다 — 집으면 그립 편집으로 넘어간다.
|
||||
// 쉴 때(맨 위 = 선택 도구)만 · 명령 안 고르기는 그립 없음(N7) · 보기 전용 · 확정 도면은 막음(H2)
|
||||
const gripHit = gripsEditable()
|
||||
? findGripAt(getSelectedEntities(), event.worldMouseLocation, pickRadius())
|
||||
: null;
|
||||
if (gripHit) {
|
||||
if (event.holdingCtrl) {
|
||||
// Ctrl+클릭 => 폴리선 정점 제거 (다기능 그립)
|
||||
@@ -75,28 +82,10 @@ export function handleFirstSelectionPoint(
|
||||
|
||||
// Mouse is close to entity and is not dragging a rectangle
|
||||
if (pickedEntity) {
|
||||
// Select the entity close to the mouse
|
||||
const closestEntity = pickedEntity;
|
||||
if (!event.holdingCtrl && !event.holdingShift) {
|
||||
// 그룹으로 묶인 객체는 하나만 집어도 함께 선택된다 (GROUP)
|
||||
setSelectedEntityIds(expandSelectionWithGroups([closestEntity.id]));
|
||||
} else if (event.holdingCtrl) {
|
||||
// ctrl => toggle selection
|
||||
if (isEntitySelected(closestEntity)) {
|
||||
// Remove the entity from the selection
|
||||
setSelectedEntityIds(getSelectedEntityIds().filter((id) => id !== closestEntity.id));
|
||||
} else {
|
||||
// Add the entity to the selection
|
||||
setSelectedEntityIds(
|
||||
expandSelectionWithGroups([...getSelectedEntityIds(), closestEntity.id])
|
||||
);
|
||||
}
|
||||
} else {
|
||||
// shift => add to selection
|
||||
setSelectedEntityIds(
|
||||
expandSelectionWithGroups([...getSelectedEntityIds(), closestEntity.id])
|
||||
);
|
||||
}
|
||||
// 오토캐드 PICKADD 2 — 누름 = 더함 · Shift = 뺌 · Ctrl = 넣고 빼기(그룹은 함께 · GROUP)
|
||||
const ids = expandSelectionWithGroups([pickedEntity.id]);
|
||||
const remove = event.holdingShift || (event.holdingCtrl && isEntitySelected(pickedEntity));
|
||||
setSelectedEntityIds(combineSelection(ids, remove ? 'remove' : 'add'));
|
||||
return {
|
||||
...context,
|
||||
startPoint: null,
|
||||
@@ -111,16 +100,37 @@ export function handleFirstSelectionPoint(
|
||||
};
|
||||
}
|
||||
|
||||
/** 앞 고름에 더함 · 뺌 · 넣고 빼기 */
|
||||
export function combineSelection(ids: string[], mode: 'add' | 'remove' | 'toggle'): string[] {
|
||||
const before = getSelectedEntityIds();
|
||||
if (mode === 'remove') return before.filter((id) => !ids.includes(id));
|
||||
if (mode === 'toggle') {
|
||||
return [
|
||||
...before.filter((id) => !ids.includes(id)),
|
||||
...ids.filter((id) => !before.includes(id)),
|
||||
];
|
||||
}
|
||||
return [...before, ...ids.filter((id) => !before.includes(id))];
|
||||
}
|
||||
|
||||
/** 그립 집기 · 그리기 — 쉴 때 · 고칠 수 있는 도면만 */
|
||||
export const gripsEditable = (): boolean =>
|
||||
!isViewOnly() &&
|
||||
!isDrawingReadOnly() &&
|
||||
getActiveToolActor()?.getSnapshot()?.context?.type === Tool.SELECT;
|
||||
|
||||
/** 상자 고르기 — 상자 = 앞 고름 ∪ 상자 · Shift = 앞 고름 − 상자 · Ctrl = 넣고 빼기(오토캐드 PICKADD 2) */
|
||||
export function selectEntitiesInsideRectangle(
|
||||
startPoint: Point,
|
||||
endPoint: Point,
|
||||
holdingCtrl: boolean
|
||||
// holdingShift: boolean, // TODO implement add to selection using shift
|
||||
holdingCtrl: boolean,
|
||||
holdingShift = false
|
||||
): void {
|
||||
// Finish the selection
|
||||
const activeSelectionRectangle = new RectangleEntity(getActiveLayerId(), startPoint, endPoint);
|
||||
const intersectionSelection = getIsIntersectionSelection(activeSelectionRectangle, startPoint);
|
||||
const newSelectedEntityIds: string[] = compact(
|
||||
const box = activeSelectionRectangle.getBoundingBox() as Box;
|
||||
const boxIds: string[] = compact(
|
||||
getEntities().map((entity): string | null => {
|
||||
const layer = getLayers().find((layer) => layer.id === entity.layerId);
|
||||
if (!layer) {
|
||||
@@ -128,44 +138,17 @@ export function selectEntitiesInsideRectangle(
|
||||
console.error('Failed to find layer for entity', entity);
|
||||
return null;
|
||||
}
|
||||
if (intersectionSelection) {
|
||||
// Select all entities that are inside the selection rectangle or intersect with the selection rectangle
|
||||
if (
|
||||
entity.intersectsWithBox(activeSelectionRectangle.getBoundingBox() as Box) ||
|
||||
entity.isContainedInBox(activeSelectionRectangle.getBoundingBox() as Box)
|
||||
) {
|
||||
if (holdingCtrl) {
|
||||
if (isEntitySelected(entity)) {
|
||||
return null;
|
||||
}
|
||||
if (!layer.isLocked) {
|
||||
return entity.id;
|
||||
}
|
||||
}
|
||||
if (!layer.isLocked) {
|
||||
return entity.id;
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// Select only entities that are completely inside the selection rectangle
|
||||
if (entity.isContainedInBox(activeSelectionRectangle.getBoundingBox() as Box)) {
|
||||
if (holdingCtrl) {
|
||||
if (isEntitySelected(entity)) {
|
||||
return null;
|
||||
}
|
||||
if (!layer.isLocked) {
|
||||
return entity.id;
|
||||
}
|
||||
}
|
||||
if (!layer.isLocked) {
|
||||
return entity.id;
|
||||
}
|
||||
}
|
||||
}
|
||||
return null;
|
||||
if (layer.isLocked) return null;
|
||||
// 걸침(오→왼) = 걸치거나 든 것 · 창(왼→오) = 통째로 든 것만
|
||||
const inside =
|
||||
entity.isContainedInBox(box) || (intersectionSelection && entity.intersectsWithBox(box));
|
||||
return inside ? entity.id : null;
|
||||
})
|
||||
);
|
||||
setSelectedEntityIds(expandSelectionWithGroups(newSelectedEntityIds));
|
||||
const ids = expandSelectionWithGroups(boxIds);
|
||||
setSelectedEntityIds(
|
||||
combineSelection(ids, holdingShift ? 'remove' : holdingCtrl ? 'toggle' : 'add')
|
||||
);
|
||||
}
|
||||
|
||||
export function drawTempSelectionRectangle(startPoint: Point, endPoint: Point) {
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import type { Point } from '@flatten-js/core';
|
||||
import {
|
||||
getNotSelectedEntities,
|
||||
isDrawingReadOnly,
|
||||
isViewOnly,
|
||||
setEntities,
|
||||
setGhostHelperEntities,
|
||||
setSelectedEntityIds,
|
||||
@@ -159,12 +161,13 @@ export const selectToolStateMachine = createMachine(
|
||||
selectEntitiesInsideRectangle(
|
||||
context.startPoint,
|
||||
(event as MouseClickEvent).worldMouseLocation,
|
||||
(event as MouseClickEvent).holdingCtrl
|
||||
// (event as MouseClickEvent).holdingShift,
|
||||
(event as MouseClickEvent).holdingCtrl,
|
||||
(event as MouseClickEvent).holdingShift
|
||||
);
|
||||
setGhostHelperEntities([]);
|
||||
},
|
||||
DELETE_SELECTED_ENTITIES: () => {
|
||||
if (isViewOnly() || isDrawingReadOnly()) return; // 보기 전용 · 확정 도면은 안 지움(H2)
|
||||
setEntities(getNotSelectedEntities(), true);
|
||||
setSelectedEntityIds([]);
|
||||
setGhostHelperEntities([]);
|
||||
|
||||
Reference in New Issue
Block a user