Merge remote-tracking branch 'origin/sub_laptop_3' into main_laptop_1
This commit is contained in:
@@ -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,
|
||||
|
||||
@@ -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([]);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user