/** * 폴리곤(POLYGON) — 337 설계 1-1 · 오토캐드 차례. * 「변의 수 입력 <4>:」(3~1024 정수 · 기억) → 「폴리곤의 중심 지정 또는 [모서리(E)]:」 → * 「옵션 입력 [원에 내접(I)/원에 외접(C)] :」(기억) → 「원의 반지름 지정:」 · * E → 「모서리의 첫 번째 끝점 지정:」 → 「모서리의 두 번째 끝점 지정:」(그 모서리에서 반시계). * 반지름 숫자 = 아래 변 수평 · 점 = 크기 + 돌림(내접 = 꼭짓점 · 외접 = 변 가운데). 하나 그리면 끝. */ import { Point } from '@flatten-js/core'; import { assign, createMachine } from 'xstate'; import { type PromptSpec, recall, remember } from '../../commands/prompt'; import { getPointFromEvent } from '../../helpers/get-point-from-event'; import { regularPolygonPoints } from '../../helpers/geometry/shape-points'; import { addEntities, setAngleGuideOriginPoint, setGhostHelperEntities, setSelectedEntityIds, setShouldDrawHelpers, } from '../../state'; import { Tool } from '../../tools'; import { polyLineEntity } from '../factories/entity-factory'; import { type DrawEvent, END_STATE, type NumberInputEvent, type PointInputEvent, type StateEvent, type ToolContext, } from '../tool.types'; import { END, firstPointEvents, option, pointEvents } from './draw-steps'; export interface PolygonContext extends ToolContext { sides: number; center: Point | null; edgeStart: Point | null; } enum S { INIT = 'INIT', SIDES = 'SIDES', CENTER = 'CENTER', FIT = 'FIT', RADIUS = 'RADIUS', EDGE_START = 'EDGE_START', EDGE_END = 'EDGE_END', } const SIDES_KEY = 'POLYSIDES'; const FIT_KEY = 'POLYGON.IC'; const INSCRIBED = option('INSCRIBED', 'I', '원에 내접'); const CIRCUMSCRIBED = option('CIRCUMSCRIBED', 'C', '원에 외접'); const circumscribed = () => recall(FIT_KEY) === CIRCUMSCRIBED.key; const SIDES_PROMPT: PromptSpec = { text: '변의 수 입력', default: { kind: 'value', remember: SIDES_KEY, initial: 4, format: 'int' }, number: { int: true, min: 3, max: 1024 }, }; const fitPrompt = (): PromptSpec => { const fit = circumscribed() ? CIRCUMSCRIBED : INSCRIBED; return { text: '옵션 입력', options: [INSCRIBED, CIRCUMSCRIBED], default: { kind: 'option', key: fit.key, label: fit.kw }, }; }; /** * 첫 꼭짓점 — 내접 = 원 위 꼭짓점 · 외접 = 변 가운데가 원 위(꼭짓점은 반 칸 돌아 R / cos(π/n)). * 각 null = 아래 변 수평. */ function firstVertex(center: Point, radius: number, angle: number | null, sides: number): Point { const half = Math.PI / sides; const outer = circumscribed(); const toward = angle ?? -Math.PI / 2 + (outer ? 0 : half); // 아래 변 가운데(외접) · 그 오른쪽 꼭짓점(내접) const at = outer ? toward - half : toward; const reach = outer ? radius / Math.cos(half) : radius; return new Point(center.x + reach * Math.cos(at), center.y + reach * Math.sin(at)); } function polygonFrom(center: Point, point: Point | null, radius: number, sides: number) { const angle = point ? Math.atan2(point.y - center.y, point.x - center.x) : null; return polyLineEntity( regularPolygonPoints(center, firstVertex(center, radius, angle, sides), sides) ); } /** 모서리 a → b 에서 반시계로 — 중심은 모서리 왼쪽 */ function polygonOnEdge(a: Point, b: Point, sides: number) { const length = a.distanceTo(b)[0]; if (!length) return null; const apothem = length / 2 / Math.tan(Math.PI / sides); const center = new Point( (a.x + b.x) / 2 - ((b.y - a.y) / length) * apothem, (a.y + b.y) / 2 + ((b.x - a.x) / length) * apothem ); return polyLineEntity(regularPolygonPoints(center, a, sides)); } const add = (entity: ReturnType) => { setGhostHelperEntities([]); if (entity) addEntities([entity], true); }; export const polygonToolStateMachine = createMachine( { types: {} as { context: PolygonContext; events: StateEvent }, context: { sides: 4, center: null, edgeStart: null, type: Tool.POLYGON }, initial: S.INIT, states: { [S.INIT]: { always: { actions: 'init', target: S.SIDES } }, [S.SIDES]: { meta: { kind: 'number', prompt: SIDES_PROMPT }, on: { NUMBER_INPUT: { actions: 'recordSides', target: S.CENTER }, ESC: { target: END_STATE }, }, }, [S.CENTER]: { meta: { kind: 'point', prompt: { text: '폴리곤의 중심 지정', options: [option('EDGE', 'E', '모서리')] }, }, on: { ...firstPointEvents('recordCenter', S.FIT), 'OPTION.EDGE': { target: S.EDGE_START }, ESC: { target: END_STATE }, }, }, [S.FIT]: { meta: { kind: 'keyword', prompt: fitPrompt }, on: { 'OPTION.INSCRIBED': { actions: 'recordFit', target: S.RADIUS }, 'OPTION.CIRCUMSCRIBED': { actions: 'recordFit', target: S.RADIUS }, ESC: { target: END_STATE }, }, }, [S.RADIUS]: { meta: { kind: 'point', prompt: { text: '원의 반지름 지정', number: { positive: true } }, }, on: { DRAW: { actions: 'previewRadius' }, ...pointEvents('drawByRadius', END_STATE), ESC: { target: END_STATE }, }, }, [S.EDGE_START]: { meta: { kind: 'point', prompt: { text: '모서리의 첫 번째 끝점 지정' } }, on: { ...firstPointEvents('recordEdge', S.EDGE_END), ESC: { target: END_STATE } }, }, [S.EDGE_END]: { meta: { kind: 'point', prompt: { text: '모서리의 두 번째 끝점 지정' } }, on: { DRAW: { actions: 'previewEdge' }, ...pointEvents('drawByEdge', END_STATE), ESC: { target: END_STATE }, }, }, ...END, }, }, { actions: { init: () => { setShouldDrawHelpers(true); setSelectedEntityIds([]); setGhostHelperEntities([]); setAngleGuideOriginPoint(null); }, recordSides: assign(({ event }) => { const sides = (event as NumberInputEvent).value; remember(SIDES_KEY, sides); return { sides }; }), recordCenter: assign(({ event }) => { const center = getPointFromEvent(null, event as PointInputEvent); setAngleGuideOriginPoint(center); // 반지름 물음의 고무줄 · 거리 숫자 기준 return { center }; }), recordFit: ({ event }) => remember(FIT_KEY, event.type.slice('OPTION.'.length)), previewRadius: ({ context, event }) => { const center = context.center as Point; const point = getPointFromEvent(center, event as DrawEvent); const polygon = polygonFrom(center, point, center.distanceTo(point)[0], context.sides); setGhostHelperEntities(polygon ? [polygon] : []); }, drawByRadius: ({ context, event }) => { const center = context.center as Point; if (event.type === 'NUMBER_INPUT') { add(polygonFrom(center, null, (event as NumberInputEvent).value, context.sides)); return; } const point = getPointFromEvent(center, event as PointInputEvent); add(polygonFrom(center, point, center.distanceTo(point)[0], context.sides)); }, recordEdge: assign(({ event }) => { const edgeStart = getPointFromEvent(null, event as PointInputEvent); setAngleGuideOriginPoint(edgeStart); return { edgeStart }; }), previewEdge: ({ context, event }) => { const start = context.edgeStart as Point; const polygon = polygonOnEdge( start, getPointFromEvent(start, event as DrawEvent), context.sides ); setGhostHelperEntities(polygon ? [polygon] : []); }, drawByEdge: ({ context, event }) => { const start = context.edgeStart as Point; add( polygonOnEdge(start, getPointFromEvent(start, event as PointInputEvent), context.sides) ); }, }, } );