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:
@@ -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 };
|
||||
}),
|
||||
},
|
||||
}
|
||||
);
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -143,13 +143,13 @@ describe("331-1 각도 고정 → 길이", () => {
|
||||
type(controller, "@10,0");
|
||||
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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -210,16 +210,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 는 둘째 선부터 — 한 구간이면 무시", () => {
|
||||
|
||||
Reference in New Issue
Block a user