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
@@ -1,172 +1,229 @@
/**
* 원(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 type { Point } from '@flatten-js/core';
import { getPointFromEvent } from '../helpers/get-point-from-event.ts';
import { arcThroughThreePoints } from '../helpers/geometry/shape-points';
import {
addEntities,
getActiveLayerId,
getActiveLineColor,
getActiveLineDash,
getActiveLineWidth,
setActiveToolActor,
setAngleGuideOriginPoint,
setGhostHelperEntities,
setSelectedEntityIds,
setShouldDrawHelpers,
} from '../state';
import type { DrawEvent, PointInputEvent, StateEvent, ToolContext } from './tool.types';
import { Tool } from '../tools';
import { Actor, assign, createMachine } from 'xstate';
import { pointDistance } from '../helpers/distance-between-points';
import { LineState } from './line-tool.ts';
import { selectToolStateMachine } from './select-tool.ts';
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 CircleContext extends ToolContext {
centerPoint: Point | null;
/** 3P · 2P 에서 이미 찍은 점 */
points: Point[];
threePoints: boolean;
}
export enum CircleState {
WAITING_FOR_CENTER_POINT = 'WAITING_FOR_CENTER_POINT',
WAITING_FOR_POINT_ON_CIRCLE = 'WAITING_FOR_POINT_ON_CIRCLE',
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',
}
export enum CircleAction {
INIT_CIRCLE_TOOL = 'INIT_CIRCLE_TOOL',
RECORD_START_POINT = 'RECORD_START_POINT',
DRAW_TEMP_CIRCLE = 'DRAW_TEMP_CIRCLE',
DRAW_FINAL_CIRCLE = 'DRAW_FINAL_CIRCLE',
SWITCH_TO_SELECT_TOOL = 'SWITCH_TO_SELECT_TOOL',
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,
type: Tool.CIRCLE,
},
types: {} as { context: CircleContext; events: StateEvent },
context: { centerPoint: null, points: [], threePoints: false, type: Tool.CIRCLE },
initial: CircleState.INIT,
states: {
[CircleState.INIT]: {
description: 'Initializing the circle tool',
always: {
actions: CircleAction.INIT_CIRCLE_TOOL,
target: CircleState.WAITING_FOR_CENTER_POINT,
},
always: { actions: 'init', target: CircleState.WAITING_FOR_CENTER_POINT },
},
[CircleState.WAITING_FOR_CENTER_POINT]: {
description: 'Select the center point of the circle tool',
meta: {
instructions: '중심점 지정 (x,y):',
},
meta: { kind: 'point', prompt: CENTER_PROMPT },
on: {
MOUSE_CLICK: {
actions: CircleAction.RECORD_START_POINT,
target: CircleState.WAITING_FOR_POINT_ON_CIRCLE,
},
ABSOLUTE_POINT_INPUT: {
actions: CircleAction.RECORD_START_POINT,
target: CircleState.WAITING_FOR_POINT_ON_CIRCLE,
},
ESC: {
actions: CircleAction.SWITCH_TO_SELECT_TOOL,
},
...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_POINT_ON_CIRCLE]: {
description: 'Select a point on the circle',
meta: {
instructions: '원 위의 점 지정 또는 반지름 입력:',
},
[CircleState.WAITING_FOR_RADIUS]: {
meta: { kind: 'point', prompt: RADIUS_PROMPT },
on: {
DRAW: {
actions: CircleAction.DRAW_TEMP_CIRCLE,
},
MOUSE_CLICK: {
actions: CircleAction.DRAW_FINAL_CIRCLE,
target: CircleState.INIT,
},
NUMBER_INPUT: {
actions: CircleAction.DRAW_FINAL_CIRCLE,
target: LineState.INIT,
},
ABSOLUTE_POINT_INPUT: {
actions: CircleAction.DRAW_FINAL_CIRCLE,
target: LineState.INIT,
},
RELATIVE_POINT_INPUT: {
actions: CircleAction.DRAW_FINAL_CIRCLE,
target: LineState.INIT,
},
ESC: {
target: CircleState.INIT,
},
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: {
[CircleAction.INIT_CIRCLE_TOOL]: assign(() => {
init: assign(() => {
setShouldDrawHelpers(true);
setGhostHelperEntities([]);
setSelectedEntityIds([]);
setAngleGuideOriginPoint(null);
return {
centerPoint: null,
};
return { centerPoint: null, points: [], threePoints: false };
}),
[CircleAction.RECORD_START_POINT]: assign(({ event }) => {
const startPoint = getPointFromEvent(null, event as PointInputEvent);
setAngleGuideOriginPoint(startPoint);
return {
centerPoint: startPoint,
};
pickThree: assign({ threePoints: true }),
pickTwo: assign({ threePoints: false }),
recordCenter: assign(({ event }) => {
const centerPoint = pointAt(null, event);
setAngleGuideOriginPoint(centerPoint);
return { centerPoint };
}),
[CircleAction.DRAW_TEMP_CIRCLE]: ({ context, event }) => {
const activeCircle = new CircleEntity(
getActiveLayerId(),
context.centerPoint as Point,
pointDistance(
(event as DrawEvent).drawController.getWorldMouseLocation(),
context.centerPoint as Point
)
);
activeCircle.lineColor = getActiveLineColor();
activeCircle.lineWidth = getActiveLineWidth();
activeCircle.lineDash = getActiveLineDash();
setGhostHelperEntities([activeCircle]);
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))]);
},
[CircleAction.DRAW_FINAL_CIRCLE]: assign(({ context, event }) => {
if (!context.centerPoint) {
throw new Error(
'Trying to DRAW_FINAL_CIRCLE when centerPoint is not yet defined in circle tool'
);
}
const pointOnCircle: Point = getPointFromEvent(
context.centerPoint,
event as PointInputEvent
);
const activeCircle = new CircleEntity(
getActiveLayerId(),
context.centerPoint as Point,
pointDistance(pointOnCircle, context.centerPoint as Point)
);
activeCircle.lineColor = getActiveLineColor();
activeCircle.lineWidth = getActiveLineWidth();
activeCircle.lineDash = getActiveLineDash();
addEntities([activeCircle], true);
setGhostHelperEntities([]);
return {
centerPoint: null,
};
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] };
}),
[CircleAction.SWITCH_TO_SELECT_TOOL]: () => {
setActiveToolActor(new Actor(selectToolStateMachine));
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([]);
},
},
}
@@ -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)
);
},
},
}
);
@@ -1,35 +1,44 @@
/**
* 선(LINE) — 337 설계 1-1 · 오토캐드 차례.
* 「첫 번째 점 지정:」(빈 Enter = 마지막 선 · 폴리선 · 호 끝점에서) → 「다음 점 지정 또는 [명령 취소(U)]:」 되풀이 ·
* 둘째 토막부터 [닫기(C)/명령 취소(U)]. 토막마다 바로 도면에 · U = 마지막 토막(되돌림 한 칸) · C = 첫 점으로 닫고 끝 ·
* 빈 Enter · Esc = 끝(그린 토막 남음).
*/
import type { Point } from '@flatten-js/core';
import { LineEntity } from '../entities/LineEntity';
import { assign, createMachine } from 'xstate';
import { OPT, type PromptSpec } from '../commands/prompt';
import type { LineEntity } from '../entities/LineEntity';
import { getPointFromEvent } from '../helpers/get-point-from-event.ts';
import {
addEntities,
deleteEntities,
getActiveLayerId,
getActiveLineColor,
getActiveLineDash,
getActiveLineWidth,
setActiveToolActor,
setAngleGuideOriginPoint,
setGhostHelperEntities,
setSelectedEntityIds,
setShouldDrawHelpers,
undo,
} from '../state';
import { Tool } from '../tools';
import { Actor, assign, createMachine } from 'xstate';
import type {
DrawEvent,
PointInputEvent,
StateEvent,
TextInputEvent,
ToolContext,
import {
END,
firstPointEvents,
lastDrawEnd,
pointEvents,
rememberDrawEnd,
} from './draw/draw-steps';
import { lineEntity } from './factories/entity-factory';
import {
type DrawEvent,
END_STATE,
type PointInputEvent,
type StateEvent,
type ToolContext,
} from './tool.types';
import { selectToolStateMachine } from './select-tool.ts';
import { getPointFromEvent } from '../helpers/get-point-from-event.ts';
export interface LineContext extends ToolContext {
startPoint: Point | null;
/** 이번 이어 그리기의 첫 점 — 닫기(C) */
firstPoint: Point | null;
/** 이번 이어 그리기에서 그린 구간 — 명령취소(U) */
/** 이번 이어 그리기에서 그린 구간 — 명령 취소(U) */
segments: LineEntity[];
}
@@ -42,13 +51,25 @@ export enum LineState {
export enum LineAction {
INIT_LINE_TOOL = 'INIT_LINE_TOOL',
RECORD_START_POINT = 'RECORD_START_POINT',
CONTINUE_FROM_LAST = 'CONTINUE_FROM_LAST',
DRAW_TEMP_LINE = 'DRAW_TEMP_LINE',
DRAW_FINAL_LINE = 'DRAW_FINAL_LINE',
CLOSE_LINE = 'CLOSE_LINE',
UNDO_SEGMENT = 'UNDO_SEGMENT',
SWITCH_TO_SELECT_TOOL = 'SWITCH_TO_SELECT_TOOL',
}
const START_PROMPT: PromptSpec = { text: '첫 번째 점 지정' };
const canClose = (context: Partial<LineContext>) => (context.segments?.length ?? 0) >= 2;
const nextPrompt = (context: Partial<LineContext>): PromptSpec => ({
text: '다음 점 지정',
options: canClose(context) ? [OPT.CLOSE, OPT.UNDO] : [OPT.UNDO],
});
const startAt = (startPoint: Point) => {
setAngleGuideOriginPoint(startPoint);
return { startPoint, firstPoint: startPoint, segments: [] };
};
export const lineToolStateMachine = createMachine(
{
types: {} as {
@@ -64,161 +85,94 @@ export const lineToolStateMachine = createMachine(
initial: LineState.INIT,
states: {
[LineState.INIT]: {
description: 'Initializing the line tool',
always: {
actions: LineAction.INIT_LINE_TOOL,
target: LineState.WAITING_FOR_START_POINT,
},
},
[LineState.WAITING_FOR_START_POINT]: {
description: 'Select the start point of the line',
meta: {
instructions: '첫 점 지정 (x,y):',
},
meta: { kind: 'point', prompt: START_PROMPT },
on: {
MOUSE_CLICK: {
actions: LineAction.RECORD_START_POINT,
...firstPointEvents(LineAction.RECORD_START_POINT, LineState.WAITING_FOR_END_POINT),
ENTER: {
guard: () => !!lastDrawEnd(),
actions: LineAction.CONTINUE_FROM_LAST,
target: LineState.WAITING_FOR_END_POINT,
},
ABSOLUTE_POINT_INPUT: {
actions: LineAction.RECORD_START_POINT,
target: LineState.WAITING_FOR_END_POINT,
},
ESC: {
actions: LineAction.SWITCH_TO_SELECT_TOOL,
},
ESC: { target: END_STATE },
},
},
[LineState.WAITING_FOR_END_POINT]: {
description: 'Select the end point of the line',
meta: {
instructions: '다음 점 지정 또는 [닫기(C)/명령취소(U)]:',
options: ['C', 'U'],
},
meta: { kind: 'point', prompt: nextPrompt },
on: {
DRAW: {
actions: LineAction.DRAW_TEMP_LINE,
DRAW: { actions: LineAction.DRAW_TEMP_LINE },
...pointEvents(LineAction.DRAW_FINAL_LINE, LineState.WAITING_FOR_END_POINT),
'OPTION.CLOSE': {
guard: 'canClose',
actions: LineAction.CLOSE_LINE,
target: END_STATE,
},
MOUSE_CLICK: {
actions: LineAction.DRAW_FINAL_LINE,
target: LineState.WAITING_FOR_END_POINT,
},
NUMBER_INPUT: {
actions: LineAction.DRAW_FINAL_LINE,
target: LineState.WAITING_FOR_END_POINT,
},
ABSOLUTE_POINT_INPUT: {
actions: LineAction.DRAW_FINAL_LINE,
target: LineState.WAITING_FOR_END_POINT,
},
RELATIVE_POINT_INPUT: {
actions: LineAction.DRAW_FINAL_LINE,
target: LineState.WAITING_FOR_END_POINT,
},
TEXT_INPUT: [
'OPTION.UNDO': [
{
// 닫기 — 둘째 선부터 · 첫 점으로 잇고 끝
guard: ({ context, event }) =>
(event as TextInputEvent).value === 'C' && context.segments.length >= 2,
actions: LineAction.CLOSE_LINE,
target: LineState.INIT,
},
{
// 명령취소 — 마지막 구간만
guard: ({ context, event }) =>
(event as TextInputEvent).value === 'U' && context.segments.length > 0,
guard: 'hasSegment',
actions: LineAction.UNDO_SEGMENT,
},
// 그린 토막 없음 — 첫 점부터 다시
{ actions: LineAction.INIT_LINE_TOOL, target: LineState.WAITING_FOR_START_POINT },
],
ESC: {
target: LineState.INIT,
},
ENTER: {
target: LineState.INIT,
},
ENTER: { target: END_STATE },
ESC: { target: END_STATE },
},
},
...END,
},
},
{
// 옵션 사건(`OPTION.*`)은 이름 붙인 guard 로 — 글자 사건 꼴이라 줄 안 guard 는 타입이 안 맞음
guards: {
canClose: ({ context }) => canClose(context),
hasSegment: ({ context }) => context.segments.length > 0,
},
actions: {
[LineAction.INIT_LINE_TOOL]: assign(() => {
setShouldDrawHelpers(true);
setSelectedEntityIds([]);
setGhostHelperEntities([]);
setAngleGuideOriginPoint(null);
return {
startPoint: null,
firstPoint: null,
segments: [],
};
}),
[LineAction.RECORD_START_POINT]: assign(({ event }) => {
const startPoint = getPointFromEvent(null, event as PointInputEvent);
setAngleGuideOriginPoint(startPoint);
return {
startPoint,
firstPoint: startPoint,
segments: [],
};
return { startPoint: null, firstPoint: null, segments: [] };
}),
[LineAction.RECORD_START_POINT]: assign(({ event }) =>
startAt(getPointFromEvent(null, event as PointInputEvent))
),
[LineAction.CONTINUE_FROM_LAST]: assign(() => startAt(lastDrawEnd() as Point)),
[LineAction.DRAW_TEMP_LINE]: ({ context, event }) => {
const activeLine = new LineEntity(
getActiveLayerId(),
context.startPoint as Point,
getPointFromEvent(context.startPoint, event as DrawEvent)
);
activeLine.lineColor = getActiveLineColor();
activeLine.lineWidth = getActiveLineWidth();
activeLine.lineDash = getActiveLineDash();
setGhostHelperEntities([activeLine]);
const start = context.startPoint as Point;
setGhostHelperEntities([lineEntity(start, getPointFromEvent(start, event as DrawEvent))]);
},
[LineAction.DRAW_FINAL_LINE]: assign(({ context, event }) => {
if (!context.startPoint) {
throw new Error('Start point is not set during DRAW_FINAL_LINE in LineEntity');
}
const endPoint = getPointFromEvent(context.startPoint, event as PointInputEvent);
const activeLine = new LineEntity(
getActiveLayerId(),
context.startPoint as Point,
endPoint
);
activeLine.lineColor = getActiveLineColor();
activeLine.lineWidth = getActiveLineWidth();
activeLine.lineDash = getActiveLineDash();
addEntities([activeLine], true);
// Keep drawing from the last point
setGhostHelperEntities([new LineEntity(getActiveLayerId(), endPoint, endPoint)]);
const start = context.startPoint as Point;
const endPoint = getPointFromEvent(start, event as PointInputEvent);
const segment = lineEntity(start, endPoint);
addEntities([segment], true);
rememberDrawEnd(endPoint);
setGhostHelperEntities([]);
setAngleGuideOriginPoint(endPoint);
return {
startPoint: endPoint,
segments: [...context.segments, activeLine],
};
return { startPoint: endPoint, segments: [...context.segments, segment] };
}),
[LineAction.CLOSE_LINE]: ({ context }) => {
const closing = new LineEntity(
getActiveLayerId(),
context.startPoint as Point,
context.firstPoint as Point
);
closing.lineColor = getActiveLineColor();
closing.lineWidth = getActiveLineWidth();
closing.lineDash = getActiveLineDash();
addEntities([closing], true);
const first = context.firstPoint as Point;
addEntities([lineEntity(context.startPoint as Point, first)], true);
rememberDrawEnd(first);
},
[LineAction.UNDO_SEGMENT]: assign(({ context }) => {
const last = context.segments[context.segments.length - 1];
deleteEntities([last], true);
undo(); // 토막 하나 = 되돌림 한 칸
const startPoint = last.getStartPoint();
setGhostHelperEntities([new LineEntity(getActiveLayerId(), startPoint, startPoint)]);
rememberDrawEnd(startPoint);
setGhostHelperEntities([]);
setAngleGuideOriginPoint(startPoint);
return { startPoint, segments: context.segments.slice(0, -1) };
}),
[LineAction.SWITCH_TO_SELECT_TOOL]: () => {
setActiveToolActor(new Actor(selectToolStateMachine));
},
},
}
);
@@ -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 };
}),
},
}
);
+491
View File
@@ -0,0 +1,491 @@
/**
* 337-3a 나-1 그리기 8 — LINE · PLINE · RECTANG · CIRCLE · POLYGON · ARC · ELLIPSE · SPLINE 를 오토캐드 차례로.
* 설계 `docs/raw/조사/2026-10-15_337_CAD오토캐드조작_설계.md` 1-1 · 6-3 나 줄.
* 실행: cd B07_DesignDetail/openwebcad && npx vitest run --dir ../../resources/tester/cad draw-acad
*/
import { beforeEach, describe, expect, it, vi } from "vitest";
vi.mock("react-toastify", () => ({
toast: Object.assign(vi.fn(), { error: vi.fn(), info: vi.fn(), warn: vi.fn(), success: vi.fn() }),
}));
import { remember } from "../../../B07_DesignDetail/openwebcad/src/commands/prompt";
import { Tool } from "../../../B07_DesignDetail/openwebcad/src/tools";
import {
activeTool,
click,
entities,
initIdle,
key,
move,
noticeLine,
promptLine,
type,
typed,
} from "./helpers/cad-drive";
type Xy = { x: number; y: number };
type Ends = { getStartPoint(): Xy; getEndPoint(): Xy };
const enter = () => key("Enter");
const esc = () => key("Escape");
const expectXy = (point: Xy, x: number, y: number) => {
expect(point.x).toBeCloseTo(x, 3);
expect(point.y).toBeCloseTo(y, 3);
};
const lines = () => entities("Line") as unknown as Ends[];
const expectLine = (line: Ends, a: [number, number], b: [number, number]) => {
expectXy(line.getStartPoint(), ...a);
expectXy(line.getEndPoint(), ...b);
};
beforeEach(() => {
initIdle();
for (const name of ["LINE.END", "CIRCLERAD", "RECT.L", "RECT.W", "POLYSIDES", "POLYGON.IC"]) {
remember(name, undefined); // 세션 기억 — 시험끼리 안 섞임
}
});
describe("LINE", () => {
it("U = 마지막 토막 · 그 시작점에서 이음 · 그린 토막 없으면 첫 점부터", () => {
type("l");
expect(promptLine()).toBe("첫 번째 점 지정:");
type("0,0");
expect(promptLine()).toBe("다음 점 지정 또는 [명령 취소(U)]:");
type("10,0");
type("10,10");
type("u");
expect(lines()).toHaveLength(1);
expect(promptLine()).toBe("다음 점 지정 또는 [명령 취소(U)]:");
type("20,0");
expect(lines()).toHaveLength(2);
expectLine(lines()[1], [10, 0], [20, 0]);
type("u");
type("u");
type("u");
expect(lines()).toHaveLength(0);
expect(promptLine()).toBe("첫 번째 점 지정:");
expect(activeTool()).toBe(Tool.LINE);
});
it("C 는 둘째 토막부터 — 그 전엔 거절 · 닫고 끝 → 쉼", () => {
type("l");
type("0,0");
type("10,0");
type("c");
expect(noticeLine()).toBe("점 또는 옵션 키워드를 요구함.");
expect(lines()).toHaveLength(1);
type("10,10");
expect(promptLine()).toBe("다음 점 지정 또는 [닫기(C)/명령 취소(U)]:");
type("C");
expect(lines()).toHaveLength(3);
expectLine(lines()[2], [10, 10], [0, 0]);
expect(activeTool()).toBe(Tool.SELECT);
});
it("Enter = 끝 → 쉼 · Space = 다시 LINE · 첫 점 빈 Enter = 마지막 끝점에서 이음", () => {
type("l");
type("0,0");
type("10,0");
enter();
expect(activeTool()).toBe(Tool.SELECT);
expect(lines()).toHaveLength(1);
key(" ");
expect(activeTool()).toBe(Tool.LINE);
expect(promptLine()).toBe("첫 번째 점 지정:");
key(" ");
expect(promptLine()).toBe("다음 점 지정 또는 [명령 취소(U)]:");
type("10,5");
expectLine(lines()[1], [10, 0], [10, 5]);
});
it("첫 점 빈 Enter — 그린 것 없으면 아무 일 없음", () => {
type("l");
enter();
expect(activeTool()).toBe(Tool.LINE);
expect(promptLine()).toBe("첫 번째 점 지정:");
});
it("Esc = 끝 · 그린 토막 남음", () => {
type("l");
type("0,0");
type("10,0");
type("20,0");
move(30, 5);
esc();
expect(activeTool()).toBe(Tool.SELECT);
expect(lines()).toHaveLength(2);
});
});
type Box = { xmin: number; ymin: number; xmax: number; ymax: number };
type Shape = { getBoundingBox(): Box; getType(): string };
type Poly = Shape & { getEntities(): (Shape & Ends)[] };
const expectBox = (shape: Shape, xmin: number, ymin: number, xmax: number, ymax: number) => {
const box = shape.getBoundingBox();
expect([box.xmin, box.ymin, box.xmax, box.ymax].map((v) => Number(v.toFixed(3)))).toEqual([
xmin,
ymin,
xmax,
ymax,
]);
};
const polys = () => entities("PolyLine") as unknown as Poly[];
const arcShape = (arc: Shape) =>
(arc as unknown as { getShape(): { center: Xy; r: number } }).getShape();
describe("PLINE", () => {
it("L = 앞 방향 길이 · U · A 접선 호 · L 선 · 호 뒤 길이 = 접선 · C 닫기", () => {
type("pline");
expect(promptLine()).toBe("시작점 지정:");
type("0,0");
expect(promptLine()).toBe("다음 점 지정 또는 [호(A)/길이(L)/명령 취소(U)]:");
type("10,0");
expect(promptLine()).toBe("다음 점 지정 또는 [호(A)/닫기(C)/길이(L)/명령 취소(U)]:");
type("l");
expect(promptLine()).toBe("선의 길이 지정:");
type("5");
type("u");
type("a");
expect(promptLine()).toBe("호의 끝점 지정 또는 [닫기(CL)/선(L)/명령 취소(U)]:");
type("20,10");
type("l");
type("L");
type("5");
type("c");
expect(activeTool()).toBe(Tool.SELECT);
expect(polys()).toHaveLength(1);
const parts = polys()[0].getEntities();
expect(parts.map((part) => part.getType())).toEqual(["Line", "Arc", "Line", "Line"]);
expectLine(parts[0], [0, 0], [10, 0]);
expectXy(arcShape(parts[1]).center, 10, 10); // 동쪽 접선 → 중심 (10, 10)
expectLine(parts[2], [20, 10], [20, 15]); // 호 끝 접선 = 북쪽
expectLine(parts[3], [20, 15], [0, 0]);
});
it("첫 토막 호 = 동쪽 접선 · CL = 호로 닫음 · 호 모드 C 는 거절", () => {
type("pline");
type("0,0");
type("a");
expect(promptLine()).toBe("호의 끝점 지정 또는 [선(L)/명령 취소(U)]:");
type("10,10");
type("c");
expect(noticeLine()).toBe("점 또는 옵션 키워드를 요구함.");
type("cl");
const parts = polys()[0].getEntities();
expect(parts.map((part) => part.getType())).toEqual(["Arc", "Arc"]);
expectXy(arcShape(parts[1]).center, 0, 10);
expectXy(parts[1].getEndPoint(), 0, 0);
});
it("Esc = 그린 데까지 남김 · 시작점만이면 없음 · 빈 Enter 끝 · 시작점 Enter = 마지막 끝점", () => {
type("pline");
type("0,0");
type("10,0");
type("20,5");
move(40, 40);
esc();
expect(activeTool()).toBe(Tool.SELECT);
expect(polys()).toHaveLength(1);
expect(polys()[0].getEntities()).toHaveLength(2);
type("pline");
type("1,1");
esc();
expect(polys()).toHaveLength(1);
type("pline");
enter();
type("@0,10");
enter();
expect(polys()).toHaveLength(2);
expectLine(polys()[1].getEntities()[0], [20, 5], [20, 15]);
});
});
describe("RECTANG", () => {
it("D = 길이 · 폭(기억) → 누른 쪽 사분면 · 없는 옵션 안내 · 하나 뒤 끝", () => {
type("rec");
expect(promptLine()).toBe("첫 번째 구석점 지정:");
type("e");
expect(noticeLine()).toBe("「고도(E)」 옵션은 이 CAD 에 없음.");
type("0,0");
expect(promptLine()).toBe("다른 구석점 지정 또는 [치수(D)]:");
type("d");
expect(promptLine()).toBe("직사각형의 길이 지정 <10.0000>:");
type("30");
expect(promptLine()).toBe("직사각형의 폭 지정 <10.0000>:");
type("20");
expect(promptLine()).toBe("다른 구석점 지정 또는 [치수(D)]:");
type("-1,-1");
expect(activeTool()).toBe(Tool.SELECT);
expectBox(entities("Rectangle")[0], -30, -20, 0, 0);
key(" ");
type("100,100");
type("d");
expect(promptLine()).toBe("직사각형의 길이 지정 <30.0000>:");
enter();
expect(promptLine()).toBe("직사각형의 폭 지정 <20.0000>:");
enter();
type("101,99");
expectBox(entities("Rectangle")[1], 100, 80, 130, 100);
});
it("두 구석 · Esc 첫 구석 = 끝", () => {
type("rec");
esc();
expect(activeTool()).toBe(Tool.SELECT);
type("rec");
type("0,0");
type("@5,3");
expectBox(entities("Rectangle")[0], 0, 0, 5, 3);
expect(activeTool()).toBe(Tool.SELECT);
});
});
describe("CIRCLE", () => {
const radius = (index: number) =>
(entities("Circle")[index] as unknown as { getRadius(): number }).getRadius();
it("반지름 · 처음엔 <> 없음(Enter 아무 일 없음) · 기억 · 한 번 뒤 끝 · 음수 거절 · Ttr 없음", () => {
type("c");
expect(promptLine()).toBe("원에 대한 중심점 지정 또는 [3점(3P)/2점(2P)]:");
type("t");
expect(noticeLine()).toBe("「Ttr - 접선 접선 반지름(T)」 옵션은 이 CAD 에 없음.");
type("0,0");
expect(promptLine()).toBe("원의 반지름 지정 또는 [지름(D)]:");
enter();
expect(entities("Circle")).toHaveLength(0);
type("-5");
expect(noticeLine()).toBe("값은 양수이고 0이 아니어야 함.");
type("5");
expect(activeTool()).toBe(Tool.SELECT);
expect(radius(0)).toBeCloseTo(5);
key(" ");
type("20,0");
expect(promptLine()).toBe("원의 반지름 지정 또는 [지름(D)] <5.0000>:");
enter();
expect(radius(1)).toBeCloseTo(5);
expectBox(entities("Circle")[1], 15, -5, 25, 5);
});
it("D 지름 <2r> · 누른 점 반지름도 기억", () => {
remember("CIRCLERAD", 3);
type("c");
type("0,0");
type("d");
expect(promptLine()).toBe("원의 지름 지정 <6.0000>:");
type("8");
expect(radius(0)).toBeCloseTo(4);
type("c");
type("50,50");
expect(promptLine()).toBe("원의 반지름 지정 또는 [지름(D)] <4.0000>:");
type("@0,7");
expect(radius(1)).toBeCloseTo(7);
type("c");
type("90,90");
expect(promptLine()).toBe("원의 반지름 지정 또는 [지름(D)] <7.0000>:");
});
it("3P · 2P", () => {
type("c");
type("3p");
expect(promptLine()).toBe("원 위의 첫 번째 점 지정:");
type("0,0");
expect(promptLine()).toBe("원 위의 두 번째 점 지정:");
type("10,0");
expect(promptLine()).toBe("원 위의 세 번째 점 지정:");
type("5,5");
expect(activeTool()).toBe(Tool.SELECT);
expectBox(entities("Circle")[0], 0, -5, 10, 5);
type("c");
type("2P");
expect(promptLine()).toBe("원 지름의 첫 번째 끝점 지정:");
type("20,0");
expect(promptLine()).toBe("원 지름의 두 번째 끝점 지정:");
type("40,0");
expectBox(entities("Circle")[1], 20, -10, 40, 10);
expect(activeTool()).toBe(Tool.SELECT);
});
});
/** 폴리선 꼭짓점(소수 셋) */
const corners = (poly: Poly) =>
poly.getEntities().map((part) => {
const point = part.getStartPoint();
return [Number(point.x.toFixed(3)) + 0, Number(point.y.toFixed(3)) + 0];
});
describe("POLYGON", () => {
it("변 수 <4> · 2.5 · 2 거절 · 외접 숫자 = 아래 변 수평 · 기억 · 한 번 뒤 끝", () => {
type("pol");
expect(promptLine()).toBe("변의 수 입력 <4>:");
type("2.5");
expect(noticeLine()).toBe("정수 값을 요구함.");
type("2");
expect(noticeLine()).toBe("3에서 1024 사이의 정수를 요구함.");
type("6");
expect(promptLine()).toBe("폴리곤의 중심 지정 또는 [모서리(E)]:");
type("0,0");
expect(promptLine()).toBe("옵션 입력 [원에 내접(I)/원에 외접(C)] <I>:");
type("c");
expect(promptLine()).toBe("원의 반지름 지정:");
type("10");
expect(activeTool()).toBe(Tool.SELECT);
const hexagon = polys()[0];
expect(hexagon.getEntities()).toHaveLength(6);
expect(hexagon.getBoundingBox().ymin).toBeCloseTo(-10); // 아래 변 = 반지름 거리 · 수평
expect(corners(hexagon).filter(([, y]) => y === -10)).toHaveLength(2);
key(" ");
expect(promptLine()).toBe("변의 수 입력 <6>:");
enter();
type("100,0");
expect(promptLine()).toBe("옵션 입력 [원에 내접(I)/원에 외접(C)] <C>:");
type("i");
type("10");
const inscribed = polys()[1];
expect(corners(inscribed).filter(([, y]) => y === -8.66)).toHaveLength(2); // 내접 · 아래 변 수평(꼭짓점 −60° · −120°)
expect(inscribed.getBoundingBox().xmax).toBeCloseTo(110);
});
it("점 = 크기 + 돌림(내접 꼭짓점) · E 모서리 = 왼쪽(반시계)", () => {
type("pol");
type("4");
type("0,0");
enter(); // <I>
type("0,10");
expect(corners(polys()[0])).toContainEqual([0, 10]);
type("pol");
enter();
type("e");
expect(promptLine()).toBe("모서리의 첫 번째 끝점 지정:");
type("0,0");
expect(promptLine()).toBe("모서리의 두 번째 끝점 지정:");
type("10,0");
expectBox(polys()[1], 0, 0, 10, 10);
});
});
describe("ARC", () => {
it("세 점 · 한 번 뒤 끝", () => {
type("a");
expect(promptLine()).toBe("호의 시작점 지정:");
type("10,0");
expect(promptLine()).toBe("호의 두 번째 점 지정 또는 [중심(C)/끝(E)]:");
type("0,10");
expect(promptLine()).toBe("호의 끝점 지정:");
type("-10,0");
expect(activeTool()).toBe(Tool.SELECT);
expectBox(entities("Arc")[0], -10, 0, 10, 10);
});
it("C 중심 → 끝점 · A 사이각(− = 시계)", () => {
type("a");
type("10,0");
type("c");
expect(promptLine()).toBe("호의 중심점 지정:");
type("0,0");
expect(promptLine()).toBe("호의 끝점 지정 또는 [각도(A)]:");
type("0,5");
expectBox(entities("Arc")[0], 0, 0, 10, 10);
type("a");
type("10,0");
type("c");
type("0,0");
type("a");
expect(promptLine()).toBe("사이각 지정:");
type("-90");
expectBox(entities("Arc")[1], 0, -10, 10, 0);
});
it("E 끝 → 중심 · R 반지름 · 시작점 Enter 없음", () => {
type("a");
enter();
expect(promptLine()).toBe("호의 시작점 지정:");
type("10,0");
type("e");
expect(promptLine()).toBe("호의 끝점 지정:");
type("-10,0");
expect(promptLine()).toBe("호의 중심점 지정 또는 [반지름(R)]:");
type("0,0");
expectBox(entities("Arc")[0], -10, 0, 10, 10);
type("a");
type("10,0");
type("e");
type("0,10");
type("r");
expect(promptLine()).toBe("호의 반지름 지정:");
type("10");
expect(arcShape(entities("Arc")[1]).r).toBeCloseTo(10);
expectXy(arcShape(entities("Arc")[1]).center, 0, 0);
});
});
describe("ELLIPSE", () => {
it("축 두 끝 · 다른 축 거리 숫자 = 중심에서 반 길이(N10) · 호 없음", () => {
type("el");
expect(promptLine()).toBe("타원의 축 끝점 지정 또는 [중심(C)]:");
type("a");
expect(noticeLine()).toBe("「호(A)」 옵션은 이 CAD 에 없음.");
type("0,0");
expect(promptLine()).toBe("축의 다른 끝점 지정:");
type("20,0");
expect(promptLine()).toBe("다른 축으로 거리를 지정 또는 [회전(R)]:");
move(10, 30);
type("5");
expect(activeTool()).toBe(Tool.SELECT);
expectBox(polys()[0], 0, -5, 20, 5);
});
it("C 중심 · R 회전 60° = 축 × cos", () => {
type("el");
type("c");
expect(promptLine()).toBe("타원의 중심 지정:");
type("0,0");
expect(promptLine()).toBe("축의 끝점 지정:");
type("10,0");
type("r");
expect(promptLine()).toBe("주축 둘레로 회전 지정:");
type("60");
expectBox(polys()[0], -10, -5, 10, 5);
});
});
describe("SPLINE", () => {
it("물음 차례 · U · 넷째 점부터 C(닫고 끝)", () => {
type("spl");
expect(promptLine()).toBe("첫 번째 점 지정:");
type("0,0");
expect(promptLine()).toBe("다음 점 입력:");
type("10,0");
expect(promptLine()).toBe("다음 점 입력 또는 [명령 취소(U)]:");
type("c");
expect(noticeLine()).toBe("점 또는 옵션 키워드를 요구함.");
type("10,10");
expect(promptLine()).toBe("다음 점 입력 또는 [명령 취소(U)/닫기(C)]:");
type("u");
expect(promptLine()).toBe("다음 점 입력 또는 [명령 취소(U)]:");
type("10,10");
type("0,10");
type("c");
expect(activeTool()).toBe(Tool.SELECT);
const parts = polys()[0].getEntities();
expectXy(parts[parts.length - 1].getEndPoint(), 0, 0);
expectXy(parts[0].getStartPoint(), 0, 0);
});
it("빈 Enter = 확정 · Esc = 버림", () => {
type("spl");
type("0,0");
type("10,0");
type("20,5");
enter();
expect(polys()).toHaveLength(1);
expect(activeTool()).toBe(Tool.SELECT);
type("spl");
type("0,0");
type("10,0");
type("20,5");
esc();
expect(polys()).toHaveLength(1);
expect(activeTool()).toBe(Tool.SELECT);
});
});
+5 -6
View File
@@ -142,13 +142,13 @@ describe("331-1 각도 고정 → 길이", () => {
type(controller, "100");
const snapshot = getActiveToolActor()?.getSnapshot();
expect(snapshot?.status).toBe("active");
expect(snapshot?.value).toBe("STEP_0");
expect(snapshot?.value).toBe("START"); // 337 나-1 폴리선 손 머신
expect(toast.error).not.toHaveBeenCalled();
expect(noticeLine()).toBe("점 또는 옵션 키워드를 요구함."); // 기준점 없는 거리 = 거절
type(controller, "0,0");
type(controller, "<0");
type(controller, "100");
expect(getActiveToolActor()?.getSnapshot()?.context.values).toHaveLength(2);
expect(getActiveToolActor()?.getSnapshot()?.context.segments).toHaveLength(1);
});
});
@@ -209,16 +209,15 @@ describe("331-3 선 중 C · U · 한 번 스냅 · 한글 안내", () => {
type(controller, "10,10");
};
it("C = 첫 점으로 닫고 끝 · 안내 「[닫기(C)/명령취소(U)]」", () => {
it("C = 첫 점으로 닫고 끝 · 안내 「[닫기(C)/명령 취소(U)]」", () => {
drawOpen();
expect(getLastStateInstructions()).toBe("다음 점 지정 또는 [닫기(C)/명령취소(U)]:");
expect(getLastStateInstructions()).toBe("다음 점 지정 또는 [닫기(C)/명령 취소(U)]:");
type(controller, "c");
const drawn = lines();
expect(drawn).toHaveLength(3);
expectXy(drawn[2].start, 10, 10);
expectXy(drawn[2].end, 0, 0);
expect(getActiveToolActor()?.getSnapshot()?.value).toBe("WAITING_FOR_START_POINT");
expect(getLastStateInstructions()).toBe("첫 점 지정 (x,y):");
expect(getActiveToolActor()?.getSnapshot()?.context.type).toBe("SELECT"); // 337 — 닫으면 명령 끝
});
it("C 는 둘째 선부터 — 한 구간이면 무시", () => {