Merge remote-tracking branch 'origin/sub_laptop_5' into main_laptop_1

This commit is contained in:
2026-10-16 00:16:51 +09:00
11 changed files with 1757 additions and 458 deletions
@@ -0,0 +1,126 @@
/**
* 호(ARC) — 337 설계 1-1 · 오토캐드 차례(순서 엔진 v2).
* 「호의 시작점 지정:」 → 「호의 두 번째 점 지정 또는 [중심(C)/끝(E)]:」 → 「호의 끝점 지정:」(세 점) ·
* C → 「호의 중심점 지정:」 → 「호의 끝점 지정 또는 [각도(A)]:」(A → 「사이각 지정:」) ·
* E → 「호의 끝점 지정:」 → 「호의 중심점 지정 또는 [반지름(R)]:」(R → 「호의 반지름 지정:」) ·
* 중심 · 끝 · 반지름 방식은 시작점에서 반시계(오토캐드) · 사이각 − = 시계 · 반지름 = 작은 호. 하나 그리면 끝.
*/
import { Point } from '@flatten-js/core';
import type { Entity } from '../../entities/Entity';
import { type ArcDefinition, arcThroughThreePoints } from '../../helpers/geometry/shape-points';
import { addEntities } from '../../state';
import { Tool } from '../../tools';
import { arcEntity, lineEntity } from '../factories/entity-factory';
import { type SequenceInput, createSequenceTool } from '../factories/sequence-tool';
import { option, rememberDrawEnd } from './draw-steps';
const angleOf = (center: Point, point: Point) => Math.atan2(point.y - center.y, point.x - center.x);
/** 중심 · 시작점 · 끝각 — 반시계(사이각 − 면 시계) */
const arcAround = (center: Point, start: Point, endAngle: number, ccw = true): ArcDefinition => ({
center,
radius: center.distanceTo(start)[0],
startAngle: angleOf(center, start),
endAngle,
counterClockwise: ccw,
});
/** 시작 · 끝 · 반지름 — 반시계 작은 호 · 반지름이 현의 반보다 작으면 null */
function arcByRadius(start: Point, end: Point, radius: number): ArcDefinition | null {
const half = start.distanceTo(end)[0] / 2;
if (half === 0 || radius < half) return null;
const rise = Math.sqrt(radius * radius - half * half);
const ux = (end.x - start.x) / (2 * half);
const uy = (end.y - start.y) / (2 * half);
const center = new Point((start.x + end.x) / 2 - uy * rise, (start.y + end.y) / 2 + ux * rise);
return arcAround(center, start, angleOf(center, end));
}
/** 지금 값으로 호 — 커서(미리보기)가 마지막 빈 값을 채움 */
function arcOf(input: SequenceInput, cursor?: Point): ArcDefinition | null {
const start = input.get<Point>('start');
const at = (id: string) => (input.has(id) ? input.get<Point>(id) : cursor);
if (input.mode === 'C' || input.mode === 'CA') {
const center = input.get<Point>('center');
if (input.mode === 'CA') {
const sweep = input.has('angle') ? input.get<number>('angle') : null;
if (sweep === null) return null;
return arcAround(center, start, angleOf(center, start) + (sweep * Math.PI) / 180, sweep > 0);
}
const end = at('cEnd');
return end ? arcAround(center, start, angleOf(center, end)) : null;
}
if (input.mode === 'E' || input.mode === 'ER') {
const end = input.get<Point>('eEnd');
if (input.mode === 'ER') {
const radius = at('radius');
return radius ? arcByRadius(start, end, end.distanceTo(radius)[0]) : null;
}
const center = at('eCenter');
return center ? arcAround(center, start, angleOf(center, end)) : null;
}
const third = at('third');
return third ? arcThroughThreePoints(start, input.get<Point>('second'), third) : null;
}
const POINT_TEXT = '호의 끝점 지정';
const when =
(...modes: (string | null)[]) =>
(input: SequenceInput) =>
modes.includes(input.mode);
export const arcToolStateMachine = createSequenceTool({
tool: Tool.ARC,
steps: [
{ kind: 'point', id: 'start', prompt: { text: '호의 시작점 지정' } },
{
kind: 'point',
id: 'second',
prompt: {
text: '호의 두 번째 점 지정',
options: [option('CENTER', 'C', '중심'), option('END', 'E', '끝')],
},
options: { CENTER: { mode: 'C', next: 'center' }, END: { mode: 'E', next: 'eEnd' } },
},
{ kind: 'point', id: 'third', prompt: { text: POINT_TEXT }, when: when(null) },
{ kind: 'point', id: 'center', prompt: { text: '호의 중심점 지정' }, when: when('C', 'CA') },
{
kind: 'point',
id: 'cEnd',
prompt: { text: POINT_TEXT, options: [option('ANGLE', 'A', '각도')] },
options: { ANGLE: { mode: 'CA', next: 'angle' } },
when: when('C'),
},
{ kind: 'number', id: 'angle', prompt: { text: '사이각 지정' }, when: when('CA') },
{ kind: 'point', id: 'eEnd', prompt: { text: POINT_TEXT }, when: when('E', 'ER') },
{
kind: 'point',
id: 'eCenter',
prompt: { text: '호의 중심점 지정', options: [option('RADIUS', 'R', '반지름')] },
options: { RADIUS: { mode: 'ER', next: 'radius' } },
when: when('E'),
},
{ kind: 'point', id: 'radius', prompt: { text: '호의 반지름 지정' }, when: when('ER') },
],
preview: (input) => {
const arc = input.values.length > 1 ? arcOf(input, input.cursor) : null;
const shown: Entity[] = arc ? [arcEntity(arc)] : [];
const last = input.points()[input.points().length - 1];
return arc || !last ? shown : [lineEntity(last, input.cursor)];
},
commit: (input) => {
const arc = arcOf(input);
if (!arc) {
// 세 점 한 줄 = 선(오토캐드는 거절 — 버리지 않게 선으로)
if (input.mode === null) {
addEntities([lineEntity(input.get<Point>('start'), input.get<Point>('third'))], true);
}
return;
}
addEntities([arcEntity(arc)], true);
rememberDrawEnd({
x: arc.center.x + arc.radius * Math.cos(arc.endAngle),
y: arc.center.y + arc.radius * Math.sin(arc.endAngle),
});
},
});
@@ -1,114 +1,65 @@
/** 그리기 명령 — 폴리선·호·다각형·타원·스플라인·점 (조사표 1절) */
/** 그리기 명령 — 스플라인·점·도넛 + 폴리선·호·다각형·타원 다시 내보냄 (조사표 1절 · 337 설계 1-1) */
import { CircleEntity } from '../../entities/CircleEntity';
import type { Entity } from '../../entities/Entity';
import {
arcThroughThreePoints,
ellipsePoints,
regularPolygonPoints,
splinePoints,
} from '../../helpers/geometry/shape-points';
import type { Point } from '@flatten-js/core';
import { OPT } from '../../commands/prompt';
import { splinePoints } from '../../helpers/geometry/shape-points';
import { addEntities, getActiveLayerId } from '../../state';
import { Tool } from '../../tools';
import {
arcEntity,
lineEntity,
pointEntity,
polyLineEntity,
styled,
} from '../factories/entity-factory';
import { pointEntity, polyLineEntity, styled } from '../factories/entity-factory';
import { createSequenceTool } from '../factories/sequence-tool';
import { option } from './draw-steps';
export const plineToolStateMachine = createSequenceTool({
tool: Tool.PLINE,
steps: [
{ kind: 'point', instructions: '폴리선의 시작점을 지정하십시오.' },
{ kind: 'point', instructions: '다음 점을 지정하십시오. ENTER로 종료합니다.' },
],
repeatLastStep: true,
preview: (input) => {
const preview = polyLineEntity([...input.points(), input.cursor]);
return preview ? [preview] : [];
},
commit: (input) => {
const polyline = polyLineEntity(input.points());
if (polyline) addEntities([polyline], true);
},
});
export { arcToolStateMachine } from './arc-tool';
export { ellipseToolStateMachine } from './ellipse-tool';
export { plineToolStateMachine } from './pline-tool';
export { polygonToolStateMachine } from './polygon-tool';
export const arcToolStateMachine = createSequenceTool({
tool: Tool.ARC,
steps: [
{ kind: 'point', instructions: '호의 시작점을 지정하십시오.' },
{ kind: 'point', instructions: '호가 지나갈 두 번째 점을 지정하십시오.' },
{ kind: 'point', instructions: '호의 끝점을 지정하십시오.' },
],
preview: (input) => {
const points = input.points();
if (points.length === 1) return [lineEntity(points[0], input.cursor)];
if (points.length === 2) {
const definition = arcThroughThreePoints(points[0], points[1], input.cursor);
return definition ? [arcEntity(definition)] : [lineEntity(points[0], input.cursor)];
}
return [];
},
commit: (input) => {
const [start, through, end] = input.points();
const definition = arcThroughThreePoints(start, through, end);
// 세 점이 일직선이면 호가 성립하지 않으므로 선으로 대체한다
addEntities([definition ? arcEntity(definition) : lineEntity(start, end)], true);
},
});
/** 닫힌 스플라인 — 끝 둘레 이웃을 이어 붙여 그린 뒤 이음 구간만 남김 */
function closedSplinePoints(points: Point[]): Point[] {
const spans = 12;
const wrapped = splinePoints([points[points.length - 1], ...points, points[0], points[1]], spans);
const loop = wrapped.slice(spans, spans * (points.length + 1));
return [...loop, loop[0]];
}
export const polygonToolStateMachine = createSequenceTool({
tool: Tool.POLYGON,
steps: [
{ kind: 'number', instructions: '면의 수를 입력하십시오 <6>.', defaultValue: 6 },
{ kind: 'point', instructions: '다각형의 중심을 지정하십시오.' },
{ kind: 'point', instructions: '꼭짓점을 지정하십시오 (중심에서의 반지름).' },
],
preview: (input) => {
const points = input.points();
if (points.length !== 1) return [];
const polygon = polyLineEntity(regularPolygonPoints(points[0], input.cursor, input.number(0)));
return polygon ? [polygon] : [];
},
commit: (input) => {
const [center, vertex] = input.points();
const polygon = polyLineEntity(regularPolygonPoints(center, vertex, input.number(0)));
if (polygon) addEntities([polygon], true);
},
});
export const ellipseToolStateMachine = createSequenceTool({
tool: Tool.ELLIPSE,
steps: [
{ kind: 'point', instructions: '타원의 중심을 지정하십시오.' },
{ kind: 'point', instructions: '장축의 끝점을 지정하십시오.' },
{ kind: 'point', instructions: '단축 거리를 지정하십시오.' },
],
preview: (input) => {
const points = input.points();
if (points.length === 1) return [lineEntity(points[0], input.cursor)];
if (points.length === 2) {
const minor = points[0].distanceTo(input.cursor)[0];
const ellipse = polyLineEntity(ellipsePoints(points[0], points[1], minor));
return ellipse ? [ellipse] : [];
}
return [];
},
commit: (input) => {
const [center, majorPoint, minorPoint] = input.points();
const minorRadius = center.distanceTo(minorPoint)[0];
const ellipse = polyLineEntity(ellipsePoints(center, majorPoint, minorRadius));
if (ellipse) addEntities([ellipse], true);
},
});
const NEXT_POINT = '다음 점 입력';
const TOLERANCE = option('TOLERANCE', 'L', '공차');
/**
* 스플라인(SPLINE) — 337 설계 1-1. 「첫 번째 점 지정:」 → 「다음 점 입력:」 → 「다음 점 입력 또는 [명령 취소(U)]:」 →
* 넷째 점부터 [명령 취소(U)/닫기(C)] 되풀이 · 빈 Enter = 확정 · C = 닫고 끝 · Esc = 버림.
*/
export const splineToolStateMachine = createSequenceTool({
tool: Tool.SPLINE,
steps: [
{ kind: 'point', instructions: '스플라인의 첫 점을 지정하십시오.' },
{ kind: 'point', instructions: '다음 점을 지정하십시오. ENTER로 종료합니다.' },
{
kind: 'point',
prompt: {
text: '첫 번째 점 지정',
absent: [
option('METHOD', 'M', '방법'),
option('KNOTS', 'K', '매듭'),
option('OBJECT', 'O', '객체'),
],
},
},
{
kind: 'point',
prompt: {
text: NEXT_POINT,
absent: [option('TANGENT', 'T', '시작 접촉부'), TOLERANCE],
},
},
{
kind: 'point',
prompt: (input) => ({
text: NEXT_POINT,
options: input.points().length >= 3 ? [OPT.UNDO, OPT.CLOSE] : [OPT.UNDO],
absent: [option('TANGENT', 'T', '끝 접촉부'), TOLERANCE],
}),
options: { UNDO: 'undo', CLOSE: 'close' },
},
],
repeatLastStep: true,
preview: (input) => {
@@ -116,7 +67,8 @@ export const splineToolStateMachine = createSequenceTool({
return preview ? [preview] : [];
},
commit: (input) => {
const spline = polyLineEntity(splinePoints(input.points()));
const points = input.points();
const spline = polyLineEntity(input.closed ? closedSplinePoints(points) : splinePoints(points));
if (spline) addEntities([spline], true);
},
});
@@ -138,6 +90,7 @@ export const donutToolStateMachine = createSequenceTool({
{ kind: 'number', instructions: '도넛의 외부 지름을 입력하십시오 <2>.', defaultValue: 2 },
{ kind: 'point', instructions: '도넛의 중심을 지정하십시오.' },
],
loopFrom: 2, // 오토캐드 — 중심 되풀이 · 빈 Enter 끝
preview: (input) => donutEntities(input.number(0), input.number(1), input.cursor),
commit: (input) => {
addEntities(donutEntities(input.number(0), input.number(1), input.point(2)), true);
@@ -0,0 +1,39 @@
/**
* 손으로 짠 그리기 도구 공용(337 설계 2-14) — 점 물음 사건 · 옵션 한 줄 · 끝 상태 · 마지막 그린 끝점(LINE.END).
* 선 · 폴리선 · 원 · 사각형 · 다각형 · 타원이 같이 씀.
*/
import { Point } from '@flatten-js/core';
import { type OptionSpec, type XY, recall, remember } from '../../commands/prompt';
import { END_STATE } from '../tool.types';
/** 옵션 하나 — 그림 「label(kw)」 · 사건 `OPTION.<key>` · 맞추기 kw · en */
export const option = (key: string, kw: string, label: string, en = key): OptionSpec => ({
key,
kw,
en,
label,
});
/** 첫 점 물음(기준점 없음) — 누름 · 절대 좌표만 */
export const firstPointEvents = (actions: string | string[], target: string) => ({
MOUSE_CLICK: { actions, target },
ABSOLUTE_POINT_INPUT: { actions, target },
});
/** 기준점 있는 점 물음 — 누름 · 절대 · 상대 · 거리 숫자 */
export const pointEvents = (actions: string | string[], target: string) => ({
...firstPointEvents(actions, target),
RELATIVE_POINT_INPUT: { actions, target },
NUMBER_INPUT: { actions, target },
});
/** 명령 끝 상태 — 들면 쉬는 상태(선택 도구)로 */
export const END = { [END_STATE]: { type: 'final' as const } };
// 마지막 선 · 폴리선 · 호 끝점 — LINE · PLINE 첫 점 빈 Enter 가 여기서 이어 그림(오토캐드)
const LINE_END = 'LINE.END';
export const rememberDrawEnd = (point: XY): void => remember(LINE_END, { x: point.x, y: point.y });
export const lastDrawEnd = (): Point | null => {
const point = recall<XY>(LINE_END);
return point ? new Point(point.x, point.y) : null;
};
@@ -0,0 +1,176 @@
/**
* 타원(ELLIPSE) — 337 설계 1-1 · 오토캐드 차례.
* 「타원의 축 끝점 지정 또는 [중심(C)]:」 → 「축의 다른 끝점 지정:」 → 「다른 축으로 거리를 지정 또는 [회전(R)]:」 ·
* C → 「타원의 중심 지정:」 → 「축의 끝점 지정:」 → 같은 거리 물음 · R → 「주축 둘레로 회전 지정:」(다른 축 = 축 × cos).
* 다른 축 거리 = 중심에서 잰 반 길이(숫자도 · N10). 하나 그리면 끝.
*/
import { Point } from '@flatten-js/core';
import { assign, createMachine } from 'xstate';
import { getPointFromEvent } from '../../helpers/get-point-from-event';
import { ellipsePoints } from '../../helpers/geometry/shape-points';
import {
addEntities,
setAngleGuideOriginPoint,
setGhostHelperEntities,
setSelectedEntityIds,
setShouldDrawHelpers,
} from '../../state';
import { Tool } from '../../tools';
import { lineEntity, 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 EllipseContext extends ToolContext {
/** 축 끝점 방식의 첫 끝 · 중심 방식의 중심 */
first: Point | null;
center: Point | null;
axisEnd: Point | null;
}
enum S {
INIT = 'INIT',
AXIS_START = 'AXIS_START',
AXIS_END = 'AXIS_END',
CENTER = 'CENTER',
CENTER_AXIS = 'CENTER_AXIS',
DISTANCE = 'DISTANCE',
ROTATION = 'ROTATION',
}
const ellipseOf = (context: EllipseContext, other: number) =>
polyLineEntity(ellipsePoints(context.center as Point, context.axisEnd as Point, other));
const add = (context: EllipseContext, other: number) => {
const ellipse = ellipseOf(context, other);
setGhostHelperEntities([]);
if (ellipse) addEntities([ellipse], true);
};
/** 축이 정해짐 — 다른 축 거리의 고무줄 · 거리 숫자 기준 = 중심 */
const axisDone = (center: Point, axisEnd: Point) => {
setAngleGuideOriginPoint(center);
return { center, axisEnd };
};
export const ellipseToolStateMachine = createMachine(
{
types: {} as { context: EllipseContext; events: StateEvent },
context: { first: null, center: null, axisEnd: null, type: Tool.ELLIPSE },
initial: S.INIT,
states: {
[S.INIT]: { always: { actions: 'init', target: S.AXIS_START } },
[S.AXIS_START]: {
meta: {
kind: 'point',
prompt: {
text: '타원의 축 끝점 지정',
options: [option('CENTER', 'C', '중심')],
absent: [option('ARC', 'A', '호')],
},
},
on: {
...firstPointEvents('recordFirst', S.AXIS_END),
'OPTION.CENTER': { target: S.CENTER },
ESC: { target: END_STATE },
},
},
[S.AXIS_END]: {
meta: { kind: 'point', prompt: { text: '축의 다른 끝점 지정' } },
on: {
DRAW: { actions: 'previewAxis' },
...pointEvents('recordAxisEnd', S.DISTANCE),
ESC: { target: END_STATE },
},
},
[S.CENTER]: {
meta: { kind: 'point', prompt: { text: '타원의 중심 지정' } },
on: { ...firstPointEvents('recordFirst', S.CENTER_AXIS), ESC: { target: END_STATE } },
},
[S.CENTER_AXIS]: {
meta: { kind: 'point', prompt: { text: '축의 끝점 지정' } },
on: {
DRAW: { actions: 'previewAxis' },
...pointEvents('recordCenterAxis', S.DISTANCE),
ESC: { target: END_STATE },
},
},
[S.DISTANCE]: {
meta: {
kind: 'point',
prompt: {
text: '다른 축으로 거리를 지정',
options: [option('ROTATION', 'R', '회전')],
number: { positive: true },
},
},
on: {
DRAW: { actions: 'previewDistance' },
...pointEvents('drawByDistance', END_STATE),
'OPTION.ROTATION': { target: S.ROTATION },
ESC: { target: END_STATE },
},
},
[S.ROTATION]: {
meta: {
kind: 'number',
prompt: { text: '주축 둘레로 회전 지정', number: { min: 0, max: 89.4 } },
},
on: {
NUMBER_INPUT: { actions: 'drawByRotation', target: END_STATE },
ESC: { target: END_STATE },
},
},
...END,
},
},
{
actions: {
init: () => {
setShouldDrawHelpers(true);
setSelectedEntityIds([]);
setGhostHelperEntities([]);
setAngleGuideOriginPoint(null);
},
recordFirst: assign(({ event }) => {
const first = getPointFromEvent(null, event as PointInputEvent);
setAngleGuideOriginPoint(first);
return { first };
}),
previewAxis: ({ context, event }) => {
const first = context.first as Point;
setGhostHelperEntities([lineEntity(first, getPointFromEvent(first, event as DrawEvent))]);
},
recordAxisEnd: assign(({ context, event }) => {
const first = context.first as Point;
const end = getPointFromEvent(first, event as PointInputEvent);
return axisDone(new Point((first.x + end.x) / 2, (first.y + end.y) / 2), end);
}),
recordCenterAxis: assign(({ context, event }) => {
const center = context.first as Point;
return axisDone(center, getPointFromEvent(center, event as PointInputEvent));
}),
previewDistance: ({ context, event }) => {
const center = context.center as Point;
const other = center.distanceTo(getPointFromEvent(center, event as DrawEvent))[0];
const ellipse = ellipseOf(context, other);
setGhostHelperEntities(ellipse ? [ellipse] : []);
},
drawByDistance: ({ context, event }) => {
const center = context.center as Point;
add(context, center.distanceTo(getPointFromEvent(center, event as PointInputEvent))[0]);
},
drawByRotation: ({ context, event }) => {
const axis = (context.center as Point).distanceTo(context.axisEnd as Point)[0];
const angle = ((event as NumberInputEvent).value * Math.PI) / 180;
add(context, axis * Math.cos(angle));
},
},
}
);
@@ -0,0 +1,277 @@
/**
* 폴리선(PLINE) — 337 설계 1-1 · 오토캐드 차례.
* 「시작점 지정:」(빈 Enter = 마지막 끝점에서) → 「다음 점 지정 또는 [호(A)/길이(L)/명령 취소(U)]:」 되풀이 ·
* 둘째 토막부터 [호(A)/닫기(C)/길이(L)/명령 취소(U)] · 호 모드 「호의 끝점 지정 또는 [닫기(CL)/선(L)/명령 취소(U)]:」.
* 호 = 앞 토막 끝 방향에 접하는 호(첫 토막이면 동쪽 0°) · 길이 = 앞 토막 방향으로 그 길이 ·
* 빈 Enter · Esc = 그린 데까지 폴리선 남기고 끝.
*/
import { Point } from '@flatten-js/core';
import { assign, createMachine } from 'xstate';
import { OPT, type PromptSpec, type XY } from '../../commands/prompt';
import type { Entity } from '../../entities/Entity';
import { PolyLineEntity } from '../../entities/PolyLineEntity';
import { getPointFromEvent } from '../../helpers/get-point-from-event';
import type { ArcDefinition } from '../../helpers/geometry/shape-points';
import {
addEntities,
getActiveLayerId,
setAngleGuideOriginPoint,
setGhostHelperEntities,
setSelectedEntityIds,
setShouldDrawHelpers,
} from '../../state';
import { Tool } from '../../tools';
import { arcEntity, lineEntity, styled } from '../factories/entity-factory';
import {
type DrawEvent,
END_STATE,
type NumberInputEvent,
type PointInputEvent,
type StateEvent,
type ToolContext,
} from '../tool.types';
import {
END,
firstPointEvents,
lastDrawEnd,
option,
pointEvents,
rememberDrawEnd,
} from './draw-steps';
/** 토막 하나 — 끝점 · 호면 그 정의(없으면 선) */
interface Segment {
end: Point;
arc: ArcDefinition | null;
}
export interface PlineContext extends ToolContext {
start: Point | null;
segments: Segment[];
}
enum S {
INIT = 'INIT',
START = 'START',
LINE = 'LINE',
ARC = 'ARC',
LENGTH = 'LENGTH',
}
const EAST: XY = { x: 1, y: 0 };
const lastEnd = (context: PlineContext): Point =>
context.segments[context.segments.length - 1]?.end ?? (context.start as Point);
/** 마지막 토막 끝의 진행 방향(단위) — 선 = 그 방향 · 호 = 끝 접선 · 토막 없음 = 동쪽 */
function endDirection(context: PlineContext): XY {
const count = context.segments.length;
if (!count) return EAST;
const { end, arc } = context.segments[count - 1];
if (arc) {
const rx = (end.x - arc.center.x) / arc.radius;
const ry = (end.y - arc.center.y) / arc.radius;
return arc.counterClockwise ? { x: -ry, y: rx } : { x: ry, y: -rx };
}
const from = count > 1 ? context.segments[count - 2].end : (context.start as Point);
const length = Math.hypot(end.x - from.x, end.y - from.y);
return length > 0 ? { x: (end.x - from.x) / length, y: (end.y - from.y) / length } : EAST;
}
/** from 에서 방향 dir 에 접하고 to 를 지나는 호 — 한 줄 위면 null(선) */
export function tangentArc(from: Point, dir: XY, to: Point): ArcDefinition | null {
const dx = to.x - from.x;
const dy = to.y - from.y;
const cross = dir.x * dy - dir.y * dx;
if (Math.abs(cross) < 1e-9) return null;
const signed = (dx * dx + dy * dy) / (2 * cross); // + = 왼쪽 중심(반시계)
const center = new Point(from.x - dir.y * signed, from.y + dir.x * signed);
return {
center,
radius: Math.abs(signed),
startAngle: Math.atan2(from.y - center.y, from.x - center.x),
endAngle: Math.atan2(to.y - center.y, to.x - center.x),
counterClockwise: cross > 0,
};
}
const segmentEntity = (from: Point, segment: Segment): Entity =>
segment.arc ? arcEntity(segment.arc) : lineEntity(from, segment.end);
function segmentEntities(context: PlineContext, extra?: Segment): Entity[] {
const list = extra ? [...context.segments, extra] : context.segments;
let from = context.start as Point;
return list.map((segment) => {
const entity = segmentEntity(from, segment);
from = segment.end;
return entity;
});
}
const lineTo = (end: Point): Segment => ({ end, arc: null });
const arcTo = (context: PlineContext, end: Point): Segment => ({
end,
arc: tangentArc(lastEnd(context), endDirection(context), end),
});
const ARC = option('ARC', 'A', '호');
const LENGTH = option('LENGTH', 'L', '길이');
const LINE = option('LINE', 'L', '선');
const ARC_CLOSE = option('CLOSE', 'CL', '닫기');
const WIDTHS = [option('HALFWIDTH', 'H', '반폭'), option('WIDTH', 'W', '폭')];
const hasSegment = (context: Partial<PlineContext>) => (context.segments?.length ?? 0) > 0;
const linePrompt = (context: Partial<PlineContext>): PromptSpec => ({
text: '다음 점 지정',
options: hasSegment(context) ? [ARC, OPT.CLOSE, LENGTH, OPT.UNDO] : [ARC, LENGTH, OPT.UNDO],
absent: WIDTHS,
});
const arcPrompt = (context: Partial<PlineContext>): PromptSpec => ({
text: '호의 끝점 지정',
options: hasSegment(context) ? [ARC_CLOSE, LINE, OPT.UNDO] : [LINE, OPT.UNDO],
absent: [
option('ANGLE', 'A', '각도'),
option('CENTER', 'CE', '중심'),
option('DIRECTION', 'D', '방향'),
option('RADIUS', 'R', '반지름'),
option('SECOND', 'S', '두 번째 점'),
...WIDTHS,
],
});
/** 토막 하나 더함 — 기준점 · 끝점 기억 */
const pushSegment = (context: PlineContext, segment: Segment) => {
setGhostHelperEntities([]);
setAngleGuideOriginPoint(segment.end);
return { segments: [...context.segments, segment] };
};
export const plineToolStateMachine = createMachine(
{
types: {} as { context: PlineContext; events: StateEvent },
context: { start: null, segments: [], type: Tool.PLINE },
initial: S.INIT,
states: {
[S.INIT]: { always: { actions: 'init', target: S.START } },
[S.START]: {
meta: { kind: 'point', prompt: { text: '시작점 지정' } },
on: {
...firstPointEvents('recordStart', S.LINE),
ENTER: { guard: () => !!lastDrawEnd(), actions: 'continueFromLast', target: S.LINE },
ESC: { target: END_STATE },
},
},
[S.LINE]: {
meta: { kind: 'point', prompt: linePrompt },
on: {
DRAW: { actions: 'previewLine' },
...pointEvents('addLine', S.LINE),
'OPTION.ARC': { target: S.ARC },
'OPTION.LENGTH': { target: S.LENGTH },
'OPTION.CLOSE': {
guard: 'hasSegment',
actions: ['closeLine', 'commit'],
target: END_STATE,
},
'OPTION.UNDO': { actions: 'undoSegment' },
ENTER: { actions: 'commit', target: END_STATE },
ESC: { actions: 'commit', target: END_STATE },
},
},
[S.ARC]: {
meta: { kind: 'point', prompt: arcPrompt },
on: {
DRAW: { actions: 'previewArc' },
...pointEvents('addArc', S.ARC),
'OPTION.LINE': { target: S.LINE },
'OPTION.CLOSE': {
guard: 'hasSegment',
actions: ['closeArc', 'commit'],
target: END_STATE,
},
'OPTION.UNDO': { actions: 'undoSegment' },
ENTER: { actions: 'commit', target: END_STATE },
ESC: { actions: 'commit', target: END_STATE },
},
},
[S.LENGTH]: {
meta: { kind: 'number', prompt: { text: '선의 길이 지정', number: { positive: true } } },
on: {
NUMBER_INPUT: { actions: 'addLength', target: S.LINE },
ESC: { actions: 'commit', target: END_STATE },
},
},
...END,
},
},
{
// 옵션 사건은 이름 붙인 guard 로(줄 안 guard 는 글자 사건 꼴과 타입이 안 맞음)
guards: { hasSegment: ({ context }) => hasSegment(context) },
actions: {
init: assign(() => {
setShouldDrawHelpers(true);
setSelectedEntityIds([]);
setGhostHelperEntities([]);
setAngleGuideOriginPoint(null);
return { start: null, segments: [] };
}),
recordStart: assign(({ event }) => {
const start = getPointFromEvent(null, event as PointInputEvent);
setAngleGuideOriginPoint(start);
return { start };
}),
continueFromLast: assign(() => {
const start = lastDrawEnd() as Point;
setAngleGuideOriginPoint(start);
return { start };
}),
previewLine: ({ context, event }) => {
const end = getPointFromEvent(lastEnd(context), event as DrawEvent);
setGhostHelperEntities(segmentEntities(context, lineTo(end)));
},
previewArc: ({ context, event }) => {
const end = getPointFromEvent(lastEnd(context), event as DrawEvent);
setGhostHelperEntities(segmentEntities(context, arcTo(context, end)));
},
addLine: assign(({ context, event }) =>
pushSegment(context, lineTo(getPointFromEvent(lastEnd(context), event as PointInputEvent)))
),
addArc: assign(({ context, event }) =>
pushSegment(
context,
arcTo(context, getPointFromEvent(lastEnd(context), event as PointInputEvent))
)
),
addLength: assign(({ context, event }) => {
const from = lastEnd(context);
const dir = endDirection(context);
const length = (event as NumberInputEvent).value;
return pushSegment(
context,
lineTo(new Point(from.x + dir.x * length, from.y + dir.y * length))
);
}),
closeLine: assign(({ context }) => ({
segments: [...context.segments, lineTo(context.start as Point)],
})),
closeArc: assign(({ context }) => ({
segments: [...context.segments, arcTo(context, context.start as Point)],
})),
undoSegment: assign(({ context }) => {
const segments = context.segments.slice(0, -1);
setGhostHelperEntities([]);
setAngleGuideOriginPoint(segments[segments.length - 1]?.end ?? context.start);
return { segments };
}),
commit: ({ context }) => {
setGhostHelperEntities([]);
if (!context.segments.length) return;
addEntities(
[styled(new PolyLineEntity(getActiveLayerId(), segmentEntities(context)))],
true
);
rememberDrawEnd(lastEnd(context));
},
},
}
);
@@ -0,0 +1,220 @@
/**
* 폴리곤(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)
);
},
},
}
);