/** * 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: >{ [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)) );