From 8c828992f72ce8dfcde711ca60c4832662e7f585 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Sun, 30 Aug 2026 15:52:25 +0900 Subject: [PATCH] =?UTF-8?q?fix(B07):=20=EC=A7=91=EA=B8=B0=20=EB=B0=98?= =?UTF-8?q?=EA=B2=BD=EC=9D=84=20=ED=99=94=EB=A9=B4=20=EA=B8=B0=EC=A4=80?= =?UTF-8?q?=EC=9C=BC=EB=A1=9C=20=EC=9E=A1=EA=B3=A0,=20=EB=88=84=EB=A5=B8?= =?UTF-8?q?=20=EC=B1=84=20=EB=81=84=EB=8A=94=20=EC=84=A0=ED=83=9D=EC=9D=84?= =?UTF-8?q?=20=EC=82=B4=EB=A6=B0=EB=8B=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 집기 반경이 월드 거리였다. HIGHLIGHT_ENTITY_DISTANCE를 월드 거리와 그대로 비교해 확대할수록 화면상 반경이 같이 커졌다(1369%에서 15월드 ≈ 205px). helpers/pick-radius로 확대율을 나눠 쓰고 상수를 화면 픽셀 10으로 낮췄다. 하이라이트·선택·그립·시퀀스 도구가 같은 반경을 본다. - 누른 채 끄는 선택이 없었다. 왼쪽 버튼은 mouseup에서만 클릭을 보내 누르고 끌어도 점이 하나만 찍혔고, 그래서 좌→우(창)·우→좌(교차) 구분이 먹히지 않는 것처럼 보였다. 선택 도구일 때만 누를 때 첫 점을 보내고, 4px 넘게 끌고 놓으면 그 자리로 사각형을 닫는다. 다른 도구는 예전처럼 놓을 때 한 점만 받는다. Co-Authored-By: Claude Opus 5 (1M context) --- B07_DesignDetail/openwebcad/src/App.consts.ts | 3 +- .../openwebcad/src/helpers/pick-radius.ts | 11 +++ .../src/inputController/input-controller.ts | 93 ++++++++++++++----- B07_DesignDetail/openwebcad/src/main.tsx | 11 +-- .../src/tools/factories/sequence-tool.ts | 27 +++--- .../src/tools/select-tool.helpers.ts | 18 +--- 6 files changed, 105 insertions(+), 58 deletions(-) create mode 100644 B07_DesignDetail/openwebcad/src/helpers/pick-radius.ts diff --git a/B07_DesignDetail/openwebcad/src/App.consts.ts b/B07_DesignDetail/openwebcad/src/App.consts.ts index 8e517983..1afeab60 100644 --- a/B07_DesignDetail/openwebcad/src/App.consts.ts +++ b/B07_DesignDetail/openwebcad/src/App.consts.ts @@ -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 diff --git a/B07_DesignDetail/openwebcad/src/helpers/pick-radius.ts b/B07_DesignDetail/openwebcad/src/helpers/pick-radius.ts new file mode 100644 index 00000000..2d497173 --- /dev/null +++ b/B07_DesignDetail/openwebcad/src/helpers/pick-radius.ts @@ -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(); +} diff --git a/B07_DesignDetail/openwebcad/src/inputController/input-controller.ts b/B07_DesignDetail/openwebcad/src/inputController/input-controller.ts index 0e163834..6e5c0683 100644 --- a/B07_DesignDetail/openwebcad/src/inputController/input-controller.ts +++ b/B07_DesignDetail/openwebcad/src/inputController/input-controller.ts @@ -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 { diff --git a/B07_DesignDetail/openwebcad/src/main.tsx b/B07_DesignDetail/openwebcad/src/main.tsx index 803f018e..19cd7387 100644 --- a/B07_DesignDetail/openwebcad/src/main.tsx +++ b/B07_DesignDetail/openwebcad/src/main.tsx @@ -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]); } } diff --git a/B07_DesignDetail/openwebcad/src/tools/factories/sequence-tool.ts b/B07_DesignDetail/openwebcad/src/tools/factories/sequence-tool.ts index b7d02b93..5ef75cb3 100644 --- a/B07_DesignDetail/openwebcad/src/tools/factories/sequence-tool.ts +++ b/B07_DesignDetail/openwebcad/src/tools/factories/sequence-tool.ts @@ -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 }) => { diff --git a/B07_DesignDetail/openwebcad/src/tools/select-tool.helpers.ts b/B07_DesignDetail/openwebcad/src/tools/select-tool.helpers.ts index a7864dd2..edde415a 100644 --- a/B07_DesignDetail/openwebcad/src/tools/select-tool.helpers.ts +++ b/B07_DesignDetail/openwebcad/src/tools/select-tool.helpers.ts @@ -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+클릭 => 폴리선 정점 제거 (다기능 그립)