Files
Aislo/B07_DesignDetail/openwebcad/src/tools/eraser-tool.ts
T
eomsangdonandClaude Opus 5.5 00a379595b feat(CAD): 337-4a 다-1 고치기 7 — OFFSET · FILLET · CHAMFER · TRIM · EXTEND · BREAK · JOIN 오토캐드 물음 차례 · 옵션 · 기본값 기억 · 빈 Enter · Esc · 끝난 뒤
- OFFSET 새 파일(offset-tool.ts) — 거리 <통과점> · 통과점(T) · 지우기(E) 예/아니오 · 객체 → 면의 점 되풀이 · 다중(M) <다음 객체> · 명령 취소(U) · 기억 OFFSETDIST · OFFSETERASE
- FILLET · CHAMFER 한 꼴 — 객체 먼저 · 반지름(R) · 거리(D) 둘(둘째 기본 = 첫째) · 자르기(T) 모드 · 다중(M) · 명령 취소(U) · Shift = 구석 · 기억 FILLETRAD · CHAMFERA/B · TRIMMODE
- TRIM · EXTEND 빠른 모드 한 꼴 — 되풀이 · Shift = 반대 일 · 집기 반경 밖 무시 · 지우기(R) · 숨은 U · 사각형 변 원래 층 · 꼴(N9) · 자른 선 꼴 유지
- BREAK — 누른 점 = 첫 끊기점 · 첫 번째 점(F) · @0,0 = 같은 점 · JOIN 물음 글
- 시험 resources/tester/cad/modify-acad.test.ts 13

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

271 lines
8.5 KiB
TypeScript

