feat(CAD): 337-3b 나-2 구성선 · 글자 · 해치 — XLINE · TEXT · MTEXT · HATCH 오토캐드 물음 차례

- XLINE 「점 지정 또는 [수평(H)/수직(V)/각도(A)]:」 → 「통과점을 지정:」 되풀이 · 각도 기억(XLINE.ANGLE) · 이등분 · 간격띄우기 = 없는 옵션 안내 · 빈 Enter · Esc = 끝(그은 선 남음)
- RAY 통과점 되풀이(loopFrom)
- TEXT 시작점 → 높이 <TEXTSIZE> → 회전 각 <TEXT.ANGLE> → 줄마다 「문자 입력:」(Enter = 아래 새 줄 · 빈 줄 = 끝) · 시작점 Enter = 마지막 글 아래 이어 씀(높이 · 각 안 물음)
- MTEXT 첫 구석 → 「반대 구석 지정 또는 [높이(H)/폭(W)]:」 → 글(\P 줄바꿈) · 상자 왼쪽 위에서 · 줄 감기 없음
- HATCH 「내부 점 선택 또는 [객체 선택(S)/명령 취소(U)]:」 되풀이 · 품은 닫힌 객체 중 가장 작은 경계 · 빈 Enter = 만듦 · Esc = 안 만듦 · 손 머신(엔진은 직교가 둘째 내부 점을 끌어감)
- 시험 resources/tester/cad/text-acad.test.ts 12

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XWh5wNNycMkbwMpACz1rte
This commit is contained in:
2026-10-16 00:10:42 +09:00
co-authored by Claude Opus 5.5
parent 6e346cfb5e
commit b384fa0660
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);
},
});