Files
Aislo/B07_DesignDetail/openwebcad/src/tools/draw/polygon-tool.ts
T
eomsangdonandClaude Opus 5.5 3cebb37ed0 feat(CAD): 337-3a 나-1 그리기 8 — 오토캐드 물음 차례 · 옵션 · 기본값 · 빈 Enter · Esc · 끝난 뒤
- LINE: 「첫 번째 점 지정」 빈 Enter = 마지막 끝점에서 이음 · U = 마지막 토막(되돌림 한 칸) · 둘째 토막부터 C · Enter · Esc = 끝(토막 남음)
- PLINE(새 pline-tool): 호(A) 접선 호 · 길이(L) · 닫기(C · 호 모드 CL) · 명령 취소(U) · 선(L) · Esc · Enter = 그린 데까지 남김
- RECTANG: 치수(D) 길이 · 폭 기억(RECT.L · RECT.W) → 누른 쪽 사분면 · 없는 옵션 안내
- CIRCLE: 3P · 2P · 지름(D) <2r> · 반지름 기억(CIRCLERAD · 처음 <> 없음) · 한 번 뒤 끝
- POLYGON(새 polygon-tool): 변 수 기억 <4> · 3~1024 정수 · 모서리(E) · 내접/외접 기억 · 숫자 = 아래 변 수평
- ARC(새 arc-tool): 세 점 · 중심(C) · 각도(A) · 끝(E) · 반지름(R)
- ELLIPSE(새 ellipse-tool): 축 두 끝 · 중심(C) · 회전(R) · 다른 축 숫자 = 중심에서 반 길이(N10)
- SPLINE: 다음 점 입력 · U · 넷째 점부터 C · Esc 버림 · DONUT 중심 되풀이(loopFrom)
- 시험 draw-acad 22 · keyboard-chain 옛 선 · 폴리선 줄 두 곳 새 동작으로

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

221 lines
7.5 KiB
TypeScript

/**
* 폴리곤(POLYGON) — 337 설계 1-1 · 오토캐드 차례.
* 「변의 수 입력 <4>:」(3~1024 정수 · 기억) → 「폴리곤의 중심 지정 또는 [모서리(E)]:」 →
* 「옵션 입력 [원에 내접(I)/원에 외접(C)] <I>:」(기억) → 「원의 반지름 지정:」 ·
* 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<typeof polyLineEntity>) => {
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)
);
},
},
}
);