- 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
221 lines
7.5 KiB
TypeScript
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)
|
|
);
|
|
},
|
|
},
|
|
}
|
|
);
|