fix(B07): 집기 반경을 화면 기준으로 잡고, 누른 채 끄는 선택을 살린다

- 집기 반경이 월드 거리였다. HIGHLIGHT_ENTITY_DISTANCE를 월드 거리와 그대로 비교해
  확대할수록 화면상 반경이 같이 커졌다(1369%에서 15월드 ≈ 205px). helpers/pick-radius로
  확대율을 나눠 쓰고 상수를 화면 픽셀 10으로 낮췄다. 하이라이트·선택·그립·시퀀스 도구가
  같은 반경을 본다.
- 누른 채 끄는 선택이 없었다. 왼쪽 버튼은 mouseup에서만 클릭을 보내 누르고 끌어도 점이
  하나만 찍혔고, 그래서 좌→우(창)·우→좌(교차) 구분이 먹히지 않는 것처럼 보였다.
  선택 도구일 때만 누를 때 첫 점을 보내고, 4px 넘게 끌고 놓으면 그 자리로 사각형을 닫는다.
  다른 도구는 예전처럼 놓을 때 한 점만 받는다.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-08-30 15:52:25 +09:00
co-authored by Claude Opus 5
parent 3a86e02747
commit 8c828992f7
6 changed files with 105 additions and 58 deletions
@@ -59,7 +59,8 @@ export const SNAP_ANGLE_DISTANCE = 15;
/**
* How far the mouse can be from an entity to highlight it and subsequently select it when you click
*/
export const HIGHLIGHT_ENTITY_DISTANCE = 15;
/** 객체를 집는 반경 — **화면 픽셀**. 월드 거리로 쓸 때는 helpers/pick-radius로 확대율을 나눈다 */
export const HIGHLIGHT_ENTITY_DISTANCE = 10;
/**
* The size of the snap point indicator shapes that are shown on active snap points
@@ -0,0 +1,11 @@
/**
* 집기 반경 — 화면에서 몇 픽셀 안쪽을 "그 객체를 가리킨 것"으로 볼지.
* 상수는 화면 픽셀이므로 월드 거리로 쓰려면 확대율로 나눠야 한다. 나누지 않으면
* 확대할수록 반경이 같이 커져서(2000%에서 15 → 300px 넘음) 엉뚱한 객체가 잡힌다.
*/
import { HIGHLIGHT_ENTITY_DISTANCE } from '../App.consts';
import { getScreenCanvasDrawController } from '../state';
export function pickRadius(): number {
return HIGHLIGHT_ENTITY_DISTANCE / getScreenCanvasDrawController().getScreenScale();
}
@@ -7,7 +7,6 @@ import {
CANVAS_INPUT_FIELD_MOUSE_OFFSET,
CANVAS_INPUT_FIELD_TEXT_COLOR,
CANVAS_INPUT_FIELD_WIDTH,
HIGHLIGHT_ENTITY_DISTANCE,
PINCH_ZOOM_EXPONENT,
SNAP_POINT_DISTANCE,
WHEEL_LINE_PX,
@@ -25,6 +24,7 @@ import type { ScreenCanvasDrawController } from '../drawControllers/screenCanvas
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,
getAngleStep,
@@ -50,6 +50,7 @@ import {
undo,
} from '../state.ts';
import { Tool } from '../tools.ts';
import { SelectState } from '../tools/select-tool.ts';
import {
type AbsolutePointInputEvent,
ActorEvent,
@@ -74,8 +75,13 @@ function polarToPoint(distance: number, degrees: number): Point {
return new Point(distance * Math.cos(radians), distance * Math.sin(radians));
}
/** 이만큼 넘게 끌면 클릭이 아니라 드래그로 본다 (화면 픽셀) */
const DRAG_SELECT_MIN_PX = 4;
export class InputController {
private text = '';
/** 왼쪽 버튼을 누른 화면 좌표 — 놓을 때 끌었는지 판단한다 */
private leftPressLocation: Point | null = null;
constructor() {
if (typeof process === 'object' && process?.env?.NODE_ENV === 'test') {
@@ -179,29 +185,58 @@ export class InputController {
setPanStartLocation(null);
}
if (evt.button === MouseButton.Left) {
const screenCanvasDrawController = getScreenCanvasDrawController();
const closestSnapPoint = getClosestSnapPointWithinRadius(
compact([getSnapPoint(), getSnapPointOnAngleGuide()]),
screenCanvasDrawController.getWorldMouseLocation(),
SNAP_POINT_DISTANCE / screenCanvasDrawController.getScreenScale()
);
const worldMouseLocationTemp = getScreenCanvasDrawController().targetToWorld(
this.getCanvasPoint(evt)
);
const worldMouseLocation = closestSnapPoint ? closestSnapPoint.point : worldMouseLocationTemp;
const activeToolActor = getActiveToolActor();
activeToolActor?.send({
type: ActorEvent.MOUSE_CLICK,
worldMouseLocation,
screenMouseLocation: screenCanvasDrawController.worldToTarget(worldMouseLocation),
holdingCtrl: evt.ctrlKey,
holdingShift: evt.shiftKey,
} as MouseClickEvent);
if (this.isSelectToolActive()) {
// 선택 도구는 누를 때 첫 점을 이미 보냈다. 끌었으면 놓는 자리로 사각형을 닫는다.
const pressLocation = this.leftPressLocation;
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()) {
this.sendMouseClick(evt);
}
return;
}
this.sendMouseClick(evt);
}
}
/** 활성 도구가 선택 도구인가 */
private isSelectToolActive(): boolean {
return getActiveToolActor()?.getSnapshot()?.context?.type === Tool.SELECT;
}
/** 선택 도구가 선택 사각형의 두 번째 점을 기다리는 중인가 */
private isWaitingForSecondSelectPoint(): boolean {
return (
getActiveToolActor()?.getSnapshot()?.value === SelectState.WAITING_FOR_SECOND_SELECT_POINT
);
}
/** 스냅을 반영한 클릭 한 번을 활성 도구에 보낸다 */
private sendMouseClick(evt: MouseEvent) {
const screenCanvasDrawController = getScreenCanvasDrawController();
const closestSnapPoint = getClosestSnapPointWithinRadius(
compact([getSnapPoint(), getSnapPointOnAngleGuide()]),
screenCanvasDrawController.getWorldMouseLocation(),
SNAP_POINT_DISTANCE / screenCanvasDrawController.getScreenScale()
);
const worldMouseLocationTemp = screenCanvasDrawController.targetToWorld(
this.getCanvasPoint(evt)
);
const worldMouseLocation = closestSnapPoint ? closestSnapPoint.point : worldMouseLocationTemp;
getActiveToolActor()?.send({
type: ActorEvent.MOUSE_CLICK,
worldMouseLocation,
screenMouseLocation: screenCanvasDrawController.worldToTarget(worldMouseLocation),
holdingCtrl: evt.ctrlKey,
holdingShift: evt.shiftKey,
} as MouseClickEvent);
}
public handleMouseEnter() {
setShouldDrawCursor(true);
}
@@ -233,7 +268,7 @@ export class InputController {
screenCanvasDrawController.targetToWorld(newScreenMouseLocation),
getEntities()
);
if (closestEntityInfo.distance < HIGHLIGHT_ENTITY_DISTANCE) {
if (closestEntityInfo.distance < pickRadius()) {
setHighlightedEntityIds([closestEntityInfo.entity.id]);
} else {
setHighlightedEntityIds([]);
@@ -279,9 +314,17 @@ export class InputController {
}
public handleMouseDown(evt: MouseEvent) {
if (evt.button !== MouseButton.Middle) return;
setPanStartLocation(this.getCanvasPoint(evt));
if (evt.button === MouseButton.Middle) {
setPanStartLocation(this.getCanvasPoint(evt));
return;
}
if (evt.button !== MouseButton.Left) return;
if ((evt.target as HTMLElement | null)?.closest('.controls')) return;
// AutoCAD처럼 누른 자리에서 사각형이 시작되도록 선택 도구에만 첫 점을 미리 보낸다.
// 다른 도구는 예전처럼 놓을 때 한 점을 받는다 (끌다가 점이 두 개 찍히지 않게).
if (!this.isSelectToolActive()) return;
this.leftPressLocation = this.getCanvasPoint(evt);
this.sendMouseClick(evt);
}
private getCanvasPoint(evt: MouseEvent): Point {
+4 -7
View File
@@ -2,7 +2,7 @@ import { Point } from '@flatten-js/core';
import React from 'react';
import ReactDOM from 'react-dom/client';
import { Actor, type MachineSnapshot } from 'xstate';
import { HIGHLIGHT_ENTITY_DISTANCE, SNAP_POINT_DISTANCE } from './App.consts';
import { SNAP_POINT_DISTANCE } from './App.consts';
import App from './App.tsx';
import { TOOL_STATE_MACHINES } from './commands/registry';
import { ScreenCanvasDrawController } from './drawControllers/screenCanvas.drawController';
@@ -11,6 +11,7 @@ import { registerCadDebugHook } from './helpers/debug-hook.ts';
import { draw } from './helpers/draw';
import { findClosestEntity } from './helpers/find-closest-entity';
import { getNewLayer } from './helpers/get-new-layer.ts';
import { pickRadius } from './helpers/pick-radius';
import { scenePerf } from './helpers/scene-cache';
import { queryEntitiesNearPoint } from './helpers/spatial-index';
import { trackHoveredSnapPoint } from './helpers/track-hovered-snap-points';
@@ -95,14 +96,10 @@ function startDrawLoop(
const { distance, entity: closestEntity } = findClosestEntity(
worldMouseLocation,
// 공간 인덱스로 후보를 좁혀 O(전체) 스캔 제거
queryEntitiesNearPoint(
worldMouseLocation.x,
worldMouseLocation.y,
HIGHLIGHT_ENTITY_DISTANCE
)
queryEntitiesNearPoint(worldMouseLocation.x, worldMouseLocation.y, pickRadius())
);
if (distance < HIGHLIGHT_ENTITY_DISTANCE) {
if (distance < pickRadius()) {
setHighlightedEntityIds([closestEntity.id]);
}
}
@@ -7,10 +7,10 @@
*/
import type { Point } from '@flatten-js/core';
import { Actor, assign, createMachine, sendTo } from 'xstate';
import { HIGHLIGHT_ENTITY_DISTANCE } from '../../App.consts';
import type { Entity } from '../../entities/Entity';
import { findClosestEntity } from '../../helpers/find-closest-entity';
import { getPointFromEvent } from '../../helpers/get-point-from-event';
import { pickRadius } from '../../helpers/pick-radius';
import { queryEntitiesNearPoint } from '../../helpers/spatial-index';
import {
getScreenCanvasDrawController,
@@ -146,8 +146,7 @@ function cursorPoint(event: StateEvent | undefined): Point {
/** 클릭 지점에서 가장 가까운 엔티티 (선택 반경 안에 있을 때만) */
function pickEntityAt(worldPoint: Point): Entity | null {
const scale = getScreenCanvasDrawController().getScreenScale() || 1;
const radius = HIGHLIGHT_ENTITY_DISTANCE / scale;
const radius = pickRadius();
const candidates = queryEntitiesNearPoint(worldPoint.x, worldPoint.y, radius);
const { distance, entity } = findClosestEntity(worldPoint, candidates);
return entity && distance <= radius ? entity : null;
@@ -175,11 +174,13 @@ export function createSequenceTool(config: SequenceToolConfig) {
setGhostHelperEntities(ghosts);
};
const pushPoint = assign(({ context, event }: { context: SequenceContext; event: StateEvent }) => {
const point = getPointFromEvent(lastPoint(context.values), event as PointInputEvent);
setAngleGuideOriginPoint(point);
return { values: [...context.values, point], picks: [...context.picks, point] };
});
const pushPoint = assign(
({ context, event }: { context: SequenceContext; event: StateEvent }) => {
const point = getPointFromEvent(lastPoint(context.values), event as PointInputEvent);
setAngleGuideOriginPoint(point);
return { values: [...context.values, point], picks: [...context.picks, point] };
}
);
const pushNumberFromEvent = assign(
({ context, event }: { context: SequenceContext; event: StateEvent }) => {
@@ -197,10 +198,12 @@ export function createSequenceTool(config: SequenceToolConfig) {
picks: [...context.picks, null],
}));
const pushText = assign(({ context, event }: { context: SequenceContext; event: StateEvent }) => ({
values: [...context.values, (event as TextInputEvent).value],
picks: [...context.picks, null],
}));
const pushText = assign(
({ context, event }: { context: SequenceContext; event: StateEvent }) => ({
values: [...context.values, (event as TextInputEvent).value],
picks: [...context.picks, null],
})
);
const pushEntity = assign(
({ context, event }: { context: SequenceContext; event: StateEvent }) => {
@@ -3,7 +3,6 @@ import { compact } from 'es-toolkit';
import { toast } from 'react-toastify';
import {
EPSILON,
HIGHLIGHT_ENTITY_DISTANCE,
SELECTION_RECTANGLE_COLOR_CONTAINS,
SELECTION_RECTANGLE_COLOR_INTERSECTION,
SELECTION_RECTANGLE_STYLE,
@@ -15,6 +14,7 @@ import { pointDistance } from '../helpers/distance-between-points';
import { expandSelectionWithGroups } from '../helpers/entity-groups';
import { findEntitiesWithinDistance } from '../helpers/find-closest-entity';
import { findGripAt, removePolylineVertex } from '../helpers/grips';
import { pickRadius } from '../helpers/pick-radius';
import {
getActiveLayerId,
getEntities,
@@ -35,17 +35,13 @@ let lastPickPoint: Point | null = null;
let pickCycleIndex = 0;
function pickEntityAt(worldPoint: Point): Entity | null {
const candidates = findEntitiesWithinDistance(
worldPoint,
getEntities(),
HIGHLIGHT_ENTITY_DISTANCE
);
const radius = pickRadius();
const candidates = findEntitiesWithinDistance(worldPoint, getEntities(), radius);
if (!candidates.length) {
lastPickPoint = null;
return null;
}
const samePlace =
!!lastPickPoint && pointDistance(lastPickPoint, worldPoint) < HIGHLIGHT_ENTITY_DISTANCE;
const samePlace = !!lastPickPoint && pointDistance(lastPickPoint, worldPoint) < radius;
pickCycleIndex = samePlace ? (pickCycleIndex + 1) % candidates.length : 0;
lastPickPoint = worldPoint;
return candidates[pickCycleIndex];
@@ -56,11 +52,7 @@ export function handleFirstSelectionPoint(
event: MouseClickEvent
): SelectContext {
// 선택된 객체의 그립이 먼저다 — 집으면 그립 편집으로 넘어간다
const gripHit = findGripAt(
getSelectedEntities(),
event.worldMouseLocation,
HIGHLIGHT_ENTITY_DISTANCE
);
const gripHit = findGripAt(getSelectedEntities(), event.worldMouseLocation, pickRadius());
if (gripHit) {
if (event.holdingCtrl) {
// Ctrl+클릭 => 폴리선 정점 제거 (다기능 그립)