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
+4 -4
View File
@@ -4,7 +4,7 @@
*
* - 값 = `aislo:pref:input-prefs`(등록표 `input-prefs`) JSON 한 벌 · 부를 때마다 읽음(새로고침 없이 바로 따름)
* - 없음 · 깨짐 · Node(저장소 없음) = 기본값(= 지금 동작) · 의존 없음 · 불러올 때 window 안 만짐(CAD 도 import)
* - 휠 확대 방향 하나로 모든 화면 일체 — 당김 = 휠을 몸 쪽으로(deltaY > 0) 굴리면 확대
* - 휠 확대 방향 하나로 모든 화면 일체 — 기본 밂 = 휠을 앞으로(deltaY < 0) 굴리면 확대(오토캐드) · 당김 = 몸 쪽으로
* - 핀치(트랙패드 · 손가락)는 설정과 상관없이 늘 벌리면 확대(deltaY < 0)
* ========================================================================== */
@@ -20,7 +20,7 @@ export interface InputPrefs {
/** `stateKey("input-prefs")` 와 같은 키(시험이 맞춰 봄) — 등록표를 불러오면 window 가 딸려 와서 글자로 둠 */
export const INPUT_PREFS_KEY = "aislo:pref:input-prefs";
export const DEFAULT_INPUT_PREFS: InputPrefs = { wheelZoom: "pull", cadAliases: {} };
export const DEFAULT_INPUT_PREFS: InputPrefs = { wheelZoom: "push", cadAliases: {} };
const cleanAliases = (raw: unknown): Record<string, string[]> => {
const out: Record<string, string[]> = {};
@@ -42,7 +42,7 @@ export function inputPrefs(): InputPrefs {
if (!raw) return { ...DEFAULT_INPUT_PREFS, cadAliases: {} };
const value = JSON.parse(raw) as Partial<InputPrefs> | null;
return {
wheelZoom: value?.wheelZoom === "push" ? "push" : "pull",
wheelZoom: value?.wheelZoom === "pull" ? "pull" : "push",
cadAliases: cleanAliases(value?.cadAliases),
};
} catch {
@@ -52,7 +52,7 @@ export function inputPrefs(): InputPrefs {
/** 휠 한 번이 확대인가 — 설정 방향을 따름 */
export const wheelIn = (deltaY: number): boolean =>
inputPrefs().wheelZoom === "push" ? deltaY < 0 : deltaY > 0;
inputPrefs().wheelZoom === "pull" ? deltaY > 0 : deltaY < 0;
/** 트랙패드 핀치 — 브라우저가 Ctrl+휠 · 잘게 쪼갠 값으로 냄
* ponytail: |deltaY| < 50 로 마우스 Ctrl+휠(한 칸 ≥ 약 100)과 가름 · 잘게 도는 마우스는 핀치로 봄 */
@@ -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([]);
+11 -11
View File
@@ -1,6 +1,6 @@
/**
* CAD 설정 배선(PLAN 332-3) — ☰ [설정] 휠 확대 방향 · 내 별칭(`A00_Common/input_prefs.ts`).
* 휠 = 기본 당김(지금과 같음) · 밀기 저장 뒤 뒤집힘 · 핀치는 늘 벌리면 확대.
* 휠 = 기본 밂(앞으로 굴리면 확대 · 오토캐드 · 337) · 당김 저장 뒤 뒤집힘 · 핀치는 늘 벌리면 확대.
* 별칭 = 명령 이름 > 내 별칭 > 기본 별칭 · 저장 즉시 따름.
* 실행: cd B07_DesignDetail/openwebcad && npx vitest run --dir ../../resources/tester/cad input-prefs-cad
*/
@@ -35,26 +35,26 @@ const wheel = (deltaY: number, ctrlKey = false) =>
wheelZoom({ deltaY, deltaMode: 0, ctrlKey })?.delta;
describe("휠 확대 방향", () => {
it("저장 없음 = 당김(몸 쪽 deltaY > 0 → 확대 = 음수 delta) — 지금과 같음", () => {
expect(wheel(100)).toBe(-100);
expect(wheel(-100)).toBe(100);
it("저장 없음 = 밂(앞으로 deltaY < 0 → 확대 = 음수 delta) — 오토캐드(337)", () => {
expect(wheel(-100)).toBe(-100);
expect(wheel(100)).toBe(100);
});
it("밀기 저장 → 뒤집힘 · 깨진 값 = 당김", () => {
savePrefs({ wheelZoom: "push" });
expect(wheel(100)).toBe(100);
expect(wheel(-100)).toBe(-100);
store.set(INPUT_PREFS_KEY, "{깨짐");
it("당김 저장 → 뒤집힘 · 깨진 값 = 밂", () => {
savePrefs({ wheelZoom: "pull" });
expect(wheel(100)).toBe(-100);
expect(wheel(-100)).toBe(100);
store.set(INPUT_PREFS_KEY, "{깨짐");
expect(wheel(100)).toBe(100);
});
it("트랙패드 핀치(ctrl · 잔 델타)는 설정과 상관없이 벌리면(deltaY < 0) 확대", () => {
expect(wheel(-7, true)).toBe(-7);
expect(wheel(7, true)).toBe(7);
savePrefs({ wheelZoom: "push" });
savePrefs({ wheelZoom: "pull" });
expect(wheel(-7, true)).toBe(-7);
// 마우스 Ctrl+휠(큰 델타)은 설정 방향
expect(wheel(100, true)).toBe(100);
expect(wheel(100, true)).toBe(-100);
});
});
+308
View File
@@ -0,0 +1,308 @@
/**
* CAD 마우스 · 고르기 · 그립 · 직교 · 휠 — 오토캐드 조작(PLAN 337-2 마 · 요구 229-3 · 설계 4 장 · 6-3 마).
* 누름 더함 · Shift 뺌 · 상자 더함/뺌 · 명령 중 끌기 상자 · 스침 · N7 · 그립 누름-뗌 대기 · 끌어 놓음 · 그립 숫자 ·
* H2 문 · 직교 강제 · Shift 임시 직교 · 스냅 점 안 덮음 · F3 꺼도 직교 · H9 · 휠 두 번 범위 · 휠 기본 밂.
* 시험 쪽 flatten-js 는 다른 사본 — 점은 앱 코드(LINE 도구)로만 만들고 숫자로 판정.
* 실행: cd B07_DesignDetail/openwebcad && npx vitest run --dir ../../resources/tester/cad mouse-acad
*/
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
vi.mock("react-toastify", () => ({
toast: Object.assign(vi.fn(), { error: vi.fn(), info: vi.fn(), warn: vi.fn(), success: vi.fn() }),
}));
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 { wheelZoom } from "../../../B07_DesignDetail/openwebcad/src/inputController/input-wheel";
import {
getActiveToolActor,
getAngleGuideEntities,
getEntities,
getHighlightedEntityIds,
getScreenCanvasDrawController,
getSelectedEntityIds,
setActiveToolActor,
setAngleStep,
setEntities,
setHostReadOnly,
setSelectedEntityIds,
setSnapEnabled,
} from "../../../B07_DesignDetail/openwebcad/src/state";
import { Tool } from "../../../B07_DesignDetail/openwebcad/src/tools";
import { selectToolStateMachine } from "../../../B07_DesignDetail/openwebcad/src/tools/select-tool";
import { TOOL_STATE_MACHINES } from "../../../B07_DesignDetail/openwebcad/src/commands/registry";
import { runCommandInput } from "../../../B07_DesignDetail/openwebcad/src/commands/run-command";
import { initApplication } from "../../../B07_DesignDetail/openwebcad/test/helpers/init-application";
import { CANVAS_HEIGHT } from "../../../B07_DesignDetail/openwebcad/test/helpers/tests.consts";
type Xy = { x: number; y: number };
type Seg = { start: Xy; end: Xy };
type Mods = { shiftKey?: boolean; ctrlKey?: boolean; button?: number };
let controller: InputController;
/** 세계 (x, y) → 마우스 사건 — 시험 캔버스 화면 원점 0 · y 위 + · 배율 따름 */
const ev = (x: number, y: number, mods: Mods = {}) => {
const scale = getScreenCanvasDrawController().getScreenScale();
return {
button: 0,
shiftKey: false,
ctrlKey: false,
clientX: x * scale,
clientY: CANVAS_HEIGHT - y * scale,
target: null,
preventDefault: () => {},
stopPropagation: () => {},
...mods,
} as unknown as MouseEvent;
};
const move = (x: number, y: number, mods?: Mods) => controller.handleMouseMove(ev(x, y, mods));
const press = (x: number, y: number, mods?: Mods) => {
move(x, y, mods);
controller.handleMouseDown(ev(x, y, mods));
};
const release = (x: number, y: number, mods?: Mods) => {
move(x, y, mods);
controller.handleMouseUp(ev(x, y, mods));
};
const click = (x: number, y: number, mods?: Mods) => {
press(x, y, mods);
release(x, y, mods);
};
const key = (k: string) =>
controller.handleKeyStroke({
key: k,
code: "",
isComposing: false,
shiftKey: false,
ctrlKey: false,
altKey: false,
metaKey: false,
repeat: false,
target: null,
preventDefault: vi.fn(),
stopPropagation: vi.fn(),
} as unknown as KeyboardEvent);
const typeEnter = (text: string) => {
for (const ch of text) key(ch);
key("Enter");
};
const toolType = () => getActiveToolActor()?.getSnapshot()?.context?.type;
const idle = () => setActiveToolActor(new Actor(selectToolStateMachine));
const lineTool = () => setActiveToolActor(new Actor(TOOL_STATE_MACHINES[Tool.LINE]));
const segOf = (id: string) =>
(getEntities().find((e) => e.id === id) as unknown as { segment: Seg }).segment;
const lastSeg = () => {
const lines = getEntities().filter((e) => e.getType() === "Line");
return (lines[lines.length - 1] as unknown as { segment: Seg }).segment;
};
const expectXy = (point: Xy, x: number, y: number) => {
expect(point.x).toBeCloseTo(x, 3);
expect(point.y).toBeCloseTo(y, 3);
};
/** LINE 도구로 선 하나(앱 쪽 점) — 그 id */
const drawLine = (x1: number, y1: number, x2: number, y2: number): string => {
lineTool();
click(x1, y1);
click(x2, y2);
idle();
const lines = getEntities();
return lines[lines.length - 1].id;
};
let a = "";
let b = "";
beforeEach(() => {
controller = initApplication();
setEntities([], true);
setSelectedEntityIds([]);
setAngleStep(0, false); // 극 · 직교 끔 — 가이드 스냅이 판정을 흔들지 않게
a = drawLine(0, 0, 100, 0);
b = drawLine(0, 200, 100, 200);
setAngleStep(45, false);
setSelectedEntityIds([]);
idle();
});
afterEach(() => {
setAngleStep(45, false);
setSnapEnabled(true);
setHostReadOnly(false);
vi.restoreAllMocks();
});
describe("고르기 — 누름 더함 · Shift 뺌 · 상자(H4 · H5)", () => {
it("쉴 때 누름 = 더함 · Shift+누름 = 뺌", () => {
click(50, 0);
expect(getSelectedEntityIds()).toEqual([a]);
click(50, 200);
expect(getSelectedEntityIds()).toEqual([a, b]);
click(25, 0, { shiftKey: true }); // 가운데 (50, 0) 은 그립
expect(getSelectedEntityIds()).toEqual([b]);
});
it("상자 = 앞 고름 ∪ 상자 · Shift+상자 = 앞 고름 − 상자(끌기 · 클릭-클릭 둘 다)", () => {
click(50, 0);
// 창(왼→오) 끌기로 B
press(-20, 180);
release(120, 220);
expect(getSelectedEntityIds().sort()).toEqual([a, b].sort());
// Shift + 클릭-클릭 상자로 A 뺌
click(-20, -20, { shiftKey: true });
click(120, 20, { shiftKey: true });
expect(getSelectedEntityIds()).toEqual([b]);
});
it("명령 중 — 누름 = 더함 · 끌기 상자 · 스침 강조", () => {
runCommandInput("MOVE");
expect(toolType()).toBe(Tool.MOVE);
move(50, 1);
expect(getHighlightedEntityIds()).toEqual([a]);
move(50, 100);
expect(getHighlightedEntityIds()).toEqual([]);
click(50, 0);
press(-20, 180); // 빈 곳 누름 → 끌어 뗌 = 상자
release(120, 220);
expect(getSelectedEntityIds().sort()).toEqual([a, b].sort());
expect(toolType()).toBe(Tool.MOVE);
});
});
describe("그립 — 누르고-옮기고-누르기 · 끌기 · 숫자 · N7 · H2", () => {
it("그립 누르고 떼면 대기(안 밀림 · H3) → 다음 누름에 놓음", () => {
click(50, 0);
click(100, 0); // 끝 그립
expect(toolType()).toBe(Tool.GRIP_EDIT);
expectXy(segOf(a).end, 100, 0);
click(130, 60);
expectXy(segOf(a).end, 130, 60);
expect(toolType()).toBe(Tool.SELECT);
});
it("그립 끌어 놓음 = 놓은 자리", () => {
click(50, 0);
press(100, 0);
release(140, 70);
expectXy(segOf(a).end, 140, 70);
expect(toolType()).toBe(Tool.SELECT);
});
it("그립 숫자 = 그립 점에서 커서 쪽 거리(N8)", () => {
click(50, 0);
click(100, 0);
move(100, 80);
typeEnter("30");
expectXy(segOf(a).end, 100, 30);
});
it("명령 안 고르기 중엔 그립을 안 집음(N7)", () => {
runCommandInput("MOVE");
click(50, 0);
click(100, 0);
expect(toolType()).toBe(Tool.MOVE);
expectXy(segOf(a).end, 100, 0);
});
it("확정 · 읽기 전용 도면 — 그립 · Delete 막음(H2)", () => {
click(50, 0);
setHostReadOnly(true);
click(100, 0);
expect(toolType()).toBe(Tool.SELECT);
key("Delete");
expect(getEntities().map((e) => e.id)).toContain(a);
setHostReadOnly(false);
key("Delete");
expect(getEntities().map((e) => e.id)).not.toContain(a);
});
});
describe("직교(H8) · 가이드 거리(H9)", () => {
const startLine = () => {
lineTool();
click(300, 300);
};
it("F8 = 직교 강제 — 누름 · 거리 숫자 · 꺼짐 비교", () => {
setAngleStep(90, false);
startLine();
click(400, 340); // 가로가 더 멂 → (400, 300)
expectXy(lastSeg().end, 400, 300);
move(420, 500); // 세로 쪽 → 거리 50 = 위로
typeEnter("50");
expectXy(lastSeg().end, 400, 350);
setAngleStep(0, false);
click(470, 380);
expectXy(lastSeg().end, 470, 380);
});
it("Shift 누른 동안 임시 직교 · 직교 켠 채 Shift = 풂", () => {
setAngleStep(0, false);
startLine();
click(400, 340, { shiftKey: true });
expectXy(lastSeg().end, 400, 300);
setAngleStep(90, false);
click(450, 370, { shiftKey: true });
expectXy(lastSeg().end, 450, 370);
});
it("객체 스냅 점은 직교가 안 덮음 · F3 꺼도 직교", () => {
setAngleStep(90, false);
lineTool();
click(10, 100);
click(98, 197); // B 시작점 근처 (100, 200) — 끝점 스냅
expectXy(lastSeg().end, 100, 200);
setSnapEnabled(false);
lineTool();
click(10, 100);
click(98, 197);
expectXy(lastSeg().end, 10, 197);
});
it("가이드 잡는 거리 = 화면 픽셀(배율 2 → 도면 7.5 · 0.5 → 30)", () => {
setAngleStep(90, false);
const view = getScreenCanvasDrawController();
view.setScreenScale(2);
startLine();
move(380, 310); // 가로 가이드에서 10
expect(getAngleGuideEntities()).toHaveLength(0);
view.setScreenScale(0.5);
move(380, 320); // 20
expect(getAngleGuideEntities()).toHaveLength(1);
view.setScreenScale(1);
});
});
describe("휠", () => {
it("기본 = 밂(앞으로 굴리면 확대 · 오토캐드) — 저장 없음", () => {
expect(wheelZoom({ deltaY: -100, deltaMode: 0, ctrlKey: false })?.delta).toBeLessThan(0);
expect(wheelZoom({ deltaY: 100, deltaMode: 0, ctrlKey: false })?.delta).toBeGreaterThan(0);
});
it("휠 두 번 = 범위 줌 · 이전 화면 되돌림 · 느리면 아님", () => {
const view = getScreenCanvasDrawController() as unknown as {
zoomToFitScreen: () => void;
setScreenScale: (s: number) => void;
getScreenScale: () => number;
};
view.zoomToFitScreen = vi.fn(() => view.setScreenScale(3));
const now = vi.spyOn(Date, "now");
now.mockReturnValue(1000);
press(10, 10, { button: 1 });
now.mockReturnValue(1300);
press(11, 11, { button: 1 });
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 });
now.mockReturnValue(5600);
press(10, 10, { button: 1 });
expect(view.zoomToFitScreen).toHaveBeenCalledTimes(1);
});
});
+5 -5
View File
@@ -148,11 +148,11 @@ const out = {
before: layer.getAttribute("transform"),
ticksBefore: ticksBefore.length,
};
// 휠 당김(deltaY > 0) = 확대 · 커서(화면 600,300 = svg 300,150) 고정
svg.fire("wheel", { clientX: 600, clientY: 300, deltaY: 100 });
// 휠 밂(deltaY < 0 · 기본 337) = 확대 · 커서(화면 600,300 = svg 300,150) 고정
svg.fire("wheel", { clientX: 600, clientY: 300, deltaY: -100 });
out.wheelIn = { ...saved };
out.cursorFixed = saved.tx + saved.scale * 300;
svg.fire("wheel", { clientX: 600, clientY: 300, deltaY: -100 });
svg.fire("wheel", { clientX: 600, clientY: 300, deltaY: 100 });
out.wheelOut = saved.scale;
// 원배율에서 좌클릭 끌기 = 팬 아님
handle.reset();
@@ -192,10 +192,10 @@ out.noLayer = attachDetailZoom(flat, AXES, undefined, () => {});
svg.fire("dblclick", {});
out.reset = layer.getAttribute("transform");
// 확대 — 십자는 중심에서 배율을 되돌림(크기 그대로)
svg.fire("wheel", { clientX: 600, clientY: 300, deltaY: 100 });
svg.fire("wheel", { clientX: 600, clientY: 300, deltaY: -100 });
out.centerTransform = center.getAttribute("transform");
out.max = (() => {
for (let i = 0; i < 60; i++) svg.fire("wheel", { clientX: 600, clientY: 300, deltaY: 100 });
for (let i = 0; i < 60; i++) svg.fire("wheel", { clientX: 600, clientY: 300, deltaY: -100 });
return saved.scale;
})();
process.stdout.write(JSON.stringify(out));
+15 -15
View File
@@ -15,7 +15,7 @@ function load(file) {
? {}
: p.endsWith("key_shortcut") // 공용 키 판정(214-2)도 그 자리에서 트랜스파일
? load(path.join(ROOT, "A00_Common", "key_shortcut.ts"))
: p.endsWith("input_prefs") // 휠 방향(332 · Node = 기본 당김)
: p.endsWith("input_prefs") // 휠 방향(337 · Node = 기본 밂)
? load(path.join(ROOT, "A00_Common", "input_prefs.ts"))
: require(p);
new Function("exports", "module", "require", js)(box.exports, box, req);
@@ -102,30 +102,30 @@ r.consts = {
r.listeners = win.calls.map((c) => [c.type, c.capture]);
}
{
// 220-1 휠 — 켜 있는 동안만 배율 · 스크롤 막음 · 켤 때마다 2배 · 확대 = 당김(deltaY > 0 · 332 기본)
// 220-1 휠 — 켜 있는 동안만 배율 · 스크롤 막음 · 켤 때마다 2배 · 확대 = 밂(deltaY < 0 · 337 기본)
const { win, view } = setup();
const wheel = (deltaY) => win.fire("wheel", { deltaY });
const zoomLog = () => view.log.filter((l) => l[0] === "zoom").map((l) => l[1]);
const offEv = wheel(100);
const offEv = wheel(-100);
const offState = { prevented: offEv.prevented, stopped: offEv.stopped, log: view.log.length };
win.fire("keydown", { code: "KeyZ" });
const up = wheel(100);
const up = wheel(-100);
const upState = { prevented: up.prevented, stopped: up.stopped, zoom: zoomLog().at(-1) };
wheel(-100);
wheel(-100);
wheel(100);
wheel(100);
const down = zoomLog().at(-1);
wheel(0); // 가로 굴림 — 배율 그대로 · 스크롤은 막음
const flat = zoomLog().at(-1);
for (let i = 0; i < 30; i++) wheel(50);
for (let i = 0; i < 30; i++) wheel(-50);
const max = zoomLog().at(-1);
for (let i = 0; i < 30; i++) wheel(-120);
for (let i = 0; i < 30; i++) wheel(120);
const min = zoomLog().at(-1);
win.fire("keyup", { code: "KeyZ" });
const n = zoomLog().length;
const afterOff = wheel(100);
const afterOff = wheel(-100);
const afterOffZoomed = zoomLog().length !== n;
win.fire("keydown", { code: "KeyZ" });
wheel(100);
wheel(-100);
r.wheel = {
off: offState,
up: upState,
@@ -136,17 +136,17 @@ r.consts = {
afterOff: { prevented: afterOff.prevented, stopped: afterOff.stopped, zoomed: afterOffZoomed },
reopen: zoomLog().at(-1), // 다시 켜면 2배에서 한 칸
pure: [
m.nextLoupeZoom(2, 1),
m.nextLoupeZoom(2, -1),
m.nextLoupeZoom(6, 1),
m.nextLoupeZoom(1.5, -1),
m.nextLoupeZoom(2, 1),
m.nextLoupeZoom(6, -1),
m.nextLoupeZoom(1.5, 1),
],
};
// 길게 누름으로 켠 동안도 같음
const b = setup();
b.win.fire("mousedown", { button: 2, buttons: 2 });
b.win.tick(400);
const press = b.win.fire("wheel", { deltaY: 1 });
const press = b.win.fire("wheel", { deltaY: -1 });
r.wheel.press = { prevented: press.prevented, zoom: b.view.log.at(-1) };
}
{
@@ -300,7 +300,7 @@ r.origin = {
// 켜진 채 CAD 위로(mouseover · mousemove 둘 다) — 숨김 · 휠 안 막음 · 나오면 배율 그대로 보임
const { win, view } = setup();
win.fire("keydown", { code: "KeyZ" });
win.fire("wheel", { deltaY: 100 }); // 2.25
win.fire("wheel", { deltaY: -100 }); // 2.25 — 밂 = 확대(337 기본)
win.fire("mouseover", { target: cad });
const hidden = last(view);
const n = view.log.length;
+5 -1
View File
@@ -28,7 +28,11 @@ function loadTs(full) {
new Function("exports", "module", "require", js)(box.exports, box, localRequire);
return box.exports;
}
STUBS["./input_prefs"] = loadTs(path.join(ROOT, "A00_Common", "input_prefs.ts")); // 휠 방향(332 · Node = 기본 당김)
// 휠 방향과 무관한 시험(처음으로 · 범위) — 337 기본 밂 뒤에도 당김 수치 그대로(pull 박음)
globalThis.localStorage = {
getItem: (k) => (k === "aislo:pref:input-prefs" ? '{"wheelZoom":"pull"}' : null),
};
STUBS["./input_prefs"] = loadTs(path.join(ROOT, "A00_Common", "input_prefs.ts")); // 휠 방향(332)
const metrics = loadTs(path.join(B06, "B06_Section_UI_Cross_View_Metrics.ts"));
const { attachSvgZoomPan } = loadTs(path.join(ROOT, "A00_Common", "b_svg_zoom_pan.ts"));
+2 -2
View File
@@ -44,8 +44,8 @@ globalThis.localStorage = {
removeItem: (k) => store.delete(k),
};
const set = (v) => store.set(prefs.INPUT_PREFS_KEY, v);
set(JSON.stringify({ wheelZoom: "push", cadAliases: { line: ["ll", 3], ARC: [] } }));
out.push = sample();
set(JSON.stringify({ wheelZoom: "pull", cadAliases: { line: ["ll", 3], ARC: [] } }));
out.saved = sample(); // 기본 밂(337) 의 반대를 저장
set("{깨짐");
out.broken = sample();
set(JSON.stringify({ wheelZoom: "sideways" }));
+1 -1
View File
@@ -60,7 +60,7 @@ def test_축_고정_도형_층만_움직임_눈금_다시(out):
assert out["noLayer"] is None # 도형 층 없음 = 줌 없음
def test_휠_당기면_확대_커서_고정(out):
def test_휠_밀면_확대_커서_고정(out):
assert out["wheelIn"]["scale"] == pytest.approx(1.15)
assert out["cursorFixed"] == pytest.approx(300) # 커서 아래 svg x 300 이 제자리
assert out["wheelOut"] < out["wheelIn"]["scale"]
+3 -3
View File
@@ -34,10 +34,10 @@ def test_지름_360_배율_한도(r):
assert (c["zoom"], c["zoomMin"], c["zoomMax"], c["zoomStep"]) == (2, 1.5, 6, 0.25)
def test_휠_당김_크게_밀기_작게_한도(r): # 332 기본 = 당김 확대
def test_휠_밂_크게_당김_작게_한도(r): # 337 기본 = 밂 확대
w = r["wheel"]
assert w["up"]["zoom"] == 2.25 # 당겨 한 칸
assert w["down"] == 1.75 # 밀어 두 칸
assert w["up"]["zoom"] == 2.25 # 밀어 한 칸
assert w["down"] == 1.75 # 당겨 두 칸
assert w["flat"] == 1.75 # 가로 굴림은 그대로
assert w["max"] == 6 and w["min"] == 1.5
assert w["pure"] == [2.25, 1.75, 6, 1.5]
@@ -20,7 +20,7 @@ def _read(rel):
def _node(script):
# 휠 방향 읽기 층(332) 을 앞에 붙임 — Node 엔 저장소가 없어 기본(당기면 확대)
# 휠 방향 읽기 층(332) 을 앞에 붙임 — Node 엔 저장소가 없어 기본(밀면 확대 · 337)
src = _read("A00_Common/input_prefs.ts") + IMPORTS.sub(
"", _read("A00_Common/b_svg_zoom_pan.ts")
)
@@ -68,9 +68,9 @@ const svg = new Svg();
const layer = { t: "", setAttribute(_k: string, v: string) { this.t = v; } };
attachSvgZoomPan({ svg: svg as any, layer: layer as any, widthPx: W, heightPx: H,
pad: { left: 0, right: 0, top: 0, bottom: 0 }, wheelZoom: true });
const svgIn = wheel(svg, 100);
const svgIn = wheel(svg, -100);
const zoomIn = layer.t;
const svgOut = wheel(svg, -100);
const svgOut = wheel(svg, 100);
const zoomOut = layer.t;
// 틀 듣개(그림 밖 여백 · 단추 줄) — 소스 줄 그대로 돌림 · passive 면 preventDefault 무시됨
const host = new EventTarget();
@@ -85,6 +85,6 @@ console.log(JSON.stringify({ svgIn, zoomIn, svgOut, zoomOut, hostBlocked, opts }
# 그림 위 휠 = 줌(커서 자리 붙잡음) · 기본동작(스크롤) 막힘 = 스크롤 위치 안 바뀜
assert got["svgIn"] is True and got["zoomIn"].endswith(" scale(1.15)")
assert got["zoomIn"].startswith("translate(-45 -29.99") # 커서(300, 200) 제자리
assert got["svgOut"] is True and got["zoomOut"] != got["zoomIn"] # 밀면 축소
assert got["svgOut"] is True and got["zoomOut"] != got["zoomIn"] # 당기면 축소
# 그림 밖 틀 위 휠도 페이지 스크롤로 안 샘
assert got["hostBlocked"] is True and got["opts"] == {"passive": False}
+14 -13
View File
@@ -17,7 +17,8 @@ import pytest
HERE = os.path.dirname(__file__)
ROOT = os.path.join(HERE, "..", "..")
BASE = "http://localhost:5177" # sub2 화면 서버(OWNERS 자기 줄)
# 화면 서버(OWNERS 자기 줄) — 돌리는 창 포트로 바꿈
BASE = os.environ.get("AISLO_UI_BASE", "http://localhost:5177")
KEY = "aislo:pref:input-prefs"
# 앱 스크립트보다 먼저 — 설정 키에 쓰는 횟수를 셈
@@ -108,7 +109,7 @@ def test_설정_창_탭_저장_물음():
page = ctx.new_page()
page.goto(f"{BASE}/#/a01-home")
page.wait_for_selector(".app-burger")
assert page.evaluate(WHEEL_IN, 100) is True # 기본 = 당김
assert page.evaluate(WHEEL_IN, -100) is True # 기본 = 밂(337 · 오토캐드)
# ☰ → [설정] · 탭 셋 · 첫 탭 = 마우스
_open_settings(page)
@@ -131,8 +132,8 @@ def test_설정_창_탭_저장_물음():
)
page.click("[data-settings-tab=mouse]")
# 밀기 고름 — [저장] 전엔 안 씀 · 닫으면 물음 · 취소 = 머묾
page.check("input[name=app-settings-wheel][value=push]")
# 당김 고름 — [저장] 전엔 안 씀 · 닫으면 물음 · 취소 = 머묾
page.check("input[name=app-settings-wheel][value=pull]")
assert _writes(page) == []
page.keyboard.press("Escape")
page.wait_for_selector(".ui-confirm")
@@ -142,8 +143,8 @@ def test_설정_창_탭_저장_물음():
# [저장] = 한 번 씀 · 읽기 층 방향 뒤집힘 · 닫을 때 안 물음
_save(page)
assert len(_writes(page)) == 1
assert json.loads(_writes(page)[0]) == {"wheelZoom": "push", "cadAliases": {}}
assert page.evaluate(WHEEL_IN, 100) is False and page.evaluate(WHEEL_IN, -100) is True
assert json.loads(_writes(page)[0]) == {"wheelZoom": "pull", "cadAliases": {}}
assert page.evaluate(WHEEL_IN, 100) is True and page.evaluate(WHEEL_IN, -100) is False
_save(page) # 고친 것 없음 — 또 안 씀
assert len(_writes(page)) == 1
_close(page)
@@ -152,7 +153,7 @@ def test_설정_창_탭_저장_물음():
# CAD 별칭 — 잘못이면 안 씀 · 칸 표시
_open_settings(page)
assert page.is_checked("input[name=app-settings-wheel][value=push]") # 저장값으로 엶
assert page.is_checked("input[name=app-settings-wheel][value=pull]") # 저장값으로 엶
page.click("[data-settings-tab=cad]")
line = "input[data-cad-command=LINE]"
circle = "input[data-cad-command=CIRCLE]"
@@ -168,20 +169,20 @@ def test_설정_창_탭_저장_물음():
_save(page)
assert page.inner_text(".app-settings__error").strip() == ""
assert len(_writes(page)) == 2
assert json.loads(_writes(page)[1]) == {"wheelZoom": "push", "cadAliases": {"LINE": ["LL"]}}
assert json.loads(_writes(page)[1]) == {"wheelZoom": "pull", "cadAliases": {"LINE": ["LL"]}}
# 닫을 때 물음 — 버림 = 안 씀 · 저장 = 한 번 더
page.click("[data-settings-tab=mouse]")
page.check("input[name=app-settings-wheel][value=pull]")
page.check("input[name=app-settings-wheel][value=push]")
_close(page)
page.click(".ui-confirm button >> nth=1") # 버리고 넘어감
assert page.locator(".app-settings").count() == 0 and len(_writes(page)) == 2
assert page.evaluate(WHEEL_IN, 100) is False
assert page.evaluate(WHEEL_IN, 100) is True
_open_settings(page)
page.check("input[name=app-settings-wheel][value=pull]")
page.check("input[name=app-settings-wheel][value=push]")
_close(page)
page.click(".ui-confirm button >> nth=2") # 저장 후 넘어감
assert page.locator(".app-settings").count() == 0 and len(_writes(page)) == 3
assert json.loads(_writes(page)[2])["wheelZoom"] == "pull"
assert page.evaluate(WHEEL_IN, 100) is True
assert json.loads(_writes(page)[2])["wheelZoom"] == "push"
assert page.evaluate(WHEEL_IN, 100) is False
browser.close()
+11 -10
View File
@@ -1,6 +1,6 @@
"""PLAN 332-1 — 휠 확대 방향 한 설정(`A00_Common/input_prefs.ts`) · 모든 휠 화면이 그 판정을 거침.
· 기본 = 당김(휠을 몸 쪽으로 = 확대) · 밀기 저장 뒤 뒤집힘 · 깨짐 · 모르는 값 · 막힌 저장소 · Node = 기본
· 기본 = 밂(휠을 앞으로 = 확대 · 오토캐드 · 337) · 당김 저장 뒤 뒤집힘 · 깨짐 · 모르는 값 · 막힌 저장소 · Node = 기본
· 핀치(트랙패드 · 손가락)는 설정과 상관없이 늘 벌리면 확대
· 키 = 등록표 `input-prefs`(pref · global) 와 같은 글자
짝: `helper_input_prefs.cjs`(실제 TS 를 Node 로 돌림).
@@ -43,6 +43,7 @@ def r() -> dict:
PULL = {"pull": True, "push": False, "mouseCtrl": True, "bridgePull": True}
PUSH = {"pull": False, "push": True, "mouseCtrl": False, "bridgePull": False}
PINCH = {"padSpread": True, "padPinch": False, "fingerSpread": True}
@@ -50,17 +51,17 @@ def _pick(case: dict, keys: dict) -> dict:
return {k: case[k] for k in keys}
def test_Node_기본_당김(r):
def test_Node_기본_밂(r):
n = r["node"]
assert n["default"] == {"wheelZoom": "pull", "cadAliases": {}}
assert n["default"] == {"wheelZoom": "push", "cadAliases": {}}
assert n["prefs"] == n["default"]
assert _pick(n, PULL) == PULL and _pick(n, PINCH) == PINCH
assert _pick(n, PULL) == PUSH and _pick(n, PINCH) == PINCH
def test_밀기_저장_뒤_뒤집힘_핀치는_그대로(r):
p = r["push"]
assert p["prefs"]["wheelZoom"] == "push"
assert _pick(p, PULL) == {"pull": False, "push": True, "mouseCtrl": False, "bridgePull": False}
def test_당김_저장_뒤_뒤집힘_핀치는_그대로(r):
p = r["saved"]
assert p["prefs"]["wheelZoom"] == "pull"
assert _pick(p, PULL) == PULL
assert _pick(p, PINCH) == PINCH # 벌리면 확대는 설정과 상관없음
# 별칭 = 대문자 · 글자 아닌 것 · 빈 목록 버림
assert p["prefs"]["cadAliases"] == {"LINE": ["LL"]}
@@ -69,8 +70,8 @@ def test_밀기_저장_뒤_뒤집힘_핀치는_그대로(r):
@pytest.mark.parametrize("case", ["broken", "unknown", "empty", "blocked"])
def test_깨짐_모르는_값_없음_막힘_기본(r, case):
c = r[case]
assert c["prefs"] == {"wheelZoom": "pull", "cadAliases": {}}
assert _pick(c, PULL) == PULL and _pick(c, PINCH) == PINCH
assert c["prefs"] == {"wheelZoom": "push", "cadAliases": {}}
assert _pick(c, PULL) == PUSH and _pick(c, PINCH) == PINCH
def test_등록표_키(r):