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:
@@ -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 {
|
||||
|
||||
@@ -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+클릭 => 폴리선 정점 제거 (다기능 그립)
|
||||
|
||||
Reference in New Issue
Block a user