Files
Aislo/B07_DesignDetail/openwebcad/src/tools/circle-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

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([]);
},
},
}
);