Merge remote-tracking branch 'origin/sub_laptop_3' into main_laptop_1

This commit is contained in:
2026-10-16 00:11:27 +09:00
4 changed files with 684 additions and 59 deletions
@@ -1,9 +1,9 @@
/** 문자 명령 — 여러 줄 문자·단일 행 문자·편집·찾기 (조사표 5절 문자 패널) */
import { Point } from '@flatten-js/core';
import { Point, Vector } from '@flatten-js/core';
import { toast } from 'react-toastify';
import { getAnnotationScale } from '../../commands/dim-settings';
import { type OptionSpec, type PromptSpec, recall } from '../../commands/prompt';
import { EntityName } from '../../entities/Entity';
import type { Entity } from '../../entities/Entity';
import type { TextEntity } from '../../entities/TextEntity';
import {
addEntities,
@@ -13,7 +13,7 @@ import {
setEntities,
} from '../../state';
import { Tool } from '../../tools';
import { textEntity } from '../factories/entity-factory';
import { polyLineEntity, textEntity } from '../factories/entity-factory';
import { createSequenceTool } from '../factories/sequence-tool';
/** AutoCAD 여러 줄 문자의 줄바꿈 표기(\P)와 \n을 모두 받는다 */
@@ -24,42 +24,156 @@ const splitLines = (text: string): string[] =>
.map((line) => line.trim())
.filter((line) => line.length > 0);
function buildTextLines(lines: string[], basePoint: Point): Entity[] {
const lineHeight = getActiveTextStyle().fontSize * getAnnotationScale() * 1.35;
const groupId = lines.length > 1 ? crypto.randomUUID() : undefined;
return lines.map((line, index) => {
const entity = textEntity(line, new Point(basePoint.x, basePoint.y - lineHeight * index), {
textAlign: 'left',
fontSize: getActiveTextStyle().fontSize * getAnnotationScale(),
});
entity.groupId = groupId;
return entity;
});
}
/** 줄 간격 = 글자 높이 × 1.35 */
const LINE_GAP = 1.35;
const opt = (key: string, kw: string, label: string): OptionSpec => ({ key, kw, en: key, label });
/** 지금 글자 크기 — TEXTSIZE 처음 값 */
const textSize = () => getActiveTextStyle().fontSize * getAnnotationScale();
const TEXT_HEIGHT = (): PromptSpec => ({
text: '높이 지정',
default: { kind: 'value', remember: 'TEXTSIZE', initial: textSize() },
number: { positive: true },
});
/** 글 물음 — 빈 Enter = 끝(기본은 안 그림) */
const typing = (text: string): PromptSpec => ({ text, default: { kind: 'option', key: 'DONE' } });
/** 한 줄 글 — 높이 · 회전 각(도) */
const placeText = (label: string, point: Point, height: number, angle: number) => {
const radians = (angle * Math.PI) / 180;
return textEntity(label, point, {
textAlign: 'left',
fontSize: height,
textDirection: new Vector(Math.cos(radians), Math.sin(radians)),
});
};
type TextLine = { point: Point; height: number; angle: number; start: Point | null };
/** TEXT 마지막 줄(세션) — 다음 줄 · 시작점 Enter 가 그 아래로 이어 씀 */
let lastLine: TextLine | null = null;
const below = ({ point, height, angle }: TextLine) => {
const radians = (angle * Math.PI) / 180;
const gap = height * LINE_GAP;
return new Point(point.x + Math.sin(radians) * gap, point.y - Math.cos(radians) * gap);
};
/**
* TEXT — 「문자의 시작점 지정:」 → 「높이 지정 <h>:」 → 「문자의 회전 각도 지정 <0>:」 → 줄마다 「문자 입력:」.
* 줄 Enter = 그 줄 만들고 아래 새 줄 · 빈 줄 Enter = 끝 · 시작점 Enter = 마지막 글 아래(높이 · 각 안 물음).
*/
export const textToolStateMachine = createSequenceTool({
tool: Tool.TEXT,
loopFrom: 'line',
steps: [
{ kind: 'point', instructions: '문자의 시작점을 지정하십시오.' },
{ kind: 'text', instructions: '문자를 입력하십시오.' },
{
kind: 'point',
id: 'start',
prompt: () => ({
text: '문자의 시작점 지정',
absent: [opt('JUSTIFY', 'J', '자리맞추기'), opt('STYLE', 'S', '스타일')],
...(lastLine ? { default: { kind: 'option', key: 'CONTINUE' } as const } : {}),
}),
options: { CONTINUE: { mode: 'continue', next: 'line' } },
},
{
kind: 'number',
id: 'height',
when: (input) => input.mode !== 'continue',
prompt: TEXT_HEIGHT,
},
{
kind: 'number',
id: 'angle',
when: (input) => input.mode !== 'continue',
prompt: {
text: '문자의 회전 각도 지정',
default: { kind: 'value', remember: 'TEXT.ANGLE', initial: 0, format: 'ang' },
},
},
{ kind: 'text', id: 'line', prompt: typing('문자 입력'), options: { DONE: 'end' } },
],
commit: (input) => {
const lines = splitLines(input.text(1));
if (!lines.length) return;
addEntities(buildTextLines(lines.slice(0, 1), input.point(0)), true);
const start = input.has('start') ? input.get<Point>('start') : null;
const previous = start ? null : lastLine;
const height = previous?.height ?? input.get<number>('height');
const angle = previous?.angle ?? input.get<number>('angle');
// 같은 명령의 다음 줄 · 시작점 Enter = 마지막 줄 아래
const follow = lastLine && (!start || lastLine.start === start) ? lastLine : null;
const point = follow ? below(follow) : (start as Point);
addEntities([placeText(String(input.get('line')), point, height, angle)], true);
lastLine = { point, height, angle, start };
},
});
/**
* MTEXT — 「첫 번째 구석 지정:」 → 「반대 구석 지정 또는 [높이(H)/폭(W)]:」 → 「문자 입력:」(줄바꿈 \P).
* 첫 줄 = 상자 왼쪽 위 · 줄 감기 없음(줄은 \P 로만).
*/
export const mtextToolStateMachine = createSequenceTool({
tool: Tool.MTEXT,
steps: [
{ kind: 'point', instructions: '여러 줄 문자의 첫 코너를 지정하십시오.' },
{ kind: 'text', instructions: '문자를 입력하십시오 (줄바꿈은 \\P).' },
{ kind: 'point', id: 'corner', prompt: { text: '첫 번째 구석 지정' } },
{
kind: 'point',
id: 'opposite',
prompt: {
text: '반대 구석 지정',
options: [opt('HEIGHT', 'H', '높이'), opt('WIDTH', 'W', '폭')],
absent: [
opt('JUSTIFY', 'J', '자리맞추기'),
opt('LINESPACING', 'L', '행 간격'),
opt('ROTATION', 'R', '회전'),
opt('STYLE', 'S', '스타일'),
opt('COLUMNS', 'C', '열'),
],
},
options: { HEIGHT: { mode: 'H', next: 'height' }, WIDTH: { mode: 'W', next: 'width' } },
},
{
kind: 'number',
id: 'height',
when: (input) => !input.has('opposite'),
next: 'opposite',
prompt: TEXT_HEIGHT,
},
{
kind: 'point',
id: 'width',
when: (input) => !input.has('opposite'),
prompt: { text: '폭 지정', number: { positive: true } },
},
{
kind: 'text',
id: 'text',
prompt: typing('문자 입력 (줄바꿈 \\P)'),
options: { DONE: 'end' },
},
],
preview: (input) => {
const corner = input.get<Point>('corner');
if (!corner || input.has('opposite') || input.mode === 'W') return [];
const { x, y } = input.cursor;
const box = [corner, new Point(x, corner.y), new Point(x, y), new Point(corner.x, y), corner];
const outline = polyLineEntity(box);
return outline ? [outline] : [];
},
commit: (input) => {
const lines = splitLines(input.text(1));
const lines = splitLines(String(input.get('text')));
if (!lines.length) return;
addEntities(buildTextLines(lines, input.point(0)), true);
const corner = input.get<Point>('corner');
const opposite = input.has('opposite') ? input.get<Point>('opposite') : corner;
const height = input.has('height')
? input.get<number>('height')
: (recall<number>('TEXTSIZE') ?? textSize());
const left = Math.min(corner.x, opposite.x);
const top = Math.max(corner.y, opposite.y) - height;
const groupId = lines.length > 1 ? crypto.randomUUID() : undefined;
const entities = lines.map((line, index) => {
const entity = placeText(line, new Point(left, top - height * LINE_GAP * index), height, 0);
entity.groupId = groupId;
return entity;
});
addEntities(entities, true);
},
});
@@ -3,11 +3,12 @@ import { Point } from '@flatten-js/core';
import { toast } from 'react-toastify';
import type { Entity } from '../../entities/Entity';
import { getMlineElements, getMlineSpacing, setMlineStyle } from '../../commands/draw-settings';
import type { OptionSpec, PromptSpec } from '../../commands/prompt';
import { offsetPolylinePoints } from '../../helpers/geometry/shape-points';
import { addEntities, getScreenCanvasDrawController } from '../../state';
import { Tool } from '../../tools';
import { hatchEntity, lineEntity, polyLineEntity } from '../factories/entity-factory';
import { createSequenceTool } from '../factories/sequence-tool';
import { createSequenceTool, type SequenceInput } from '../factories/sequence-tool';
/**
* 구성선은 원래 무한하지만, 무한 선은 경계상자를 망가뜨려 [범위 줌]을 못 쓰게 만든다.
@@ -32,30 +33,75 @@ function extendFrom(base: Point, through: Point, bothWays: boolean): [Point, Poi
return [backward, forward];
}
const opt = (key: string, kw: string, label: string): OptionSpec => ({ key, kw, en: key, label });
/** 통과점 물음 — 빈 Enter = 끝(기본은 안 그림) */
const THROUGH: PromptSpec = { text: '통과점을 지정', default: { kind: 'option', key: 'DONE' } };
/** 구성선 양 끝 — 수평 · 수직 · 각도 모드는 통과점 하나로 · 아니면 첫 점 → 통과점 방향 */
function xlineEnds(input: SequenceInput, through: Point): [Point, Point] | null {
const degrees = { H: 0, V: 90, A: input.has('angle') ? input.get<number>('angle') : 0 }[
input.mode ?? ''
];
if (degrees === undefined) {
const base = input.get<Point>('base');
if (!base || (base.x === through.x && base.y === through.y)) return null;
return extendFrom(base, through, true);
}
const radians = (degrees * Math.PI) / 180;
const toward = new Point(through.x + Math.cos(radians), through.y + Math.sin(radians));
return extendFrom(through, toward, true);
}
/** XLINE — 오토캐드 「점 지정 또는 [수평(H)/수직(V)/각도(A)]:」 → 「통과점을 지정:」 되풀이 */
export const xlineToolStateMachine = createSequenceTool({
tool: Tool.XLINE,
loopFrom: 'through',
steps: [
{ kind: 'point', instructions: '구성선이 지날 점을 지정하십시오.' },
{ kind: 'point', instructions: '방향을 지정하십시오.' },
{
kind: 'point',
id: 'base',
prompt: {
text: '점 지정',
options: [opt('HOR', 'H', '수평'), opt('VER', 'V', '수직'), opt('ANG', 'A', '각도')],
absent: [opt('BISECT', 'B', '이등분'), opt('OFFSET', 'O', '간격띄우기')],
},
options: {
HOR: { mode: 'H', next: 'through' },
VER: { mode: 'V', next: 'through' },
ANG: { mode: 'A', next: 'angle' },
},
},
{
kind: 'number',
id: 'angle',
when: (input) => input.mode === 'A',
prompt: {
text: 'X선의 각도 입력',
default: { kind: 'value', remember: 'XLINE.ANGLE', initial: 0, format: 'ang' },
absent: [opt('REFERENCE', 'R', '참조')],
},
},
{ kind: 'point', id: 'through', prompt: THROUGH, options: { DONE: 'end' } },
],
preview: (input) => {
const points = input.points();
if (points.length !== 1) return [];
const [start, end] = extendFrom(points[0], input.cursor, true);
return [lineEntity(start, end)];
if (!input.mode && !input.has('base')) return [];
const ends = xlineEnds(input, input.cursor);
return ends ? [lineEntity(...ends)] : [];
},
commit: (input) => {
const [base, through] = input.points();
const [start, end] = extendFrom(base, through, true);
addEntities([lineEntity(start, end)], true);
const ends = xlineEnds(input, input.get<Point>('through'));
if (ends) addEntities([lineEntity(...ends)], true);
},
});
/** RAY — 「시작점을 지정:」 → 「통과점을 지정:」 되풀이 */
export const rayToolStateMachine = createSequenceTool({
tool: Tool.RAY,
loopFrom: 'through',
steps: [
{ kind: 'point', instructions: '광선의 시작점을 지정하십시오.' },
{ kind: 'point', instructions: '통과점을 지정하십시오.' },
{ kind: 'point', id: 'base', prompt: { text: '시작점을 지정' } },
{ kind: 'point', id: 'through', prompt: THROUGH, options: { DONE: 'end' } },
],
preview: (input) => {
const points = input.points();
@@ -1,17 +1,44 @@
/** 해치·그라데이션·경계·영역 — 선택한 객체가 이루는 닫힌 경계를 채우거나 뽑는다 */
import type { Point } from '@flatten-js/core';
import { toast } from 'react-toastify';
import { assign, createMachine, sendTo } from 'xstate';
import {
autoHatchSpacing,
getHatchAngle,
getHatchSpacing,
getHatchStyle,
} from '../../commands/draw-settings';
import {
OPT,
type OptionSpec,
type PromptSpec,
SELECT_OBJECTS,
rememberSelection,
} from '../../commands/prompt';
import { entitiesToLoop } from '../../helpers/geometry/entity-loop';
import { addEntities, getActiveLineColor, setSelectedEntityIds } from '../../state';
import { isPointInPolygon } from '../../helpers/geometry/hatch-lines';
import { sampleEntityPoints } from '../../helpers/geometry/sample-entity';
import {
addEntities,
getActiveLineColor,
getPickableEntities,
getSelectedEntities,
setGhostHelperEntities,
setSelectedEntityIds,
setShouldDrawHelpers,
} from '../../state';
import { Tool } from '../../tools';
import { hatchEntity, polyLineEntity } from '../factories/entity-factory';
import { createSequenceTool, type SequenceInput } from '../factories/sequence-tool';
import { selectToolStateMachine } from '../select-tool';
import {
type AbsolutePointInputEvent,
ActorEvent,
END_STATE,
type MouseClickEvent,
type StateEvent,
type ToolContext,
} from '../tool.types';
/** 선택 객체에서 닫힌 경계를 얻는다. 못 얻으면 안내 후 빈 배열 */
function loopFromSelection(input: SequenceInput): Point[] {
@@ -29,31 +56,150 @@ function boundingSize(points: Point[]): { width: number; height: number } {
return { width: Math.max(...xs) - Math.min(...xs), height: Math.max(...ys) - Math.min(...ys) };
}
export const hatchToolStateMachine = createSequenceTool({
tool: Tool.HATCH,
helpers: false,
steps: [
{ kind: 'selection', instructions: '해치를 채울 경계 객체를 선택한 뒤 ENTER를 누르십시오.' },
/** 해치 한 벌 — 지금 해치 설정 · 간격 없으면 경계 크기로 */
function hatchOf(loop: Point[]) {
const { width, height } = boundingSize(loop);
return hatchEntity(loop, {
style: getHatchStyle(),
color: getActiveLineColor(),
spacing: getHatchSpacing() ?? autoHatchSpacing(width, height),
angle: getHatchAngle(),
});
}
const loopArea = (loop: Point[]) =>
Math.abs(
loop.reduce((sum, point, index) => {
const next = loop[(index + 1) % loop.length];
return sum + point.x * next.y - next.x * point.y;
}, 0)
) / 2;
/** 내부 점 — 그 점을 품은 닫힌 객체(닫힌 폴리선 · 사각형 · 원) 중 가장 작은 하나 */
function loopAround(point: Point): { id: string; loop: Point[] } | null {
let best: { id: string; loop: Point[]; area: number } | null = null;
for (const entity of getPickableEntities()) {
const loop = sampleEntityPoints(entity);
if (loop.length < 4) continue;
const [first, last] = [loop[0], loop[loop.length - 1]];
if (Math.hypot(first.x - last.x, first.y - last.y) > 1e-6) continue; // 열린 객체
if (!isPointInPolygon(loop, point)) continue;
const area = loopArea(loop);
if (!best || area < best.area) best = { id: entity.id, loop, area };
}
return best;
}
type HatchContext = ToolContext & { boundaries: { id: string; loop: Point[] }[] };
type HatchArgs = { context: HatchContext; event: StateEvent };
const opt = (key: string, kw: string, label: string): OptionSpec => ({ key, kw, en: key, label });
const PICK_PROMPT: PromptSpec = {
text: '내부 점 선택',
options: [opt('SELECT', 'S', '객체 선택'), OPT.UNDO],
absent: [
opt('REMOVE', 'B', '경계 제거'),
opt('SETTINGS', 'T', '설정'),
opt('DRAW', 'W', '그리기'),
],
commit: (input) => {
const loop = loopFromSelection(input);
if (!loop.length) return;
const { width, height } = boundingSize(loop);
addEntities(
[
hatchEntity(loop, {
style: getHatchStyle(),
color: getActiveLineColor(),
spacing: getHatchSpacing() ?? autoHatchSpacing(width, height),
angle: getHatchAngle(),
}),
],
true
);
setSelectedEntityIds([]);
},
};
const SELECT_CHILD = 'hatchSelect';
const showBoundaries = (boundaries: HatchContext['boundaries']) =>
setGhostHelperEntities(boundaries.flatMap(({ loop }) => polyLineEntity(loop) ?? []));
/** 내부 점 하나 — 경계 못 찾으면 안내만 · 같은 경계 두 번은 한 번 */
const pickAt = assign(({ context, event }: HatchArgs) => {
const at =
event.type === ActorEvent.MOUSE_CLICK
? (event as MouseClickEvent).worldMouseLocation
: (event as AbsolutePointInputEvent).value;
const found = loopAround(at);
if (!found) {
toast.warn('닫힌 경계를 찾을 수 없음.');
return {};
}
if (context.boundaries.some(({ id }) => id === found.id)) return {};
const boundaries = [...context.boundaries, found];
showBoundaries(boundaries);
return { boundaries };
});
const undoPick = assign(({ context }: HatchArgs) => {
const boundaries = context.boundaries.slice(0, -1);
showBoundaries(boundaries);
return { boundaries };
});
/** 모은 경계(+ 고른 객체 경계)마다 해치 하나 */
const createHatches = ({ context }: { context: HatchContext }) => {
const loops = context.boundaries.map(({ loop }) => loop);
const selected = getSelectedEntities();
if (selected.length) {
rememberSelection(selected.map((entity) => entity.id));
const loop = entitiesToLoop(selected);
if (loop.length >= 3) loops.push(loop);
else toast.warn('닫힌 경계를 만들 객체를 선택하십시오.');
}
setGhostHelperEntities([]);
setSelectedEntityIds([]);
if (loops.length) addEntities(loops.map(hatchOf), true);
};
const finish = () => {
setGhostHelperEntities([]);
setSelectedEntityIds([]);
};
// biome-ignore lint/suspicious/noExplicitAny: 자식 머신을 품은 상태 구성은 xstate 타입 추론이 안 맞음
const states: Record<string, any> = {
PICK: {
meta: { kind: 'point', prompt: PICK_PROMPT },
entry: () => setShouldDrawHelpers(false),
on: {
MOUSE_CLICK: { actions: pickAt },
ABSOLUTE_POINT_INPUT: { actions: pickAt },
'OPTION.SELECT': { target: 'SELECT' },
'OPTION.UNDO': { actions: undoPick },
ENTER: { actions: createHatches, target: END_STATE },
ESC: { actions: finish, target: END_STATE },
},
},
SELECT: {
meta: { kind: 'selection', prompt: SELECT_OBJECTS },
invoke: {
id: SELECT_CHILD,
src: selectToolStateMachine,
onDone: { actions: createHatches, target: END_STATE },
},
on: {
MOUSE_CLICK: { actions: sendTo(SELECT_CHILD, ({ event }) => event) },
ENTER: { actions: sendTo(SELECT_CHILD, ({ event }) => event) },
DRAW: { actions: sendTo(SELECT_CHILD, ({ event }) => event) },
'OPTION.*': { actions: sendTo(SELECT_CHILD, ({ event }) => event) },
ESC: { actions: finish, target: END_STATE },
},
},
[END_STATE]: { type: 'final' },
};
/**
* HATCH — 「내부 점 선택 또는 [객체 선택(S)/명령 취소(U)]:」 되풀이 · 빈 Enter = 만듦 · Esc = 안 만들고 끝.
* 손으로 짬 — 순서 엔진은 앞 점을 기준점으로 직교를 걸어 둘째 내부 점이 끌려감.
*/
export const hatchToolStateMachine = createMachine(
{
types: {} as { context: HatchContext; events: StateEvent },
context: { type: Tool.HATCH, boundaries: [] },
initial: 'PICK',
states,
},
{
// biome-ignore lint/suspicious/noExplicitAny: 자식 머신의 컨텍스트 타입이 달라 그대로 넘긴다
actions: { ...(selectToolStateMachine.implementations.actions as any) },
}
);
export const gradientToolStateMachine = createSequenceTool({
tool: Tool.GRADIENT,
helpers: false,
+319
View File
@@ -0,0 +1,319 @@
/**
* 337 나-2 — 구성선 · 글자 · 해치 넷(XLINE · TEXT · MTEXT · HATCH)을 오토캐드 물음 차례대로.
* 설계 `docs/raw/조사/2026-10-15_337_CAD오토캐드조작_설계.md` 1-1 · 6-3 나 줄(XLINE 뒤 넷).
* 실행: cd B07_DesignDetail/openwebcad && npx vitest run --dir ../../resources/tester/cad text-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 { cancelActiveCommand } from "../../../B07_DesignDetail/openwebcad/src/inputController/prompt-input";
import { getSelectedEntityIds } from "../../../B07_DesignDetail/openwebcad/src/state";
import { Tool } from "../../../B07_DesignDetail/openwebcad/src/tools";
import {
activeTool,
click,
entities,
initIdle,
key,
noticeLine,
promptLine,
type,
typeEnter,
typed,
} from "./helpers/cad-drive";
type Xy = { x: number; y: number };
// biome-ignore lint/suspicious/noExplicitAny: 앱 객체 속을 숫자로만 읽음
type Any = any;
const enter = () => key("Enter");
const close = (a: number, b: number) => expect(a).toBeCloseTo(b, 6);
const ends = (line: Any): [Xy, Xy] => [line.getStartPoint(), line.getEndPoint()];
const texts = () =>
entities("Text").map((text: Any) => ({
label: text.getLabel() as string,
at: text.basePoint as Xy,
size: text.getTextOptions().fontSize as number,
dir: text.getTextOptions().textDirection as Xy,
}));
const idle = () => expect(activeTool()).toBe(Tool.SELECT);
beforeEach(() => {
initIdle();
remember("XLINE.ANGLE", 0);
remember("TEXTSIZE", 2.5);
remember("TEXT.ANGLE", 0);
});
describe("XLINE(XL)", () => {
it("첫 물음 · H 되풀이 · Enter 끝", () => {
type("xl");
expect(promptLine()).toBe("점 지정 또는 [수평(H)/수직(V)/각도(A)]:");
type("h");
expect(promptLine()).toBe("통과점을 지정:");
type("0,10");
type("0,20");
expect(promptLine()).toBe("통과점을 지정:");
enter();
idle();
const lines = entities("Line").map(ends);
expect(lines).toHaveLength(2);
lines.forEach(([a, b], index) => {
close(a.y, 10 * (index + 1));
close(b.y, 10 * (index + 1));
expect(Math.abs(b.x - a.x)).toBeGreaterThan(1000);
});
});
it("V · Esc = 끝(그은 선 남음)", () => {
type("xl");
type("v");
type("5,0");
key("Escape");
idle();
const [[a, b]] = entities("Line").map(ends);
close(a.x, 5);
close(b.x, 5);
});
it("A — 각도 물음 · 기억 · 통과점", () => {
type("xl");
type("a");
expect(promptLine()).toBe("X선의 각도 입력 <0>:");
type("45");
expect(promptLine()).toBe("통과점을 지정:");
type("0,0");
enter();
type("xl");
type("a");
expect(promptLine()).toBe("X선의 각도 입력 <45>:");
enter(); // 45
type("10,0");
enter();
idle();
const lines = entities("Line").map(ends);
expect(lines).toHaveLength(2);
for (const [a, b] of lines) close((b.y - a.y) / (b.x - a.x), 1);
const [, [a2]] = lines;
close(a2.y - 0, a2.x - 10); // (10, 0) 을 지남
});
it("두 점 방향 되풀이 · 없는 옵션 안내 · 첫 통과점 Enter = 그냥 끝", () => {
type("xl");
type("b");
expect(noticeLine()).toBe("「이등분(B)」 옵션은 이 CAD 에 없음.");
type("0,0");
type("10,10");
type("10,-10");
enter();
idle();
const slopes = entities("Line")
.map(ends)
.map(([a, b]) => (b.y - a.y) / (b.x - a.x));
expect(slopes).toHaveLength(2);
close(slopes[0], 1);
close(slopes[1], -1);
type("xl");
type("h");
enter();
idle();
expect(entities("Line")).toHaveLength(2);
});
});
describe("TEXT(DT)", () => {
it("시작점 → 높이 → 각 → 줄 이어 치기 → 빈 줄 끝", () => {
type("dt");
expect(promptLine()).toBe("문자의 시작점 지정:");
type("s");
expect(noticeLine()).toBe("「스타일(S)」 옵션은 이 CAD 에 없음.");
type("0,0");
expect(promptLine()).toBe("높이 지정 <2.5000>:");
type("-2");
expect(noticeLine()).toBe("값은 양수이고 0이 아니어야 함.");
type("5");
expect(promptLine()).toBe("문자의 회전 각도 지정 <0>:");
enter();
expect(promptLine()).toBe("문자 입력:");
typeEnter("ab c"); // 글 물음의 Space = 빈칸
expect(promptLine()).toBe("문자 입력:");
typeEnter("de");
enter(); // 빈 줄 = 끝
idle();
const [first, second] = texts();
expect([first.label, second.label]).toEqual(["ab c", "de"]);
expect([first.size, second.size]).toEqual([5, 5]);
close(first.at.x, 0);
close(first.at.y, 0);
close(second.at.x, 0);
close(second.at.y, -5 * 1.35);
});
it("높이 기억 · 시작점 Enter = 앞 글 아래(높이 · 각 안 물음) · 처음 줄 빈 Enter = 아무것도 없이 끝", () => {
type("dt");
type("0,0");
type("4");
enter();
typeEnter("one");
enter();
type("dt");
enter(); // 시작점 Enter
expect(promptLine()).toBe("문자 입력:");
typeEnter("two");
enter();
const [one, two] = texts();
expect(two.size).toBe(4);
close(two.at.x, one.at.x);
close(two.at.y, one.at.y - 4 * 1.35);
type("dt");
type("50,50");
expect(promptLine()).toBe("높이 지정 <4.0000>:");
enter();
enter();
enter(); // 첫 줄에서 빈 Enter
idle();
expect(texts()).toHaveLength(2);
});
it("회전 각 — 글 방향 · 다음 줄은 돌린 아래 · Esc = 치던 줄 버림", () => {
type("dt");
type("10,0");
type("2");
type("90");
typeEnter("up");
typeEnter("next");
typed("zz");
key("Escape");
idle();
const [up, next] = texts();
expect(texts()).toHaveLength(2);
close(up.dir.x, 0);
close(up.dir.y, 1);
close(next.at.x, 10 + 2 * 1.35);
close(next.at.y, 0);
type("dt");
expect(promptLine()).toBe("문자의 시작점 지정:");
type("0,0");
expect(promptLine()).toBe("높이 지정 <2.0000>:");
enter();
expect(promptLine()).toBe("문자의 회전 각도 지정 <90>:");
key("Escape");
});
});
describe("MTEXT(T · MT)", () => {
it("첫 구석 → H → 반대 구석 → 글(\\P 줄바꿈) · 상자 왼쪽 위에서", () => {
type("t");
expect(promptLine()).toBe("첫 번째 구석 지정:");
type("0,100");
expect(promptLine()).toBe("반대 구석 지정 또는 [높이(H)/폭(W)]:");
type("c");
expect(noticeLine()).toBe("「열(C)」 옵션은 이 CAD 에 없음.");
type("h");
expect(promptLine()).toBe("높이 지정 <2.5000>:");
type("4");
expect(promptLine()).toBe("반대 구석 지정 또는 [높이(H)/폭(W)]:");
type("50,60");
expect(promptLine()).toBe("문자 입력 (줄바꿈 \\P):");
typeEnter("a\\Pb");
idle();
const [a, b] = texts();
expect([a.label, b.label]).toEqual(["a", "b"]);
expect(a.size).toBe(4);
close(a.at.x, 0);
close(a.at.y, 96);
close(b.at.y, 96 - 4 * 1.35);
});
it("W — 폭 숫자 → 글 · 높이 = 기억(TEXTSIZE) · 빈 글 = 안 만들고 끝", () => {
type("mt");
type("10,10");
type("w");
expect(promptLine()).toBe("폭 지정:");
type("30");
expect(promptLine()).toBe("문자 입력 (줄바꿈 \\P):");
typeEnter("x");
idle();
const [x] = texts();
close(x.at.x, 10);
close(x.at.y, 10 - 2.5);
type("t");
type("0,0");
type("5,5");
enter();
idle();
expect(texts()).toHaveLength(1);
});
});
describe("HATCH(H · BH)", () => {
const boxes = () => {
for (const [a, b] of [
["0,0", "100,100"],
["40,40", "60,60"],
]) {
type("rec");
type(a);
type(b);
cancelActiveCommand();
}
expect(entities()).toHaveLength(2);
};
const spans = () =>
entities("Hatch").map((hatch: Any) => {
const xs = (hatch.getPoints() as Xy[]).map((point) => point.x);
return [Math.min(...xs), Math.max(...xs)];
});
beforeEach(boxes);
it("내부 점 = 품은 것 중 가장 작은 경계 · 되풀이 · 같은 경계 한 번 · Enter = 만듦", () => {
type("h");
expect(promptLine()).toBe("내부 점 선택 또는 [객체 선택(S)/명령 취소(U)]:");
click(50, 50);
click(10, 10);
click(15, 15);
expect(promptLine()).toBe("내부 점 선택 또는 [객체 선택(S)/명령 취소(U)]:");
enter();
idle();
expect(spans()).toEqual([
[40, 60],
[0, 100],
]);
});
it("U = 마지막 경계 빼기 · 경계 없는 점은 무시 · Esc = 안 만듦", () => {
type("h");
click(50, 50);
click(10, 10);
click(500, 500);
type("u");
enter();
expect(spans()).toEqual([[40, 60]]);
type("h");
click(10, 10);
key("Escape");
idle();
expect(spans()).toHaveLength(1);
type("bh");
type("t");
expect(noticeLine()).toBe("「설정(T)」 옵션은 이 CAD 에 없음.");
enter(); // 경계 없이 Enter = 그냥 끝
idle();
expect(spans()).toHaveLength(1);
});
it("S — 객체 선택 → Enter = 고른 객체 경계로 만듦 · 고름 풂", () => {
type("h");
type("s");
expect(promptLine()).toBe("객체 선택:");
click(0, 50);
enter();
idle();
expect(spans()).toEqual([[0, 100]]);
expect(getSelectedEntityIds()).toEqual([]);
});
});