/** * OFFSET(337 설계 1-2) — 오토캐드 간격띄우기 차례: 거리(또는 통과점(T) · 지우기(E)) → 객체 → 면의 점 → * 객체로 되풀이 · 다중(M) = 방금 만든 것을 같은 거리로 되풀이 · U = 이 명령 안 마지막 하나 · 빈 Enter · Esc = 끝. * 거리 기억 = OFFSETDIST(처음 <통과점>) · 원본 지우기 기억 = OFFSETERASE. */ import { type Point, Segment } from '@flatten-js/core'; import { toast } from 'react-toastify'; import { assign, createMachine } from 'xstate'; import { OPT, type OptionSpec, type PromptSpec, recall, remember } from '../../commands/prompt'; import type { Entity } from '../../entities/Entity'; import { findClosestEntity } from '../../helpers/find-closest-entity'; import { getPointFromEvent } from '../../helpers/get-point-from-event'; import { addEntities, deleteEntities, getEntities, setAngleGuideOriginPoint, setGhostHelperEntities, setShouldDrawHelpers, undo, } from '../../state'; import { Tool } from '../../tools'; import { pickEntityAt } from '../eraser-tool.helpers'; import { type DrawEvent, END_STATE, type MouseClickEvent, type PointInputEvent, type StateEvent, type ToolContext, } from '../tool.types'; import { offsetEntity } from './modify.helpers'; interface OffsetContext extends ToolContext { /** 간격띄울 객체 */ sourceId: string | null; /** 처음 고른 객체 — 원본 지우기는 이것만 */ pickedId: string | null; multiple: boolean; /** 이 명령에서 만든 수(U) */ made: number; } type Ctx = { context: OffsetContext; event: StateEvent }; const option = (key: string, kw: string, label: string): OptionSpec => ({ key, kw, en: key, label, }); const THROUGH = option('THROUGH', 'T', '통과점'); /** 기억한 거리 · null = 통과점 */ const offsetDistance = (): number | null => { const distance = recall('OFFSETDIST'); return typeof distance === 'number' ? distance : null; }; const DISTANCE_PROMPT = (): PromptSpec => { const distance = offsetDistance(); return { text: '간격띄우기 거리 지정', options: [THROUGH, option('ERASE', 'E', '지우기')], absent: [option('LAYER', 'L', '도면층')], default: distance === null ? { kind: 'option', key: 'THROUGH' } : { kind: 'value', remember: 'OFFSETDIST', initial: distance }, number: { positive: true }, }; }; const ERASE_PROMPT = (): PromptSpec => ({ text: '간격띄우기 후 원본 객체를 지우시겠습니까?', options: [OPT.YES, OPT.NO], default: { kind: 'option', key: recall('OFFSETERASE') ? 'YES' : 'NO' }, }); const OBJECT_PROMPT: PromptSpec = { text: '간격띄우기할 객체 선택', options: [OPT.EXIT, OPT.UNDO], default: { kind: 'enter', label: '종료' }, }; const SIDE_PROMPT = (context: Partial): PromptSpec => ({ text: offsetDistance() === null ? '통과점 지정' : '간격띄우기할 면의 점 지정', options: context.multiple ? [OPT.EXIT, OPT.UNDO] : [OPT.EXIT, OPT.MULTIPLE, OPT.UNDO], default: { kind: 'enter', label: context.multiple ? '다음 객체' : '종료' }, }); /** 통과점까지 거리 — 선은 무한 직선까지(오토캐드) · 그 밖은 모양까지 */ function throughDistance(entity: Entity, point: Point): number { const shape = entity.getShape(); if (shape instanceof Segment) { const { start, end } = shape; const length = start.distanceTo(end)[0] || 1; return ( Math.abs((end.x - start.x) * (point.y - start.y) - (end.y - start.y) * (point.x - start.x)) / length ); } return findClosestEntity(point, [entity]).distance; } function offsetThrough(source: Entity, point: Point): Entity | null { return offsetEntity(source, offsetDistance() ?? throughDistance(source, point), point); } const sourceOf = (context: OffsetContext) => getEntities().find((entity) => entity.id === context.sourceId); /** 면의 점 하나 → 새 객체 · 다중이면 그것이 다음 원본 */ const placeOffset = assign(({ context, event }: Ctx): Partial => { const source = sourceOf(context); if (!source) return { sourceId: null }; const point = getPointFromEvent(null, event as PointInputEvent); const result = offsetThrough(source, point); setGhostHelperEntities([]); if (!result) { toast.warn('이 객체는 지정한 거리로 간격을 띄울 수 없습니다.'); return {}; } if (recall('OFFSETERASE') && source.id === context.pickedId) deleteEntities([source], false); addEntities([result], true); return { made: context.made + 1, sourceId: context.multiple ? result.id : null }; }); const undoLast = assign(({ context }: Ctx): Partial => { setGhostHelperEntities([]); if (context.made === 0) return {}; undo(); return { made: context.made - 1, sourceId: null, multiple: false }; }); const end = { target: END_STATE }; const toObject = { actions: assign({ sourceId: null, multiple: false }), target: 'OBJECT' }; // biome-ignore lint/suspicious/noExplicitAny: 상태마다 사건 꼴이 달라 느슨하게(순서 엔진과 같음) const states: Record = { INIT: { always: { actions: () => { setShouldDrawHelpers(false); setGhostHelperEntities([]); setAngleGuideOriginPoint(null); }, target: 'DISTANCE', }, }, DISTANCE: { meta: { kind: 'number', prompt: DISTANCE_PROMPT }, on: { NUMBER_INPUT: { actions: ({ event }: Ctx) => remember('OFFSETDIST', (event as { value: number }).value), target: 'OBJECT', }, 'OPTION.THROUGH': { actions: () => remember('OFFSETDIST', 'THROUGH'), target: 'OBJECT' }, 'OPTION.ERASE': { target: 'ERASE' }, ESC: end, }, }, ERASE: { meta: { kind: 'keyword', prompt: ERASE_PROMPT }, on: { 'OPTION.YES': { actions: () => remember('OFFSETERASE', true), target: 'DISTANCE' }, 'OPTION.NO': { actions: () => remember('OFFSETERASE', false), target: 'DISTANCE' }, ESC: end, }, }, OBJECT: { meta: { kind: 'entity', prompt: OBJECT_PROMPT }, entry: () => setShouldDrawHelpers(false), on: { MOUSE_CLICK: { guard: ({ event }: Ctx) => !!pickEntityAt((event as MouseClickEvent).worldMouseLocation), actions: assign(({ event }: Ctx) => { const id = pickEntityAt((event as MouseClickEvent).worldMouseLocation)?.id ?? null; return { sourceId: id, pickedId: id }; }), target: 'SIDE', }, 'OPTION.EXIT': end, 'OPTION.UNDO': { actions: undoLast }, ENTER: end, ESC: end, }, }, SIDE: { meta: { kind: 'point', prompt: SIDE_PROMPT }, entry: () => setShouldDrawHelpers(true), on: { DRAW: { actions: ({ context, event }: Ctx) => { const source = sourceOf(context); const preview = source && offsetThrough(source, getPointFromEvent(null, event as DrawEvent)); setGhostHelperEntities(preview ? [preview] : []); }, }, MOUSE_CLICK: { actions: placeOffset, target: 'PLACED' }, ABSOLUTE_POINT_INPUT: { actions: placeOffset, target: 'PLACED' }, 'OPTION.EXIT': end, 'OPTION.MULTIPLE': { actions: assign({ multiple: true }) }, 'OPTION.UNDO': { actions: undoLast, target: 'OBJECT' }, ENTER: [{ guard: ({ context }: Ctx) => context.multiple, ...toObject }, end], ESC: end, }, }, /** 다중이면 같은 물음 · 아니면 객체로 */ PLACED: { always: [{ guard: ({ context }: Ctx) => !!context.sourceId, target: 'SIDE' }, toObject], }, [END_STATE]: { type: 'final' }, }; export const offsetToolStateMachine = createMachine({ types: {} as { context: OffsetContext; events: StateEvent }, context: { sourceId: null, pickedId: null, multiple: false, made: 0, type: Tool.OFFSET }, initial: 'INIT', states, });