Files
Aislo/B07_DesignDetail/openwebcad/src/tools/select-tool.helpers.ts
T
eomsangdonandClaude Opus 5.5 66279024d8 feat(B07 CAD): 337-2 마-2 ZOOM 덧물음 · Shift+우클릭 스냅 메뉴 · 고르기 ALL/L/P · 그립 종료(X) · ↶↷ 끊고 되돌림 · snapped 캐스트 지움
- ZOOM(Z): 「윈도우 구석 지정, 축척 비율 (nX 또는 nXP) 입력 또는 [전체(A)/범위(E)/이전(P)/윈도우(W)] <실시간>:」 덧물음 · E · A = 범위 · P = 앞 화면 · W · 누름 = 두 구석 윈도우 · 2X = 배율 곱 · 빈 Enter = 실시간 안내 · 중심 · 동적 · 축척 · 객체 = 없음 안내 · 'Z 투명 · ⛶ 단추 = 바로 범위
- 약속 더함: SubPrompt.onText(꼴이 정해지지 않은 글 · nX) — routeTyped 가 덧물음에 먼저 물음 · undefined = 사다리 그대로(브레인 알림)
- Shift+우클릭: 점 물음에서만 한 번 스냅 메뉴(시작점에서 · 두 점 사이의 중간 · 끝점 · 중간점 · 교차점 · 중심 · 사분점 · 수직 · 노드 · 근처점 · 없음) → 고른 이름을 친 것과 같은 길 · Esc · 바깥 누름 = 메뉴만 닫음(ic/snap-menu.ts)
- 고르기 숨은 키워드 ALL · L · P(선택 도구 첫 점 대기 · 쉼 · 명령 안 자식) — 앞 고름에 더함 · 「n개를 찾음, 총 m개」
- 그립 물음 새 꼴 「** 신축 ** 신축점 지정 또는 [종료(X)]:」 · X = 놓음 · 기준점(B) · 복사(C) · 명령 취소(U) 없음 안내
- ↶ ↷ 단추 = undoCommand · redoCommand(끊고 되돌림) · 우클릭 = 명령 중 Enter · 쉴 때 반복(가-1 길 · 시험)
- 시험: mouse-acad 마-2 더함(21)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Se7ytqbohmBQnfYpwYFnYf
2026-10-16 00:11:53 +09:00

205 lines
7.7 KiB
TypeScript

