Files
Aislo/B07_DesignDetail/openwebcad/src/tools/select-tool.helpers.ts
T
eomsangdonandClaude Opus 5 9bf6de9b10 fix(B07): 잠금 도면층을 집던 경로 다섯 곳을 마저 닫는다
앞선 감사가 불완전했다. 같은 부류로 도각·원지반을 건드리던 곳이 더 있었다.

- select-tool.helpers pickEntityAt: 클릭 선택 후보에 잠금 객체가 섞여
  도각을 물면 아무 일도 안 일어나는 죽은 클릭이 됐다.
- eraser-tool: 자르기의 교차점 계산이 도각 선을 절단 경계로 썼다.
- property-tools OVERKILL: 선택이 없으면 전체가 대상이라 도각까지 지웠다.
- text-tools 찾기/바꾸기: 도각 표제란 글자를 바꿔 버렸다.
- selection-tools 유형/유사 선택: 잠금 객체까지 세어 토스트 개수가 틀렸다.

전부 getPickableEntities()로 바꾼다.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-31 15:48:08 +09:00

201 lines
6.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,
isEntitySelected,
setEntities,
setGhostHelperEntities,
setSelectedEntityIds,
} from '../state';
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 {
// 선택된 객체의 그립이 먼저다 — 집으면 그립 편집으로 넘어간다
const gripHit = findGripAt(getSelectedEntities(), event.worldMouseLocation, pickRadius());
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) {
// 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])
);
}
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 selectEntitiesInsideRectangle(
startPoint: Point,
endPoint: Point,
holdingCtrl: boolean
// holdingShift: boolean, // TODO implement add to selection using shift
): void {
// Finish the selection
const activeSelectionRectangle = new RectangleEntity(getActiveLayerId(), startPoint, endPoint);
const intersectionSelection = getIsIntersectionSelection(activeSelectionRectangle, startPoint);
const newSelectedEntityIds: 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 (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;
})
);
setSelectedEntityIds(expandSelectionWithGroups(newSelectedEntityIds));
}
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;
}