- 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
231 lines
8.2 KiB
TypeScript
231 lines
8.2 KiB
TypeScript
/**
|
|
* 원(CIRCLE) — 337 설계 1-1 · 오토캐드 차례.
|
|
* 「원에 대한 중심점 지정 또는 [3점(3P)/2점(2P)]:」 → 「원의 반지름 지정 또는 [지름(D)] <r>:」 ·
|
|
* 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<number>(RADIUS_KEY);
|
|
return {
|
|
text: '원의 지름 지정',
|
|
default: { kind: 'value', remember: DIAMETER_KEY, initial: radius ? radius * 2 : null },
|
|
number: { positive: true },
|
|
};
|
|
};
|
|
const ORDINAL = ['첫 번째', '두 번째', '세 번째'];
|
|
const threePrompt = (context: Partial<CircleContext>): PromptSpec => ({
|
|
text: `원 위의 ${ORDINAL[context.points?.length ?? 0]} 점 지정`,
|
|
});
|
|
const twoPrompt = (context: Partial<CircleContext>): 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([]);
|
|
},
|
|
},
|
|
}
|
|
);
|