import type { Box, Point, Polygon } from '@flatten-js/core';
import { compact } from 'es-toolkit';
import { toast } from 'react-toastify';
import {
EPSILON,
SELECTION_RECTANGLE_COLOR_CONTAINS,
SELECTION_RECTANGLE_COLOR_INTERSECTION,
SELECTION_RECTANGLE_STYLE,
SELECTION_RECTANGLE_WIDTH,
} from '../App.consts';
import type { Entity } from '../entities/Entity';
import { RectangleEntity } from '../entities/RectangleEntity';
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,
getPickableEntities,
getLayers,
getSelectedEntities,
getSelectedEntityIds,
getActiveToolActor,
isDrawingReadOnly,
isEntitySelected,
isViewOnly,
setEntities,
setGhostHelperEntities,
setSelectedEntityIds,
} from '../state';
import { Tool } from '../tools';
import { previousSelection } from '../commands/prompt';
import { startGripEdit } from './grip-edit-tool';
import type { SelectContext } from './select-tool';
import type { MouseClickEvent } from './tool.types';
/** 선택 순환 — 같은 자리를 다시 클릭하면 겹친 후보를 차례로 돌린다 */
let lastPickPoint: Point | null = null;
let pickCycleIndex = 0;
function pickEntityAt(worldPoint: Point): Entity | null {
const radius = pickRadius();
const candidates = findEntitiesWithinDistance(worldPoint, getPickableEntities(), radius);
if (!candidates.length) {
lastPickPoint = null;
return null;
}
const samePlace = !!lastPickPoint && pointDistance(lastPickPoint, worldPoint) < radius;
pickCycleIndex = samePlace ? (pickCycleIndex + 1) % candidates.length : 0;
lastPickPoint = worldPoint;
return candidates[pickCycleIndex];
}
export function handleFirstSelectionPoint(
context: SelectContext,
event: MouseClickEvent
): SelectContext {
// 선택된 객체의 그립이 먼저다 — 집으면 그립 편집으로 넘어간다.
// 쉴 때(맨 위 = 선택 도구)만 · 명령 안 고르기는 그립 없음(N7) · 보기 전용 · 확정 도면은 막음(H2)
const gripHit = gripsEditable()
? findGripAt(getSelectedEntities(), event.worldMouseLocation, pickRadius())
: null;
if (gripHit) {
if (event.holdingCtrl) {
// Ctrl+클릭 => 폴리선 정점 제거 (다기능 그립)
const trimmed = removePolylineVertex(gripHit.entity, gripHit.grip);
if (trimmed) {
setEntities(
getEntities().map((entity) => (entity.id === trimmed.id ? trimmed : entity)),
true
);
return { ...context, startPoint: null };
}
} else {
startGripEdit(gripHit.entity, gripHit.grip);
return { ...context, startPoint: null };
}
}
const pickedEntity = pickEntityAt(event.worldMouseLocation);
// Mouse is close to entity and is not dragging a rectangle
if (pickedEntity) {
// 오토캐드 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,
};
}
// No elements are close to the mouse and no selection dragging is in progress
// Start a new selection rectangle drag
return {
...context,
startPoint: event.worldMouseLocation,
};
}
/** 앞 고름에 더함 · 뺌 · 넣고 빼기 */
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))];
}
/** 알림 줄 — 입력 층이 붙임(`ic/selecting.ts` · prompt-input 을 바로 부르면 불러오기 고리) */
let notice: (text: string) => void = () => {};
export const setKeywordNotice = (sink: (text: string) => void): void => {
notice = sink;
};
/** 고르기 키워드 — 전체(ALL = Ctrl+A 와 같은 목록) · 최종(LAST = 집을 수 있는 마지막) · 이전(P) · 앞 고름에 더함 */
export function selectByKeyword(key: 'ALL' | 'LAST' | 'PREVIOUS'): void {
const pickable = getPickableEntities();
const found =
key === 'ALL'
? pickable.map((entity) => entity.id)
: key === 'LAST'
? pickable.slice(-1).map((entity) => entity.id)
: previousSelection().filter((id) => pickable.some((entity) => entity.id === id));
const ids = combineSelection(expandSelectionWithGroups(found), 'add');
setSelectedEntityIds(ids);
notice(`${found.length}개를 찾음, 총 ${ids.length}개`);
}
/** 그립 집기 · 그리기 — 쉴 때 · 고칠 수 있는 도면만 */
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 = false
): void {
// Finish the selection
const activeSelectionRectangle = new RectangleEntity(getActiveLayerId(), startPoint, endPoint);
const intersectionSelection = getIsIntersectionSelection(activeSelectionRectangle, startPoint);
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) {
toast.error(`Failed to find layer for entity: ${entity?.id}`);
console.error('Failed to find layer for entity', entity);
return null;
}
if (layer.isLocked) return null;
// 걸침(오→왼) = 걸치거나 든 것 · 창(왼→오) = 통째로 든 것만
const inside =
entity.isContainedInBox(box) || (intersectionSelection && entity.intersectsWithBox(box));
return inside ? entity.id : null;
})
);
const ids = expandSelectionWithGroups(boxIds);
setSelectedEntityIds(
combineSelection(ids, holdingShift ? 'remove' : holdingCtrl ? 'toggle' : 'add')
);
}
export function drawTempSelectionRectangle(startPoint: Point, endPoint: Point) {
const activeSelectionRectangle = new RectangleEntity(getActiveLayerId(), startPoint, endPoint);
const isIntersectionSelection: boolean = getIsIntersectionSelection(
activeSelectionRectangle,
startPoint
);
activeSelectionRectangle.lineColor = isIntersectionSelection
? SELECTION_RECTANGLE_COLOR_INTERSECTION
: SELECTION_RECTANGLE_COLOR_CONTAINS;
activeSelectionRectangle.lineWidth = SELECTION_RECTANGLE_WIDTH;
activeSelectionRectangle.lineDash = SELECTION_RECTANGLE_STYLE;
setGhostHelperEntities([activeSelectionRectangle]);
}
/**
* Selections to the left of the start point are intersection selections (green), and everything intersecting with the selection rectangle will be selected
* Selections to the right of the start point are normal selections (blue), and only the entities fully inside the selection rectangle will be selected
*/
export function getIsIntersectionSelection(
rectangleEntity: RectangleEntity,
startPoint: Point
): boolean {
if (!rectangleEntity.getShape() || !startPoint) {
return false;
}
const selectionRectangleMinX = Math.min(
...(rectangleEntity.getShape() as Polygon).vertices.map((v) => v.x)
);
return Math.abs(startPoint.x - selectionRectangleMinX) > EPSILON;
}