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
This commit is contained in:
2026-10-16 00:16:15 +09:00
co-authored by Claude Opus 5.5
parent 6e346cfb5e
commit 3cebb37ed0
11 changed files with 1757 additions and 458 deletions
@@ -1,152 +1,159 @@
import type { Point } from '@flatten-js/core';
/**
* 직사각형(RECTANG) — 337 설계 1-1 · 오토캐드 차례.
* 「첫 번째 구석점 지정:」 → 「다른 구석점 지정 또는 [치수(D)]:」 ·
* D → 「직사각형의 길이 지정 <10.0000>:」 → 「직사각형의 폭 지정 <10.0000>:」(기억 RECT.L · RECT.W) →
* 다시 「다른 구석점 지정 또는 [치수(D)]:」(누른 쪽 사분면으로 놓음). 하나 그리면 끝.
*/
import { Point } from '@flatten-js/core';
import { assign, createMachine } from 'xstate';
import { type PromptSpec, remember } from '../commands/prompt';
import { RectangleEntity } from '../entities/RectangleEntity';
import { getPointFromEvent } from '../helpers/get-point-from-event.ts';
import {
addEntities,
getActiveLayerId,
getActiveLineColor,
getActiveLineDash,
getActiveLineWidth,
setAngleGuideOriginPoint,
setGhostHelperEntities,
setSelectedEntityIds,
setShouldDrawHelpers,
} from '../state';
import type { DrawEvent, PointInputEvent, StateEvent, ToolContext } from './tool.types';
import { Tool } from '../tools';
import { assign, createMachine } from 'xstate';
import { getPointFromEvent } from '../helpers/get-point-from-event.ts';
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 RectangleContext extends ToolContext {
startPoint: Point | null;
/** 치수(D)로 정한 길이 · 폭 — 있으면 다른 구석 = 사분면만 */
length: number | null;
width: number | null;
}
export enum RectangleState {
INIT = 'INIT',
WAITING_FOR_START_POINT = 'WAITING_FOR_START_POINT',
WAITING_FOR_END_POINT = 'WAITING_FOR_END_POINT',
WAITING_FOR_LENGTH = 'WAITING_FOR_LENGTH',
WAITING_FOR_WIDTH = 'WAITING_FOR_WIDTH',
}
export enum RectangleAction {
INIT_RECTANGLE_TOOL = 'INIT_RECTANGLE_TOOL',
RECORD_START_POINT = 'RECORD_START_POINT',
DRAW_TEMP_RECTANGLE = 'DRAW_TEMP_RECTANGLE',
DRAW_FINAL_RECTANGLE = 'DRAW_FINAL_RECTANGLE',
const START_PROMPT: PromptSpec = {
text: '첫 번째 구석점 지정',
absent: [
option('CHAMFER', 'C', '모따기'),
option('ELEVATION', 'E', '고도'),
option('FILLET', 'F', '모깎기'),
option('THICKNESS', 'T', '두께'),
option('WIDTH', 'W', '폭'),
],
};
const END_PROMPT: PromptSpec = {
text: '다른 구석점 지정',
options: [option('DIMENSIONS', 'D', '치수')],
absent: [option('AREA', 'A', '면적'), option('ROTATION', 'R', '회전')],
};
const sizePrompt = (text: string, key: string): PromptSpec => ({
text,
default: { kind: 'value', remember: key, initial: 10 },
number: { positive: true },
});
/** 다른 구석 — 치수가 있으면 누른 쪽 사분면에 길이 × 폭 */
function cornerOf(context: RectangleContext, point: Point): Point {
const start = context.startPoint as Point;
if (context.length === null || context.width === null) return point;
const sx = point.x < start.x ? -1 : 1;
const sy = point.y < start.y ? -1 : 1;
return new Point(start.x + sx * context.length, start.y + sy * context.width);
}
const rectangle = (context: RectangleContext, point: Point) =>
styled(
new RectangleEntity(getActiveLayerId(), context.startPoint as Point, cornerOf(context, point))
);
export const rectangleToolStateMachine = createMachine(
{
types: {} as {
context: RectangleContext;
events: StateEvent;
},
context: {
startPoint: null,
type: Tool.RECTANGLE,
},
types: {} as { context: RectangleContext; events: StateEvent },
context: { startPoint: null, length: null, width: null, type: Tool.RECTANGLE },
initial: RectangleState.INIT,
states: {
[RectangleState.INIT]: {
description: 'Initializing the rectangle tool',
always: {
actions: RectangleAction.INIT_RECTANGLE_TOOL,
target: RectangleState.WAITING_FOR_START_POINT,
},
always: { actions: 'init', target: RectangleState.WAITING_FOR_START_POINT },
},
[RectangleState.WAITING_FOR_START_POINT]: {
description: '첫 모서리 지정 (x,y):',
meta: {
instructions: '첫 모서리 지정 (x,y):',
},
meta: { kind: 'point', prompt: START_PROMPT },
on: {
MOUSE_CLICK: {
actions: RectangleAction.RECORD_START_POINT,
target: RectangleState.WAITING_FOR_END_POINT,
},
ABSOLUTE_POINT_INPUT: {
actions: RectangleAction.RECORD_START_POINT,
target: RectangleState.WAITING_FOR_END_POINT,
},
...firstPointEvents('recordStart', RectangleState.WAITING_FOR_END_POINT),
ESC: { target: END_STATE },
},
},
[RectangleState.WAITING_FOR_END_POINT]: {
description: '맞은 모서리 지정 (@x,y):',
meta: {
instructions: '맞은 모서리 지정 (@x,y):',
},
meta: { kind: 'point', prompt: END_PROMPT },
on: {
DRAW: {
actions: RectangleAction.DRAW_TEMP_RECTANGLE,
},
MOUSE_CLICK: {
actions: RectangleAction.DRAW_FINAL_RECTANGLE,
target: RectangleState.INIT,
},
NUMBER_INPUT: {
actions: RectangleAction.DRAW_FINAL_RECTANGLE, // TODO see if we want to add a flow where you enter the width and then the height if one of the dimensions of the "direction + distance" comes out to 0
target: RectangleState.INIT,
},
ABSOLUTE_POINT_INPUT: {
actions: RectangleAction.DRAW_FINAL_RECTANGLE,
target: RectangleState.INIT,
},
RELATIVE_POINT_INPUT: {
actions: RectangleAction.DRAW_FINAL_RECTANGLE,
target: RectangleState.INIT,
},
ESC: {
target: RectangleState.INIT,
},
DRAW: { actions: 'preview' },
...pointEvents('draw', END_STATE),
'OPTION.DIMENSIONS': { target: RectangleState.WAITING_FOR_LENGTH },
ESC: { target: END_STATE },
},
},
[RectangleState.WAITING_FOR_LENGTH]: {
meta: { kind: 'number', prompt: sizePrompt('직사각형의 길이 지정', 'RECT.L') },
on: {
NUMBER_INPUT: { actions: 'recordLength', target: RectangleState.WAITING_FOR_WIDTH },
ESC: { target: END_STATE },
},
},
[RectangleState.WAITING_FOR_WIDTH]: {
meta: { kind: 'number', prompt: sizePrompt('직사각형의 폭 지정', 'RECT.W') },
on: {
NUMBER_INPUT: { actions: 'recordWidth', target: RectangleState.WAITING_FOR_END_POINT },
ESC: { target: END_STATE },
},
},
...END,
},
},
{
actions: {
[RectangleAction.INIT_RECTANGLE_TOOL]: () => {
init: assign(() => {
setShouldDrawHelpers(true);
setGhostHelperEntities([]);
setSelectedEntityIds([]);
setAngleGuideOriginPoint(null);
},
[RectangleAction.RECORD_START_POINT]: assign(({ event }) => {
return { startPoint: null, length: null, width: null };
}),
recordStart: assign(({ event }) => {
const startPoint = getPointFromEvent(null, event as PointInputEvent);
setAngleGuideOriginPoint(startPoint);
return {
startPoint,
};
return { startPoint };
}),
[RectangleAction.DRAW_TEMP_RECTANGLE]: ({ context, event }) => {
if (!context.startPoint) {
throw new Error('[RECTANGLE]: calling draw without start point being set');
}
const activeRectangle = new RectangleEntity(
getActiveLayerId(),
context.startPoint as Point,
(event as DrawEvent).drawController.getWorldMouseLocation()
);
activeRectangle.lineColor = getActiveLineColor();
activeRectangle.lineWidth = getActiveLineWidth();
activeRectangle.lineDash = getActiveLineDash();
setGhostHelperEntities([activeRectangle]);
preview: ({ context, event }) => {
const point = getPointFromEvent(context.startPoint, event as DrawEvent);
setGhostHelperEntities([rectangle(context, point)]);
},
[RectangleAction.DRAW_FINAL_RECTANGLE]: ({ context, event }) => {
if (!context.startPoint) {
throw Error(
'Trying to DRAW_FINAL_RECTANGLE when startPoint is not defined in rectangle-tool'
);
}
const endPoint = getPointFromEvent(context.startPoint, event as PointInputEvent);
const activeRectangle = new RectangleEntity(
getActiveLayerId(),
context.startPoint as Point,
endPoint
);
activeRectangle.lineColor = getActiveLineColor();
activeRectangle.lineWidth = getActiveLineWidth();
activeRectangle.lineDash = getActiveLineDash();
addEntities([activeRectangle], true);
draw: ({ context, event }) => {
const point = getPointFromEvent(context.startPoint, event as PointInputEvent);
setGhostHelperEntities([]);
addEntities([rectangle(context, point)], true);
},
recordLength: assign(({ event }) => {
const length = (event as NumberInputEvent).value;
remember('RECT.L', length);
return { length };
}),
recordWidth: assign(({ event }) => {
const width = (event as NumberInputEvent).value;
remember('RECT.W', width);
return { width };
}),
},
}
);