/**
* TRIM · EXTEND 빠른 모드(337 설계 1-2) — 누를 때마다 자름(연장) · Shift+누름 = 반대 일 · 되풀이 ·
* 빈 Enter · Esc = 끝 · 집기 반경 밖 누름은 아무 일 없음(N9) · U = 이 명령 안 마지막 하나 ·
* TRIM 지우기(R) = 고르기 → Enter 로 지우고 돌아옴.
*/
import type { Point, Polygon } from '@flatten-js/core';
import { assign, createMachine, sendTo } from 'xstate';
import {
OPT,
type OptionSpec,
type PromptSpec,
SELECT_OBJECTS,
rememberSelection,
} from '../commands/prompt';
import type { ArcEntity } from '../entities/ArcEntity';
import type { CircleEntity } from '../entities/CircleEntity';
import { EntityName } from '../entities/Entity';
import { LineEntity } from '../entities/LineEntity';
import type { RectangleEntity } from '../entities/RectangleEntity';
import { findClosestEntity } from '../helpers/find-closest-entity';
import { pickRadius } from '../helpers/pick-radius';
import { polygonToSegments } from '../helpers/polygon-to-segments';
import {
addEntities,
deleteEntities,
getEntities,
getPickableEntities,
getSelectedEntities,
setEntities,
setGhostHelperEntities,
setSelectedEntityIds,
setShouldDrawHelpers,
undo,
} from '../state';
import { Tool } from '../tools';
import {
eraseArcSegment,
eraseCircleSegment,
eraseLineSegment,
extendLineEnd,
getAllIntersectionPoints,
pickEntityAt,
} from './eraser-tool.helpers';
import { copyStyle } from './modify/modify.helpers';
import { selectToolStateMachine } from './select-tool';
import { END_STATE, type MouseClickEvent, type StateEvent, type ToolContext } from './tool.types';
export interface EraserContext extends ToolContext {
/** 이 명령에서 바꾼 횟수(U) */
made: number;
}
export enum EraserState {
INIT = 'INIT',
WAITING_FOR_FIRST_CLICK = 'WAITING_FOR_FIRST_CLICK',
ERASE_PICK = 'ERASE_PICK',
}
const absent = (key: string, kw: string, label: string): OptionSpec => ({
key,
kw,
en: key,
label,
});
const HIDDEN_UNDO: OptionSpec = { ...OPT.UNDO, hidden: true };
const CROSSING = absent('CROSSING', 'C', '걸치기');
const MODE = absent('MODE', 'O', '모드');
const PROJECT = absent('PROJECT', 'P', '투영');
const TRIM_PROMPT: PromptSpec = {
text: '자를 객체 선택 또는 Shift 키를 누른 채 선택하여 연장',
options: [{ key: 'ERASE', kw: 'R', en: 'ERASE', label: '지우기' }, HIDDEN_UNDO],
absent: [absent('CUTTINGEDGES', 'T', '절단 모서리'), CROSSING, MODE, PROJECT],
};
const EXTEND_PROMPT: PromptSpec = {
text: '연장할 객체 선택 또는 Shift 키를 누른 채 선택하여 자르기',
options: [HIDDEN_UNDO],
absent: [absent('BOUNDARYEDGES', 'B', '경계 모서리'), CROSSING, MODE, PROJECT],
};
/** TRIM 한 번 — 누른 자리 반경 안 객체만(없으면 false) · 사각형 변은 원래 층 · 꼴 */
export function handleMouseClick(
worldMouseLocation: Point,
radius = Number.POSITIVE_INFINITY
): boolean {
const closestEntity = findClosestEntity(worldMouseLocation, getPickableEntities());
if (!closestEntity.entity || closestEntity.distance > radius) {
return false;
}
const clickedPointOnShape = closestEntity.segment.start;
// Find entities that intersect with the closest entity
const intersections = getAllIntersectionPoints(closestEntity.entity, getPickableEntities());
const entityType = closestEntity.entity.getType();
switch (entityType) {
case EntityName.Line: {
const line = closestEntity.entity as LineEntity;
eraseLineSegment(line, clickedPointOnShape, intersections);
break;
}
case EntityName.Circle: {
const circle = closestEntity.entity as CircleEntity;
eraseCircleSegment(circle, clickedPointOnShape, intersections);
break;
}
case EntityName.Arc: {
const arc = closestEntity.entity as ArcEntity;
eraseArcSegment(arc, clickedPointOnShape, intersections);
break;
}
case EntityName.Rectangle: {
const rectangle = closestEntity.entity as RectangleEntity;
const segments = polygonToSegments(rectangle.getShape() as Polygon);
const segmentEntities = segments.map((segment) =>
copyStyle(rectangle, new LineEntity(rectangle.layerId, segment))
);
// Find the closest segment to the clicked point
const closestSegmentInfo = findClosestEntity(worldMouseLocation, segmentEntities);
// Remove the rectangle
deleteEntities([rectangle], false);
// Add 4 lines instead of the rectangle
addEntities(segmentEntities, false);
// Remove (a segment) of the 4th line closest to the cursor
const lineIntersections = getAllIntersectionPoints(
closestSegmentInfo.entity,
getPickableEntities()
);
eraseLineSegment(
closestSegmentInfo.entity as LineEntity,
clickedPointOnShape,
lineIntersections
);
break;
}
default:
return false;
}
setEntities(getEntities(), true); // Force a new undo state entry
return true;
}
/** EXTEND 한 번 — 누른 쪽 끝을 가장 가까운 경계까지 · 못 늘리면 false */
export function extendAt(worldMouseLocation: Point): boolean {
const target = pickEntityAt(worldMouseLocation);
if (!target) return false;
const extended = extendLineEnd(target, worldMouseLocation, getPickableEntities());
if (!extended) return false;
deleteEntities([target], false);
addEntities([extended], true);
return true;
}
const trimAt = (location: Point) => handleMouseClick(location, pickRadius());
type Ctx = { context: EraserContext; event: StateEvent };
/** 빠른 자르기 · 연장 머신 — `act(점, shift)` 가 바꿨으면 true */
function createQuickEditTool(
tool: Tool,
prompt: PromptSpec,
act: (location: Point, shift: boolean) => boolean
) {
const undoLast = assign(({ context }: Ctx) => {
if (context.made === 0) return {};
undo();
return { made: context.made - 1 };
});
const end = { target: END_STATE };
const forward = { actions: sendTo('trimErase', ({ event }: Ctx) => event) };
const takesErase = prompt.options?.some((option) => option.key === 'ERASE');
return createMachine(
{
types: {} as { context: EraserContext; events: StateEvent },
context: { made: 0, type: tool },
initial: EraserState.INIT,
// biome-ignore lint/suspicious/noExplicitAny: 상태마다 사건 꼴이 달라 느슨하게(순서 엔진과 같음)
states: <Record<string, any>>{
[EraserState.INIT]: {
always: {
actions: () => {
setShouldDrawHelpers(false);
setGhostHelperEntities([]);
},
target: EraserState.WAITING_FOR_FIRST_CLICK,
},
},
[EraserState.WAITING_FOR_FIRST_CLICK]: {
meta: { kind: 'entity', prompt },
on: {
MOUSE_CLICK: {
actions: assign(({ context, event }: Ctx) => {
const click = event as MouseClickEvent;
return act(click.worldMouseLocation, !!click.holdingShift)
? { made: context.made + 1 }
: {};
}),
},
...(takesErase ? { 'OPTION.ERASE': { target: EraserState.ERASE_PICK } } : {}),
'OPTION.UNDO': { actions: undoLast },
ENTER: end,
ESC: end,
},
},
...(takesErase
? {
[EraserState.ERASE_PICK]: {
meta: {
kind: 'selection',
prompt: { text: '지울 객체 선택', options: SELECT_OBJECTS.options },
},
entry: () => setShouldDrawHelpers(false),
invoke: {
id: 'trimErase',
src: selectToolStateMachine,
onDone: {
actions: assign(({ context }: Ctx) => {
const picked = getSelectedEntities();
if (!picked.length) return {};
rememberSelection(picked.map((entity) => entity.id));
setSelectedEntityIds([]);
deleteEntities(picked, true);
return { made: context.made + 1 };
}),
target: EraserState.WAITING_FOR_FIRST_CLICK,
},
},
on: {
MOUSE_CLICK: forward,
ENTER: forward,
DRAW: forward,
'OPTION.*': forward,
ESC: { actions: () => setSelectedEntityIds([]), target: END_STATE },
},
},
}
: {}),
[END_STATE]: { type: 'final' },
},
},
{
// 고르기 자식(선택 도구)의 액션 구현을 함께 넘긴다
// biome-ignore lint/suspicious/noExplicitAny: 자식 머신의 컨텍스트 타입이 달라 그대로 넘긴다
actions: { ...(selectToolStateMachine.implementations.actions as any) },
}
);
}
export const eraserToolStateMachine = createQuickEditTool(
Tool.ERASER,
TRIM_PROMPT,
(location, shift) => (shift ? extendAt(location) : trimAt(location))
);
export const extendToolStateMachine = createQuickEditTool(
Tool.EXTEND,
EXTEND_PROMPT,
(location, shift) => (shift ? trimAt(location) : extendAt(location))
);