diff --git a/B07_DesignDetail/openwebcad/src/tools/annotate/text-tools.ts b/B07_DesignDetail/openwebcad/src/tools/annotate/text-tools.ts index add2367b7..66a611411 100644 --- a/B07_DesignDetail/openwebcad/src/tools/annotate/text-tools.ts +++ b/B07_DesignDetail/openwebcad/src/tools/annotate/text-tools.ts @@ -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 — 「문자의 시작점 지정:」 → 「높이 지정 :」 → 「문자의 회전 각도 지정 <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('start') : null; + const previous = start ? null : lastLine; + const height = previous?.height ?? input.get('height'); + const angle = previous?.angle ?? input.get('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('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('corner'); + const opposite = input.has('opposite') ? input.get('opposite') : corner; + const height = input.has('height') + ? input.get('height') + : (recall('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); }, }); diff --git a/B07_DesignDetail/openwebcad/src/tools/draw/construction-tools.ts b/B07_DesignDetail/openwebcad/src/tools/draw/construction-tools.ts index 01209cceb..3520c438e 100644 --- a/B07_DesignDetail/openwebcad/src/tools/draw/construction-tools.ts +++ b/B07_DesignDetail/openwebcad/src/tools/draw/construction-tools.ts @@ -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('angle') : 0 }[ + input.mode ?? '' + ]; + if (degrees === undefined) { + const base = input.get('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('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(); diff --git a/B07_DesignDetail/openwebcad/src/tools/draw/fill-tools.ts b/B07_DesignDetail/openwebcad/src/tools/draw/fill-tools.ts index a0db57845..d54b6edb3 100644 --- a/B07_DesignDetail/openwebcad/src/tools/draw/fill-tools.ts +++ b/B07_DesignDetail/openwebcad/src/tools/draw/fill-tools.ts @@ -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 = { + 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, diff --git a/resources/tester/cad/text-acad.test.ts b/resources/tester/cad/text-acad.test.ts new file mode 100644 index 000000000..aa8de4942 --- /dev/null +++ b/resources/tester/cad/text-acad.test.ts @@ -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([]); + }); +});