/** * 원(CIRCLE) — 337 설계 1-1 · 오토캐드 차례. * 「원에 대한 중심점 지정 또는 [3점(3P)/2점(2P)]:」 → 「원의 반지름 지정 또는 [지름(D)] :」 · * D → 「원의 지름 지정 <2r>:」 · 3P → 원 위 세 점 · 2P → 지름 두 끝점. * 반지름 기억(CIRCLERAD · 처음 없음 → <> 안 그림 · 빈 Enter 아무 일 없음) · 누른 점 · 3P · 2P 반지름도 기억. 하나 그리면 끝. */ import { Point } from '@flatten-js/core'; import { assign, createMachine } from 'xstate'; import { type PromptSpec, recall, remember } from '../commands/prompt'; import { CircleEntity } from '../entities/CircleEntity'; import { getPointFromEvent } from '../helpers/get-point-from-event.ts'; import { arcThroughThreePoints } from '../helpers/geometry/shape-points'; import { addEntities, getActiveLayerId, setAngleGuideOriginPoint, setGhostHelperEntities, setSelectedEntityIds, setShouldDrawHelpers, } from '../state'; import { Tool } from '../tools'; import { END, firstPointEvents, option, pointEvents } from './draw/draw-steps'; import { styled } from './factories/entity-factory'; import { type DrawEvent, END_STATE, type NumberInputEvent, type PointInputEvent, type StateEvent, type ToolContext, } from './tool.types'; export interface CircleContext extends ToolContext { centerPoint: Point | null; /** 3P · 2P 에서 이미 찍은 점 */ points: Point[]; threePoints: boolean; } export enum CircleState { INIT = 'INIT', WAITING_FOR_CENTER_POINT = 'WAITING_FOR_CENTER_POINT', WAITING_FOR_RADIUS = 'WAITING_FOR_RADIUS', WAITING_FOR_DIAMETER = 'WAITING_FOR_DIAMETER', THREE_POINTS = 'THREE_POINTS', TWO_POINTS = 'TWO_POINTS', } const RADIUS_KEY = 'CIRCLERAD'; /** 지름 기본 = 2 × 기억 반지름 — 이 열쇠엔 쓰지 않음 */ const DIAMETER_KEY = 'CIRCLERAD.D'; const CENTER_PROMPT: PromptSpec = { text: '원에 대한 중심점 지정', options: [option('3P', '3P', '3점'), option('2P', '2P', '2점')], absent: [option('TTR', 'T', 'Ttr - 접선 접선 반지름')], }; const RADIUS_PROMPT: PromptSpec = { text: '원의 반지름 지정', options: [option('DIAMETER', 'D', '지름')], default: { kind: 'value', remember: RADIUS_KEY, initial: null }, number: { positive: true }, }; const diameterPrompt = (): PromptSpec => { const radius = recall(RADIUS_KEY); return { text: '원의 지름 지정', default: { kind: 'value', remember: DIAMETER_KEY, initial: radius ? radius * 2 : null }, number: { positive: true }, }; }; const ORDINAL = ['첫 번째', '두 번째', '세 번째']; const threePrompt = (context: Partial): PromptSpec => ({ text: `원 위의 ${ORDINAL[context.points?.length ?? 0]} 점 지정`, }); const twoPrompt = (context: Partial): PromptSpec => ({ text: `원 지름의 ${ORDINAL[context.points?.length ?? 0]} 끝점 지정`, }); function addCircle(center: Point, radius: number) { setGhostHelperEntities([]); if (!(radius > 0)) return; addEntities([styled(new CircleEntity(getActiveLayerId(), center, radius))], true); remember(RADIUS_KEY, radius); } /** 3P · 2P — 찍은 점들 + 마지막 점으로 원(점 모자람 · 한 줄 = null) */ function circleThrough(context: CircleContext, last: Point) { const [a, b] = context.points; if (!context.threePoints) { return a ? { center: new Point((a.x + last.x) / 2, (a.y + last.y) / 2), radius: a.distanceTo(last)[0] / 2, } : null; } const arc = b ? arcThroughThreePoints(a, b, last) : null; return arc ? { center: arc.center, radius: arc.radius } : null; } /** 점 사건 → 점(앞 점 기준 · 첫 점이면 기준 없음) */ const pointAt = (base: Point | null, event: StateEvent) => getPointFromEvent(base, event as PointInputEvent); const lastOf = (points: Point[]) => points[points.length - 1] ?? null; /** 3P 는 세 점 · 2P 는 두 점째에 원 */ const pointsEvents = (count: number, self: CircleState) => { const enough = ({ context }: { context: CircleContext }) => context.points.length === count - 1; const done = { guard: enough, actions: 'drawThrough', target: END_STATE }; const more = { actions: 'recordPoint', target: self }; return { DRAW: { actions: 'previewThrough' }, MOUSE_CLICK: [done, more], ABSOLUTE_POINT_INPUT: [done, more], RELATIVE_POINT_INPUT: [done, more], NUMBER_INPUT: [done, more], ESC: { target: END_STATE }, }; }; export const circleToolStateMachine = createMachine( { types: {} as { context: CircleContext; events: StateEvent }, context: { centerPoint: null, points: [], threePoints: false, type: Tool.CIRCLE }, initial: CircleState.INIT, states: { [CircleState.INIT]: { always: { actions: 'init', target: CircleState.WAITING_FOR_CENTER_POINT }, }, [CircleState.WAITING_FOR_CENTER_POINT]: { meta: { kind: 'point', prompt: CENTER_PROMPT }, on: { ...firstPointEvents('recordCenter', CircleState.WAITING_FOR_RADIUS), 'OPTION.3P': { actions: 'pickThree', target: CircleState.THREE_POINTS }, 'OPTION.2P': { actions: 'pickTwo', target: CircleState.TWO_POINTS }, ESC: { target: END_STATE }, }, }, [CircleState.WAITING_FOR_RADIUS]: { meta: { kind: 'point', prompt: RADIUS_PROMPT }, on: { DRAW: { actions: 'previewRadius' }, ...pointEvents('drawByRadius', END_STATE), 'OPTION.DIAMETER': { target: CircleState.WAITING_FOR_DIAMETER }, ESC: { target: END_STATE }, }, }, [CircleState.WAITING_FOR_DIAMETER]: { meta: { kind: 'point', prompt: diameterPrompt }, on: { DRAW: { actions: 'previewDiameter' }, ...pointEvents('drawByDiameter', END_STATE), ESC: { target: END_STATE }, }, }, [CircleState.THREE_POINTS]: { meta: { kind: 'point', prompt: threePrompt }, on: pointsEvents(3, CircleState.THREE_POINTS), }, [CircleState.TWO_POINTS]: { meta: { kind: 'point', prompt: twoPrompt }, on: pointsEvents(2, CircleState.TWO_POINTS), }, ...END, }, }, { actions: { init: assign(() => { setShouldDrawHelpers(true); setGhostHelperEntities([]); setSelectedEntityIds([]); setAngleGuideOriginPoint(null); return { centerPoint: null, points: [], threePoints: false }; }), pickThree: assign({ threePoints: true }), pickTwo: assign({ threePoints: false }), recordCenter: assign(({ event }) => { const centerPoint = pointAt(null, event); setAngleGuideOriginPoint(centerPoint); return { centerPoint }; }), previewRadius: ({ context, event }) => { const center = context.centerPoint as Point; const radius = center.distanceTo(getPointFromEvent(center, event as DrawEvent))[0]; setGhostHelperEntities([styled(new CircleEntity(getActiveLayerId(), center, radius))]); }, previewDiameter: ({ context, event }) => { const center = context.centerPoint as Point; const radius = center.distanceTo(getPointFromEvent(center, event as DrawEvent))[0] / 2; setGhostHelperEntities([styled(new CircleEntity(getActiveLayerId(), center, radius))]); }, drawByRadius: ({ context, event }) => { const center = context.centerPoint as Point; const radius = event.type === 'NUMBER_INPUT' ? (event as NumberInputEvent).value : center.distanceTo(pointAt(center, event))[0]; addCircle(center, radius); }, drawByDiameter: ({ context, event }) => { const center = context.centerPoint as Point; const diameter = event.type === 'NUMBER_INPUT' ? (event as NumberInputEvent).value : center.distanceTo(pointAt(center, event))[0]; addCircle(center, diameter / 2); }, recordPoint: assign(({ context, event }) => { const point = pointAt(lastOf(context.points), event); setAngleGuideOriginPoint(point); return { points: [...context.points, point] }; }), previewThrough: ({ context, event }) => { const last = lastOf(context.points); if (!last) return; const circle = circleThrough(context, getPointFromEvent(last, event as DrawEvent)); setGhostHelperEntities( circle ? [styled(new CircleEntity(getActiveLayerId(), circle.center, circle.radius))] : [] ); }, drawThrough: ({ context, event }) => { const circle = circleThrough(context, pointAt(lastOf(context.points), event)); if (circle) addCircle(circle.center, circle.radius); else setGhostHelperEntities([]); }, }, } );