Merge remote-tracking branch 'origin/dev' into sub_laptop_7
This commit is contained in:
@@ -17,6 +17,7 @@ import {
|
||||
chamferToolStateMachine,
|
||||
filletToolStateMachine,
|
||||
} from '../tools/modify/corner-tools';
|
||||
import { lengthenToolStateMachine } from '../tools/modify/lengthen-tool';
|
||||
import {
|
||||
drawOrderBackToolStateMachine,
|
||||
drawOrderFrontToolStateMachine,
|
||||
@@ -32,11 +33,10 @@ import {
|
||||
extendToolStateMachine,
|
||||
joinToolStateMachine,
|
||||
} from '../tools/modify/structure-tools';
|
||||
import { offsetToolStateMachine } from '../tools/modify/offset-tool';
|
||||
import {
|
||||
alignToolStateMachine,
|
||||
lengthenToolStateMachine,
|
||||
mirrorToolStateMachine,
|
||||
offsetToolStateMachine,
|
||||
stretchToolStateMachine,
|
||||
} from '../tools/modify/transform-tools';
|
||||
import { moveToolStateMachine } from '../tools/move-tool';
|
||||
|
||||
@@ -6,7 +6,7 @@ import { bumpSceneVersion } from '../helpers/scene-version';
|
||||
import { getScreenCanvasDrawController, setEntities, getEntities } from '../state';
|
||||
import { Tool } from '../tools';
|
||||
import { selectToolStateMachine } from '../tools/select-tool';
|
||||
import { zoomExtents } from '../inputController/zoom-prompt';
|
||||
import { startZoomPrompt } from '../inputController/zoom-prompt';
|
||||
|
||||
const zoomBy = (factor: number): string => {
|
||||
const controller = getScreenCanvasDrawController();
|
||||
@@ -43,10 +43,10 @@ export const VIEW_COMMANDS: CadCommand[] = [
|
||||
label: '범위 줌',
|
||||
aliases: ['Z'],
|
||||
glyph: '⛶',
|
||||
hint: '도면 전체가 보이도록 화면을 맞춘다',
|
||||
hint: '범위(E) · 전체(A) · 이전(P) · 윈도우(W) · 2X — 오토캐드 ZOOM(⛶ 단추 = 바로 범위)',
|
||||
run: () => {
|
||||
zoomExtents(); // 앞 화면 기억(이전(P))
|
||||
return '도면 전체 보기';
|
||||
startZoomPrompt(); // 덧물음 — 명령 중 'Z 도 같은 길
|
||||
return '';
|
||||
},
|
||||
},
|
||||
{
|
||||
|
||||
@@ -3,17 +3,20 @@ import { type FC, type FormEvent, useMemo, useRef, useState } from 'react';
|
||||
import { describeCommand, matchCommandPrefixes } from '../commands/registry';
|
||||
import { getCommandHistory, runCommandInput } from '../commands/run-command';
|
||||
import { activeToolWantsFreeText, isPlainSpace } from '../inputController/ime-sink';
|
||||
import { commandCandidate, recallInput } from '../inputController/input-history';
|
||||
import { currentPrompt } from '../inputController/prompt-input';
|
||||
import { getInputController } from '../state';
|
||||
|
||||
export const CommandLine: FC = () => {
|
||||
const [text, setText] = useState('');
|
||||
const [historyIndex, setHistoryIndex] = useState<number | null>(null);
|
||||
/** Tab · ↑↓ 후보 돌림 — 처음 친 글 · 몇째(null = 안 돌림) */
|
||||
const [pick, setPick] = useState<{ base: string; index: number } | null>(null);
|
||||
const [fromHistory, setFromHistory] = useState(false);
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
const history = getCommandHistory();
|
||||
const instruction = currentPrompt().line || '명령을 입력하거나 도구를 선택하십시오.';
|
||||
|
||||
const suggestions = useMemo(() => matchCommandPrefixes(text).slice(0, 8), [text]);
|
||||
const suggestions = useMemo(() => matchCommandPrefixes(pick?.base ?? text).slice(0, 8), [text, pick]);
|
||||
|
||||
const submit = (event: FormEvent) => {
|
||||
event.preventDefault();
|
||||
@@ -26,17 +29,28 @@ export const CommandLine: FC = () => {
|
||||
// 진행 중인 명령이 문자·숫자를 기다리면 그쪽으로, 아니면 명령으로 해석된다
|
||||
getInputController().submitText(value);
|
||||
setText('');
|
||||
setHistoryIndex(null);
|
||||
setPick(null);
|
||||
setFromHistory(false);
|
||||
};
|
||||
|
||||
const recallHistory = (direction: -1 | 1) => {
|
||||
if (!history.length) return;
|
||||
const nextIndex =
|
||||
historyIndex === null
|
||||
? history.length - 1
|
||||
: Math.min(history.length - 1, Math.max(0, historyIndex + direction));
|
||||
setHistoryIndex(nextIndex);
|
||||
setText(history[nextIndex].split(' ')[0]);
|
||||
/** 후보 보이면 후보 돌림 · 아니면 친 명령 · 최근 값 기록(캔버스와 같은 함수 · 알림 줄 안 섞음) */
|
||||
const recall = (direction: -1 | 1) => {
|
||||
if (pick || (!fromHistory && text.trim() && suggestions.length)) {
|
||||
cycle(direction);
|
||||
return;
|
||||
}
|
||||
const value = recallInput(direction, text);
|
||||
if (value === null) return;
|
||||
setText(value);
|
||||
setFromHistory(true);
|
||||
};
|
||||
const cycle = (direction: -1 | 1) => {
|
||||
const base = pick?.base ?? text;
|
||||
const index = pick ? pick.index + direction : direction > 0 ? 0 : -1;
|
||||
const name = commandCandidate(base, index);
|
||||
if (!name) return;
|
||||
setPick({ base, index });
|
||||
setText(name);
|
||||
};
|
||||
|
||||
return (
|
||||
@@ -53,7 +67,11 @@ export const CommandLine: FC = () => {
|
||||
id="cad-command"
|
||||
ref={inputRef}
|
||||
value={text}
|
||||
onChange={(event) => setText(event.target.value)}
|
||||
onChange={(event) => {
|
||||
setText(event.target.value);
|
||||
setPick(null);
|
||||
setFromHistory(false);
|
||||
}}
|
||||
onKeyDown={(event) => {
|
||||
if (event.nativeEvent.isComposing) return; // 한글 조합 중 키는 IME 몫
|
||||
if (event.key === ' ' && !activeToolWantsFreeText()) {
|
||||
@@ -68,17 +86,13 @@ export const CommandLine: FC = () => {
|
||||
event.preventDefault();
|
||||
getInputController().handleEscapeKey();
|
||||
}
|
||||
if (event.key === 'ArrowUp') {
|
||||
if (event.key === 'ArrowUp' || event.key === 'ArrowDown') {
|
||||
event.preventDefault();
|
||||
recallHistory(-1);
|
||||
}
|
||||
if (event.key === 'ArrowDown') {
|
||||
event.preventDefault();
|
||||
recallHistory(1);
|
||||
recall(event.key === 'ArrowUp' ? -1 : 1);
|
||||
}
|
||||
if (event.key === 'Tab' && suggestions.length) {
|
||||
event.preventDefault();
|
||||
setText(suggestions[0].id);
|
||||
cycle(1);
|
||||
}
|
||||
}}
|
||||
placeholder="명령 입력 (예: L, LINE, REC, MOVE)"
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
/** 화면 우하단 탐색 막대 (줌 표시·전체 보기) — 제목줄이 없는 화면(M02)은 되돌리기 · 다시 하기도 */
|
||||
import type { FC } from 'react';
|
||||
import { runCommandInput } from '../commands/run-command';
|
||||
import { redoCommand, undoCommand } from '../inputController/prompt-input';
|
||||
import { zoomExtents } from '../inputController/zoom-prompt';
|
||||
import {
|
||||
getScreenCanvasDrawController,
|
||||
@@ -18,10 +19,10 @@ export const ViewControls: FC = () => {
|
||||
<div className="cad-view-controls controls">
|
||||
{showUndo && (
|
||||
<>
|
||||
<button type="button" onClick={() => runCommandInput('UNDO')} title="실행 취소 (Ctrl+Z)">
|
||||
<button type="button" onClick={undoCommand} title="실행 취소 (Ctrl+Z)">
|
||||
↶
|
||||
</button>
|
||||
<button type="button" onClick={() => runCommandInput('REDO')} title="다시 실행 (Ctrl+Y)">
|
||||
<button type="button" onClick={redoCommand} title="다시 실행 (Ctrl+Y)">
|
||||
↷
|
||||
</button>
|
||||
</>
|
||||
|
||||
@@ -51,6 +51,8 @@ export class MeasurementEntity implements Entity {
|
||||
private startPoint: Point;
|
||||
private endPoint: Point;
|
||||
private offsetPoint: Point;
|
||||
/** 치수 문자 바꿈(DIMLINEAR 문자(T)) — `<>` = 잰 값 · 없으면 잰 값 */
|
||||
public textOverride?: string;
|
||||
|
||||
constructor(layerId: string, startPoint: Point, endPoint: Point, offsetPoint: Point) {
|
||||
this.layerId = layerId;
|
||||
@@ -75,6 +77,17 @@ export class MeasurementEntity implements Entity {
|
||||
this.offsetPoint = point;
|
||||
}
|
||||
|
||||
/** 잰 값(치수 소수 자릿수) */
|
||||
public getMeasuredText(): string {
|
||||
return String(round(pointDistance(this.startPoint, this.endPoint), getDimDecimals()));
|
||||
}
|
||||
|
||||
/** 보이는 치수 문자 */
|
||||
public getLabel(): string {
|
||||
const measured = this.getMeasuredText();
|
||||
return this.textOverride ? this.textOverride.replaceAll('<>', measured) : measured;
|
||||
}
|
||||
|
||||
public getDrawPoints() {
|
||||
// Return if measurement is zero length
|
||||
if (isPointEqual(this.startPoint, this.endPoint)) {
|
||||
@@ -301,7 +314,7 @@ export class MeasurementEntity implements Entity {
|
||||
drawController.drawLine(offsetStartPointMargin, offsetStartPointExtend);
|
||||
drawController.drawLine(offsetEndPointMargin, offsetEndPointExtend);
|
||||
|
||||
const distance = String(round(pointDistance(this.startPoint, this.endPoint), getDimDecimals()));
|
||||
const distance = this.getLabel();
|
||||
const originalTextDirection = normalUnit.rotate90CW();
|
||||
let finalTextDirection = originalTextDirection;
|
||||
if (
|
||||
@@ -343,12 +356,14 @@ export class MeasurementEntity implements Entity {
|
||||
}
|
||||
|
||||
public clone(): MeasurementEntity {
|
||||
return new MeasurementEntity(
|
||||
const copy = new MeasurementEntity(
|
||||
getActiveLayerId(),
|
||||
this.startPoint.clone(),
|
||||
this.endPoint.clone(),
|
||||
this.offsetPoint.clone()
|
||||
);
|
||||
copy.textOverride = this.textOverride;
|
||||
return copy;
|
||||
}
|
||||
|
||||
public intersectsWithBox(box: Box): boolean {
|
||||
@@ -416,7 +431,7 @@ export class MeasurementEntity implements Entity {
|
||||
];
|
||||
|
||||
// Calculate text properties
|
||||
const distance = String(round(pointDistance(this.startPoint, this.endPoint), getDimDecimals()));
|
||||
const distance = this.getLabel();
|
||||
const worldFactor = annotationWorldFactor();
|
||||
const textHeight = getDimTextHeight() / worldFactor;
|
||||
// Estimate width: textString.length * fontSize * aspectRatioFactor
|
||||
@@ -582,6 +597,7 @@ export class MeasurementEntity implements Entity {
|
||||
startPoint: { x: this.startPoint.x, y: this.startPoint.y },
|
||||
endPoint: { x: this.endPoint.x, y: this.endPoint.y },
|
||||
offsetPoint: { x: this.offsetPoint.x, y: this.offsetPoint.y },
|
||||
...(this.textOverride ? { textOverride: this.textOverride } : {}),
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -611,6 +627,7 @@ export class MeasurementEntity implements Entity {
|
||||
measurementEntity.lineColor = jsonEntity.lineColor;
|
||||
measurementEntity.lineWidth = jsonEntity.lineWidth;
|
||||
measurementEntity.lineDash = jsonEntity.lineDash;
|
||||
measurementEntity.textOverride = jsonEntity.shapeData.textOverride;
|
||||
return measurementEntity;
|
||||
}
|
||||
}
|
||||
@@ -619,4 +636,5 @@ export interface MeasurementJsonData {
|
||||
startPoint: { x: number; y: number };
|
||||
endPoint: { x: number; y: number };
|
||||
offsetPoint: { x: number; y: number };
|
||||
textOverride?: string;
|
||||
}
|
||||
|
||||
@@ -29,7 +29,7 @@ export function getPointFromEvent(startPoint: Point | null, event: PointInputEve
|
||||
: mouse;
|
||||
}
|
||||
if (event.type === ActorEvent.MOUSE_CLICK) {
|
||||
const clickEvent = event as MouseClickEvent & { snapped?: boolean };
|
||||
const clickEvent = event as MouseClickEvent;
|
||||
const click = clickEvent.worldMouseLocation;
|
||||
if (locked) return projectOnLock(startPoint, click);
|
||||
return startPoint && !clickEvent.snapped && orthoOn(!!clickEvent.holdingShift)
|
||||
|
||||
@@ -38,9 +38,11 @@ import { cadCtrlCommand, ctrlShortcut } from './input-shortcut.ts';
|
||||
import { wheelZoom } from './input-wheel.ts';
|
||||
import { isObjectSnap, noteShift, snapAt } from './ortho.ts';
|
||||
import { isWaitingForSecondSelectPoint, selectingActor } from './selecting.ts';
|
||||
import { openSnapMenu } from './snap-menu.ts';
|
||||
import { isMiddleDouble, zoomExtents } from './zoom-prompt.ts';
|
||||
import { attachTouch } from './input-touch.ts';
|
||||
import { NUMBER_REGEXP, type PointTextEvent } from './input-patterns.ts';
|
||||
import { commandCandidate, pushInput, recallInput } from './input-history.ts';
|
||||
import {
|
||||
angleLockHint,
|
||||
getAngleLock,
|
||||
@@ -74,6 +76,10 @@ const DRAG_SELECT_MIN_PX = 4;
|
||||
|
||||
export class InputController {
|
||||
private text = '';
|
||||
/** ↑↓ · Tab 돌림 — 기록인가 후보인가 · 후보의 처음 친 글 · 몇째 */
|
||||
private browsing: 'history' | 'candidates' | null = null;
|
||||
private candidateBase = '';
|
||||
private candidateIndex = 0;
|
||||
/** 왼쪽 버튼을 누른 화면 좌표 — 놓을 때 끌었는지 판단한다 */
|
||||
private leftPressLocation: Point | null = null;
|
||||
public readonly ime = new ImeSink((data) => {
|
||||
@@ -163,10 +169,14 @@ export class InputController {
|
||||
|
||||
public handleMouseUp(evt: MouseEvent) {
|
||||
if (evt.button === MouseButton.Right) {
|
||||
// 우클릭 = Enter(명령 중 확정 · 쉴 때 반복)
|
||||
// 우클릭 = Enter(명령 중 확정 · 쉴 때 반복) · Shift+우클릭 = 점 물음에서 한 번 스냅 메뉴
|
||||
evt.preventDefault();
|
||||
evt.stopPropagation();
|
||||
this.handleEnterKey(true);
|
||||
if (evt.shiftKey && currentPrompt().kind === 'point') {
|
||||
openSnapMenu(evt.clientX, evt.clientY, (name) => this.submitText(name));
|
||||
} else {
|
||||
this.handleEnterKey(true);
|
||||
}
|
||||
}
|
||||
|
||||
// If ancestor parent exist with class .controls => ignore clicks, since a button was clicked instead of the canvas
|
||||
@@ -408,7 +418,12 @@ export class InputController {
|
||||
return;
|
||||
}
|
||||
if (evt.key === 'Tab') {
|
||||
// 점 단계(기준점 있음) = 동적 입력 칸 넘김 · 그 밖 = 브라우저 초점 이동
|
||||
// 쉼 + 친 글 = 명령 후보 돌림 · 점 단계(기준점 있음) = 동적 입력 칸 넘김 · 그 밖 = 브라우저 초점 이동
|
||||
if (isIdle() && this.text) {
|
||||
evt.preventDefault();
|
||||
this.cycleCandidate(1);
|
||||
return;
|
||||
}
|
||||
if (!dynamicInputApplies()) return;
|
||||
evt.preventDefault();
|
||||
const isNumber = NUMBER_REGEXP.test(this.text);
|
||||
@@ -445,6 +460,7 @@ export class InputController {
|
||||
// Remove the last character from the input field
|
||||
evt.preventDefault();
|
||||
this.text = this.text.slice(0, this.text.length - 1);
|
||||
this.browsing = null;
|
||||
} else if (evt.key === 'Delete') {
|
||||
// User wants to delete the current selection
|
||||
evt.preventDefault();
|
||||
@@ -464,6 +480,9 @@ export class InputController {
|
||||
this.handleEnterKey(true);
|
||||
this.ime.sync(activeToolWantsIme());
|
||||
}
|
||||
} else if ((evt.key === 'ArrowUp' || evt.key === 'ArrowDown') && !evt.shiftKey) {
|
||||
// ↑↓ = 기록 · 최근 값 · 후보(오토캐드) — 화면 옮김은 Shift+↑↓ · ← →
|
||||
this.recall(evt.key === 'ArrowUp' ? -1 : 1);
|
||||
} else if (evt.key === 'ArrowDown') {
|
||||
// Move the screen down
|
||||
getScreenCanvasDrawController().setScreenOffset(this.getScreenPanStep('down', evt.shiftKey));
|
||||
@@ -483,12 +502,40 @@ export class InputController {
|
||||
// 글 단계(TEXT·MTEXT)는 친 그대로 — 명령 입력일 때만 대문자로 바꾼다(보이는 글만 · 맞추기는 prompt.ts).
|
||||
// Ctrl · Alt · ⌘ 와 함께 누른 글자는 쌓지 않음(Ctrl+C → 원이 되던 것).
|
||||
this.text += activeToolWantsFreeText() ? evt.key : evt.key.toUpperCase();
|
||||
this.browsing = null;
|
||||
}
|
||||
}
|
||||
|
||||
/** ↑↓ — 후보 보일 때 = 후보 돌림 · 아니면 기록(쉼 = 친 명령 · 명령 중 = 그 물음 최근 값) */
|
||||
private recall(dir: -1 | 1) {
|
||||
const candidatesShown = isIdle() && !!this.text && !!commandCandidate(this.text, 0);
|
||||
if (this.browsing === 'candidates' || (this.browsing === null && candidatesShown)) {
|
||||
this.cycleCandidate(dir);
|
||||
return;
|
||||
}
|
||||
const value = recallInput(dir, this.text);
|
||||
if (value === null) return;
|
||||
this.text = value;
|
||||
this.browsing = 'history';
|
||||
}
|
||||
|
||||
/** 명령 후보 돌림 — 처음 친 글로 시작하는 명령 이름을 차례로 친 글 자리에 */
|
||||
private cycleCandidate(dir: -1 | 1) {
|
||||
if (this.browsing !== 'candidates') {
|
||||
this.candidateBase = this.text;
|
||||
this.candidateIndex = dir > 0 ? -1 : 0;
|
||||
}
|
||||
const name = commandCandidate(this.candidateBase, this.candidateIndex + dir);
|
||||
if (!name) return;
|
||||
this.candidateIndex += dir;
|
||||
this.text = name;
|
||||
this.browsing = 'candidates';
|
||||
}
|
||||
|
||||
/** Esc 한 번 = 명령 끝(오토캐드) — 친 글 · 조합 · 각도 잠금 · 한 번 스냅 · 덧물음도 함께 풂 */
|
||||
public handleEscapeKey() {
|
||||
this.text = '';
|
||||
this.browsing = null;
|
||||
this.ime.composing = '';
|
||||
const boxing =
|
||||
this.isSelectToolActive() &&
|
||||
@@ -511,7 +558,12 @@ export class InputController {
|
||||
if (this.text === '' || !isIdle()) {
|
||||
return [];
|
||||
}
|
||||
return matchCommandPrefixes(this.text).slice(0, 6).map(describeCommand);
|
||||
const picking = this.browsing === 'candidates';
|
||||
const candidates = matchCommandPrefixes(picking ? this.candidateBase : this.text);
|
||||
const picked = picking ? candidates.findIndex((command) => command.id === this.text) : -1;
|
||||
return candidates
|
||||
.slice(0, 6)
|
||||
.map((command, index) => `${index === picked ? '▸ ' : ''}${describeCommand(command)}`);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -521,6 +573,8 @@ export class InputController {
|
||||
public handleEnterKey(_repeatIfIdle = false) {
|
||||
const text = this.text;
|
||||
this.text = '';
|
||||
this.browsing = null;
|
||||
pushInput(text); // ↑↓ 기록(지금 물음 묶음 — 해석 전에)
|
||||
routeTyped(text);
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,52 @@
|
||||
/**
|
||||
* ↑↓ 기록(337 설계 2-12 · H11) — 쉴 때 = 친 명령 기록 · 명령 중 = 그 물음(`prompt.history` · 없으면 글)의 최근 값 20.
|
||||
* 보이는 기록 줄(run-command 의 log · 알림 섞임)과 나눔. 캔버스 · 명령행이 같이 씀. 글 물음(TEXT 등)은 안 남김.
|
||||
*/
|
||||
import { matchCommandPrefixes, resolveCommandInput } from '../commands/registry';
|
||||
import { currentPrompt } from './prompt-input';
|
||||
|
||||
const COMMANDS = '\u0000명령';
|
||||
const LIMIT = { command: 50, value: 20 };
|
||||
|
||||
const groups = new Map<string, string[]>();
|
||||
let browsing: { group: string; index: number } | null = null;
|
||||
|
||||
/** 지금 물음의 기록 묶음 · 글 물음 · 모름 = null */
|
||||
function groupNow(): string | null {
|
||||
const prompt = currentPrompt();
|
||||
if (prompt.kind === 'idle') return COMMANDS;
|
||||
if (prompt.kind === 'text' || prompt.kind === null) return null;
|
||||
return prompt.spec?.history ?? prompt.spec?.text ?? prompt.line;
|
||||
}
|
||||
|
||||
/** 제출한 글 하나를 지금 물음 기록에 — 쉴 때는 명령이 된 글만 */
|
||||
export function pushInput(text: string): void {
|
||||
browsing = null;
|
||||
const value = text.trim();
|
||||
const group = groupNow();
|
||||
if (!value || !group) return;
|
||||
if (group === COMMANDS && !resolveCommandInput(value)) return;
|
||||
const list = groups.get(group) ?? [];
|
||||
if (list[list.length - 1] !== value) list.push(value);
|
||||
list.splice(0, Math.max(0, list.length - (group === COMMANDS ? LIMIT.command : LIMIT.value)));
|
||||
groups.set(group, list);
|
||||
}
|
||||
|
||||
/** ↑(-1) · ↓(+1) — 지금 물음 기록에서 한 칸 · 끝을 지나면 빈 글 · 기록 없으면 null(친 글 그대로) */
|
||||
export function recallInput(dir: -1 | 1, _text: string): string | null {
|
||||
const group = groupNow();
|
||||
const list = group ? groups.get(group) : undefined;
|
||||
if (!group || !list?.length) return null;
|
||||
const from = browsing?.group === group ? browsing.index : list.length;
|
||||
const index = Math.min(list.length, Math.max(0, from + dir));
|
||||
browsing = { group, index };
|
||||
return index === list.length ? '' : list[index];
|
||||
}
|
||||
|
||||
/** 명령 후보 돌림(Tab · 후보 보일 때 ↑↓) — base 로 시작하는 명령 중 index 번째(돌아감) */
|
||||
export function commandCandidate(base: string, index: number): string | null {
|
||||
const candidates = matchCommandPrefixes(base);
|
||||
if (!candidates.length) return null;
|
||||
const n = candidates.length;
|
||||
return candidates[((index % n) + n) % n].id;
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
/**
|
||||
* 한 번 스냅 — 점 단계에서 END · MID · CEN · INT · PER · NEA · NON 을 치면 다음 한 점만 그 스냅(오토캐드).
|
||||
* 한 번 스냅 — 점 단계에서 END · MID · CEN · QUA · INT · PER · NEA · NOD · NON 을 치면 다음 한 점만 그 스냅(오토캐드).
|
||||
* 이름 = 앞 세 글자 ~ 영문 전체(END · ENDP · ENDPOINT) · 우리에 없는 스냅(TAN · EXT …)은 안내.
|
||||
* F3(객체 스냅) 꺼져 있어도 먹음. 커서 둘레에 맞는 점이 없으면 클릭을 버리고 기다림 · Esc 로 풂.
|
||||
*/
|
||||
import type { Point } from '@flatten-js/core';
|
||||
@@ -14,9 +15,11 @@ const SNAP_LABELS = {
|
||||
END: '끝점',
|
||||
MID: '중간점',
|
||||
CEN: '중심',
|
||||
QUA: '사분점',
|
||||
INT: '교차점',
|
||||
PER: '수직',
|
||||
NEA: '근처점',
|
||||
NOD: '노드',
|
||||
NON: '스냅 없음',
|
||||
} as const;
|
||||
type SnapName = keyof typeof SNAP_LABELS;
|
||||
@@ -25,15 +28,52 @@ const SNAP_TYPES: Partial<Record<SnapName, SnapPointType>> = {
|
||||
END: SnapPointType.LineEndPoint,
|
||||
MID: SnapPointType.LineMidPoint,
|
||||
CEN: SnapPointType.CircleCenter,
|
||||
QUA: SnapPointType.CircleCardinal,
|
||||
NOD: SnapPointType.Point,
|
||||
};
|
||||
|
||||
/** 오토캐드 영문 전체 이름 — 세 글자 줄임부터 받음 */
|
||||
const FULL_NAMES: Record<SnapName, string> = {
|
||||
END: 'ENDPOINT',
|
||||
MID: 'MIDPOINT',
|
||||
CEN: 'CENTER',
|
||||
QUA: 'QUADRANT',
|
||||
INT: 'INTERSECTION',
|
||||
PER: 'PERPENDICULAR',
|
||||
NEA: 'NEAREST',
|
||||
NOD: 'NODE',
|
||||
NON: 'NONE',
|
||||
};
|
||||
/** 오토캐드엔 있고 우리엔 없는 스냅 */
|
||||
const ABSENT_NAMES: Record<string, string> = {
|
||||
TAN: 'TANGENT',
|
||||
EXT: 'EXTENSION',
|
||||
INS: 'INSERTION',
|
||||
APP: 'APPINT',
|
||||
PAR: 'PARALLEL',
|
||||
GCE: 'GCEN',
|
||||
};
|
||||
|
||||
const byName = <K extends string>(table: Record<K, string>, text: string): K | null => {
|
||||
const typed = text.trim().toUpperCase().replace(/^_/, '');
|
||||
if (typed.length < 3) return null;
|
||||
const hit = (Object.keys(table) as K[]).find(
|
||||
(short) => typed.startsWith(short) && table[short].startsWith(typed)
|
||||
);
|
||||
return hit ?? null;
|
||||
};
|
||||
/** 친 글 → 스냅 이름(END · ENDP · ENDPOINT → END) · 아니면 null */
|
||||
export const snapName = (text: string): SnapName | null => byName(FULL_NAMES, text);
|
||||
/** 우리에 없는 오토캐드 스냅 이름(세 글자 꼴) · 아니면 null */
|
||||
export const absentSnapName = (text: string): string | null => byName(ABSENT_NAMES, text);
|
||||
|
||||
let override: SnapName | null = null;
|
||||
|
||||
export const getSnapOverride = (): SnapName | null => override;
|
||||
export const setSnapOverride = (name: SnapName | null) => {
|
||||
override = name;
|
||||
};
|
||||
export const isSnapName = (text: string): text is SnapName => text in SNAP_LABELS;
|
||||
export const isSnapName = (text: string): text is SnapName => snapName(text) === text;
|
||||
|
||||
/** 커서 옆 안내 줄 */
|
||||
export const snapOverrideHint = (): string | null =>
|
||||
|
||||
@@ -0,0 +1,83 @@
|
||||
/**
|
||||
* 점 물음 보조(337 설계 1-4 · 2-5 ④ · 가-2) — 한 번 스냅 이름 · FROM · M2P(MTP) · `@`(마지막 점) · `#` · `*`(절대 좌표).
|
||||
* FROM · M2P 는 덧물음 두 개를 잇고 끝에 고른 점을 원래 물음에 넘김(도구 머신은 모름 → 투명).
|
||||
*/
|
||||
import { Point } from '@flatten-js/core';
|
||||
import { lastPoint, normalizeKeyword } from '../commands/prompt';
|
||||
import { ActorEvent } from '../tools/tool.types';
|
||||
import { parsePointText } from './input-patterns';
|
||||
import { absentSnapName, setSnapOverride, snapName } from './input-snap-override';
|
||||
import { distanceCursor, notice, pushSubPrompt, sendPoint } from './prompt-input';
|
||||
|
||||
const point = (x: number, y: number) => new Point(x, y);
|
||||
|
||||
/** FROM — 「기준점:」 → 「<간격띄우기>:」(`@dx,dy` · 거리 = 기준점에서 커서 쪽) */
|
||||
function startFrom() {
|
||||
pushSubPrompt({
|
||||
prompt: { text: '기준점' },
|
||||
kind: 'point',
|
||||
onPoint: (base) => {
|
||||
pushSubPrompt({
|
||||
prompt: { text: '<간격띄우기>' },
|
||||
kind: 'point',
|
||||
base,
|
||||
onPoint: (target) => ({ point: target }),
|
||||
});
|
||||
return 'done';
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
/** M2P · MTP — 두 점의 가운데 */
|
||||
function startMidBetween() {
|
||||
pushSubPrompt({
|
||||
prompt: { text: '중간점의 첫 번째 점' },
|
||||
kind: 'point',
|
||||
onPoint: (first) => {
|
||||
pushSubPrompt({
|
||||
prompt: { text: '중간점의 두 번째 점' },
|
||||
kind: 'point',
|
||||
base: first,
|
||||
onPoint: (second) => ({ point: point((first.x + second.x) / 2, (first.y + second.y) / 2) }),
|
||||
});
|
||||
return 'done';
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
/** 점 물음에서 친 글이 보조면 처리하고 true · 아니면 false(옵션 · 좌표 차례로) */
|
||||
export function tryPointModifier(text: string): boolean {
|
||||
const typed = normalizeKeyword(text);
|
||||
const snap = snapName(typed);
|
||||
if (snap) {
|
||||
setSnapOverride(snap);
|
||||
return true;
|
||||
}
|
||||
const absent = absentSnapName(typed);
|
||||
if (absent) {
|
||||
notice(`「${absent}」 스냅은 이 CAD 에 없음.`);
|
||||
return true;
|
||||
}
|
||||
if (typed === 'FROM' || typed === 'FRO') {
|
||||
startFrom();
|
||||
return true;
|
||||
}
|
||||
if (typed === 'M2P' || typed === 'MTP') {
|
||||
startMidBetween();
|
||||
return true;
|
||||
}
|
||||
if (typed === '@') {
|
||||
const last = lastPoint();
|
||||
if (!last) return false;
|
||||
sendPoint({ type: ActorEvent.ABSOLUTE_POINT_INPUT, value: point(last.x, last.y) });
|
||||
return true;
|
||||
}
|
||||
if (typed.startsWith('#') || typed.startsWith('*')) {
|
||||
// 절대 좌표 강제 — `#x,y` · `*d<a`
|
||||
const event = parsePointText(typed.slice(1), distanceCursor);
|
||||
if (event?.type !== ActorEvent.ABSOLUTE_POINT_INPUT) return false;
|
||||
sendPoint(event);
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
@@ -49,17 +49,22 @@ import {
|
||||
parsePointText,
|
||||
type PointTextEvent,
|
||||
} from './input-patterns';
|
||||
import { isSnapName, setSnapOverride } from './input-snap-override';
|
||||
import { setSnapOverride } from './input-snap-override';
|
||||
import { tryPointModifier } from './point-modifiers';
|
||||
|
||||
// ---- 덧물음(설계 2-11) ----
|
||||
|
||||
export interface SubPrompt {
|
||||
prompt: PromptSpec;
|
||||
kind: StepKind;
|
||||
/** 상대 좌표 · 거리의 기준(FROM 간격띄우기 · M2P 둘째 점) — 없으면 지금 기준점 */
|
||||
base?: Point;
|
||||
onPoint?(p: Point): SubResult;
|
||||
onNumber?(n: number): SubResult;
|
||||
onOption?(key: string): SubResult;
|
||||
onEnter?(): SubResult;
|
||||
/** 꼴이 정해지지 않은 글(ZOOM 「2X」) — undefined = 안 먹음 · 사다리 그대로 */
|
||||
onText?(text: string): SubResult | undefined;
|
||||
}
|
||||
/** `{point}` = 닫고 그 점을 원래 물음에 ABSOLUTE_POINT_INPUT 로 */
|
||||
export type SubResult = 'stay' | 'done' | { point: Point };
|
||||
@@ -75,9 +80,11 @@ export const closeSubPrompt = (): void => {
|
||||
subs.length = 0;
|
||||
};
|
||||
|
||||
function settle(result: SubResult | undefined) {
|
||||
/** 덧물음 sub 의 답 — 'done' · {point} 면 그 덧물음만 닫음(답 안에서 다음 덧물음을 열어도 됨) */
|
||||
function settle(sub: SubPrompt, result: SubResult | undefined) {
|
||||
if (!result || result === 'stay') return;
|
||||
subs.pop();
|
||||
const at = subs.lastIndexOf(sub);
|
||||
if (at >= 0) subs.splice(at, 1);
|
||||
if (result !== 'done') sendPoint({ type: ActorEvent.ABSOLUTE_POINT_INPUT, value: result.point });
|
||||
}
|
||||
|
||||
@@ -181,21 +188,32 @@ export const distanceEvent = (value: number): PointTextEvent => ({
|
||||
export const dynamicInputApplies = (): boolean =>
|
||||
currentPrompt().kind === 'point' && !!getAngleGuideOriginPoint();
|
||||
|
||||
/** base 에서 to 쪽으로 distance 간 점 */
|
||||
const toward = (base: Point, to: Point, distance: number): Point => {
|
||||
const length = Math.hypot(to.x - base.x, to.y - base.y) || 1;
|
||||
return new Point(
|
||||
base.x + ((to.x - base.x) / length) * distance,
|
||||
base.y + ((to.y - base.y) / length) * distance
|
||||
);
|
||||
};
|
||||
|
||||
/** 점 하나 — 덧물음 먼저(마우스 누름도) · 각도 고정 · 한 번 스냅은 이 한 점으로 끝 */
|
||||
export function sendPoint(event: PointTextEvent | MouseClickEvent): void {
|
||||
const sub = subPrompt();
|
||||
if (sub) {
|
||||
const last = lastPoint();
|
||||
const base = sub.base ?? getAngleGuideOriginPoint() ?? (last && new Point(last.x, last.y));
|
||||
if (event.type === ActorEvent.NUMBER_INPUT) {
|
||||
if (sub.onNumber) settle(sub.onNumber(event.value));
|
||||
if (sub.onNumber) settle(sub, sub.onNumber(event.value));
|
||||
else if (base) settle(sub, sub.onPoint?.(toward(base, event.worldMouseLocation, event.value)));
|
||||
else reject(currentPrompt());
|
||||
} else if (event.type === ActorEvent.MOUSE_CLICK) {
|
||||
settle(sub.onPoint?.(event.worldMouseLocation));
|
||||
settle(sub, sub.onPoint?.(event.worldMouseLocation));
|
||||
} else if (event.type === ActorEvent.RELATIVE_POINT_INPUT) {
|
||||
const base = getAngleGuideOriginPoint() ?? lastPoint();
|
||||
if (base) settle(sub.onPoint?.(new Point(base.x + event.value.x, base.y + event.value.y)));
|
||||
if (base) settle(sub, sub.onPoint?.(new Point(base.x + event.value.x, base.y + event.value.y)));
|
||||
else reject(currentPrompt());
|
||||
} else {
|
||||
settle(sub.onPoint?.(event.value));
|
||||
settle(sub, sub.onPoint?.(event.value));
|
||||
}
|
||||
} else {
|
||||
getActiveToolActor()?.send(event);
|
||||
@@ -206,13 +224,13 @@ export function sendPoint(event: PointTextEvent | MouseClickEvent): void {
|
||||
|
||||
function sendOption(key: string) {
|
||||
const sub = subPrompt();
|
||||
if (sub) settle(sub.onOption?.(key));
|
||||
if (sub) settle(sub, sub.onOption?.(key));
|
||||
else send({ type: `OPTION.${key}` });
|
||||
}
|
||||
|
||||
function sendEnter() {
|
||||
const sub = subPrompt();
|
||||
if (sub) settle(sub.onEnter?.());
|
||||
if (sub) settle(sub, sub.onEnter?.());
|
||||
else send({ type: ActorEvent.ENTER });
|
||||
}
|
||||
|
||||
@@ -275,7 +293,8 @@ function routeEmpty(prompt: CurrentPrompt) {
|
||||
if (fallback?.kind === 'value') {
|
||||
const value = defaultValue(fallback);
|
||||
if (typeof value === 'number') {
|
||||
if (subPrompt()?.onNumber) settle(subPrompt()?.onNumber?.(value));
|
||||
const sub = subPrompt();
|
||||
if (sub?.onNumber) settle(sub, sub.onNumber(value));
|
||||
else sendPoint(distanceEvent(value));
|
||||
return;
|
||||
}
|
||||
@@ -316,12 +335,7 @@ function routePointAid(text: string): boolean {
|
||||
setAngleLock(Number.parseFloat(lock[1]));
|
||||
return true;
|
||||
}
|
||||
const upper = normalizeKeyword(text);
|
||||
if (isSnapName(upper)) {
|
||||
setSnapOverride(upper);
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
return tryPointModifier(text); // 스냅 이름 · FROM · M2P · @ · # · *
|
||||
}
|
||||
|
||||
/** ④ 좌표 · 거리 */
|
||||
@@ -329,6 +343,13 @@ function routePointText(text: string, prompt: CurrentPrompt) {
|
||||
const event = parsePointText(text, distanceCursor);
|
||||
if (!event) return reject(prompt);
|
||||
const hasBase = !!getAngleGuideOriginPoint() || !!subPrompt();
|
||||
if (event.type === ActorEvent.RELATIVE_POINT_INPUT && !hasBase) {
|
||||
// 기준점 없는 첫 점 `@dx,dy` = 마지막 점(LASTPOINT) 기준
|
||||
const last = lastPoint();
|
||||
if (!last) return reject(prompt);
|
||||
const value = new Point(last.x + event.value.x, last.y + event.value.y);
|
||||
return sendPoint({ type: ActorEvent.ABSOLUTE_POINT_INPUT, value });
|
||||
}
|
||||
if (event.type !== ActorEvent.ABSOLUTE_POINT_INPUT && !hasBase) return reject(prompt);
|
||||
if (event.type === ActorEvent.NUMBER_INPUT) {
|
||||
const problem = numberProblem(event.value, prompt.spec?.number);
|
||||
@@ -344,7 +365,7 @@ function routeNumber(text: string, prompt: CurrentPrompt) {
|
||||
const problem = numberProblem(value, prompt.spec?.number);
|
||||
if (problem) return reject(prompt, problem);
|
||||
const sub = subPrompt();
|
||||
if (sub) settle(sub.onNumber?.(value));
|
||||
if (sub) settle(sub, sub.onNumber?.(value));
|
||||
else send({ type: ActorEvent.NUMBER_INPUT, value, worldMouseLocation: distanceCursor() });
|
||||
}
|
||||
|
||||
@@ -358,6 +379,9 @@ export function routeTyped(raw: string): void {
|
||||
return;
|
||||
}
|
||||
if (text.startsWith("'")) return routeTransparent(text.slice(1));
|
||||
const textSub = subPrompt();
|
||||
const own = textSub?.onText?.(text);
|
||||
if (textSub && own) return settle(textSub, own);
|
||||
if (prompt.kind === 'idle') {
|
||||
// ⑨ 이름 > 내 별칭 > 기본 별칭 딱 맞음만
|
||||
const command = resolveCommandInput(text);
|
||||
@@ -365,9 +389,12 @@ export function routeTyped(raw: string): void {
|
||||
else notice(`알 수 없는 명령 「${normalizeKeyword(text)}」.`);
|
||||
return;
|
||||
}
|
||||
if (prompt.kind === 'point' && routePointAid(text)) return;
|
||||
// ⑤ 옵션(숨김 포함) · 없는 옵션 안내
|
||||
const hit = matchOption(text, prompt.spec);
|
||||
// 옵션 kw · 영문 전체와 딱 같으면 옵션 먼저(ARC 「CENTER」) · 줄임은 스냅 먼저(「CEN」 = 스냅)
|
||||
const exact =
|
||||
hit && 'option' in hit && [hit.option.kw, hit.option.en].includes(normalizeKeyword(text));
|
||||
if (prompt.kind === 'point' && !exact && routePointAid(text)) return;
|
||||
// ⑤ 옵션(숨김 포함) · 없는 옵션 안내
|
||||
if (hit && 'option' in hit) return sendOption(hit.option.key);
|
||||
if (hit && 'absent' in hit) {
|
||||
notice(`「${hit.absent.label}(${hit.absent.kw})」 옵션은 이 CAD 에 없음.`);
|
||||
|
||||
@@ -6,6 +6,11 @@ import type { AnyActorRef } from 'xstate';
|
||||
import { getActiveToolActor } from '../state.ts';
|
||||
import { Tool } from '../tools.ts';
|
||||
import { SelectState } from '../tools/select-tool.ts';
|
||||
import { setKeywordNotice } from '../tools/select-tool.helpers.ts';
|
||||
import { notice } from './prompt-input.ts';
|
||||
|
||||
// 고르기 키워드(ALL · L · P) 「n개를 찾음」 → 알림 줄
|
||||
setKeywordNotice(notice);
|
||||
|
||||
const isSelect = (actor: AnyActorRef | undefined): boolean =>
|
||||
actor?.getSnapshot()?.context?.type === Tool.SELECT;
|
||||
|
||||
@@ -0,0 +1,29 @@
|
||||
/* Shift+우클릭 한 번 스냅 메뉴(`snap-menu.ts`) — 커서 자리에 세로 목록 */
|
||||
.cad-snap-menu {
|
||||
position: fixed;
|
||||
z-index: 60;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-width: 160px;
|
||||
padding: 4px;
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: 6px;
|
||||
background: var(--color-surface-raised);
|
||||
box-shadow: var(--shadow-sm);
|
||||
}
|
||||
|
||||
.cad-snap-menu > button {
|
||||
padding: 4px 10px;
|
||||
border: 0;
|
||||
border-radius: 4px;
|
||||
background: transparent;
|
||||
color: var(--color-text);
|
||||
font-size: 13px;
|
||||
text-align: left;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.cad-snap-menu > button:hover,
|
||||
.cad-snap-menu > button:focus-visible {
|
||||
background: var(--color-surface-hover, rgba(127, 127, 127, 0.15));
|
||||
}
|
||||
@@ -0,0 +1,76 @@
|
||||
/**
|
||||
* Shift+우클릭 한 번 스냅 메뉴(337 마 · 오토캐드) — 점 물음일 때만 · 고르면 그 이름을 친 것과 같은 길(`submitText`).
|
||||
* 스냅 이름 · FROM · M2P 의 뜻은 친 글 사다리(`prompt-input.ts`) 몫 · 메뉴는 고르기만.
|
||||
*/
|
||||
import './snap-menu.css';
|
||||
|
||||
/** [친 글, 이름] — 오토캐드 객체 스냅 메뉴 차례 */
|
||||
export const SNAP_MENU: readonly [string, string][] = [
|
||||
['FROM', '시작점에서'],
|
||||
['M2P', '두 점 사이의 중간'],
|
||||
['END', '끝점'],
|
||||
['MID', '중간점'],
|
||||
['INT', '교차점'],
|
||||
['CEN', '중심'],
|
||||
['QUA', '사분점'],
|
||||
['PER', '수직'],
|
||||
['NOD', '노드'],
|
||||
['NEA', '근처점'],
|
||||
['NON', '없음'],
|
||||
];
|
||||
|
||||
let current: { onPick: (name: string) => void; element: HTMLElement | null } | null = null;
|
||||
|
||||
const onKey = (evt: KeyboardEvent) => {
|
||||
if (evt.key !== 'Escape') return;
|
||||
evt.preventDefault();
|
||||
evt.stopImmediatePropagation(); // Esc = 메뉴만 닫음(명령은 그대로)
|
||||
closeSnapMenu();
|
||||
};
|
||||
const onOutside = (evt: MouseEvent) => {
|
||||
if (!current?.element?.contains(evt.target as Node)) closeSnapMenu();
|
||||
};
|
||||
|
||||
export const isSnapMenuOpen = (): boolean => current !== null;
|
||||
|
||||
export function closeSnapMenu(): void {
|
||||
if (!current) return;
|
||||
current.element?.remove();
|
||||
current = null;
|
||||
if (typeof window === 'undefined') return;
|
||||
window.removeEventListener('keydown', onKey, true);
|
||||
window.removeEventListener('mousedown', onOutside, true);
|
||||
}
|
||||
|
||||
/** 메뉴에서 하나 고름 — 닫고 그 이름을 넘김 */
|
||||
export function pickSnapMenu(name: string): void {
|
||||
const picked = current;
|
||||
closeSnapMenu();
|
||||
picked?.onPick(name);
|
||||
}
|
||||
|
||||
/** 화면 (x, y) 에 메뉴를 띄움 — DOM 없는 시험에선 상태만 */
|
||||
export function openSnapMenu(x: number, y: number, onPick: (name: string) => void): void {
|
||||
closeSnapMenu();
|
||||
current = { onPick, element: null };
|
||||
if (typeof document === 'undefined') return;
|
||||
const menu = document.createElement('div');
|
||||
menu.className = 'cad-snap-menu controls';
|
||||
menu.setAttribute('role', 'menu');
|
||||
for (const [name, label] of SNAP_MENU) {
|
||||
const item = document.createElement('button');
|
||||
item.type = 'button';
|
||||
item.setAttribute('role', 'menuitem');
|
||||
item.textContent = `${label} (${name})`;
|
||||
item.addEventListener('click', () => pickSnapMenu(name));
|
||||
menu.append(item);
|
||||
}
|
||||
document.body.append(menu);
|
||||
// 화면 밖으로 안 나가게
|
||||
const box = menu.getBoundingClientRect();
|
||||
menu.style.left = `${Math.min(x, window.innerWidth - box.width - 4)}px`;
|
||||
menu.style.top = `${Math.min(y, window.innerHeight - box.height - 4)}px`;
|
||||
current.element = menu;
|
||||
window.addEventListener('keydown', onKey, true);
|
||||
window.addEventListener('mousedown', onOutside, true);
|
||||
}
|
||||
@@ -1,9 +1,12 @@
|
||||
/**
|
||||
* 보기 바꿈(337 마) — 범위 줌 · 앞 화면(ZOOM 이전(P)) · 휠 두 번 = 범위.
|
||||
* 보기 바꿈(337 마) — 범위 줌 · 앞 화면(ZOOM 이전(P)) · 휠 두 번 = 범위 · ZOOM 덧물음.
|
||||
* 앞 화면 기억 = ZOOM · 휠 두 번 · ⛶ 로 바꾼 것만(휠 굴림 · 팬은 안 쌓음) · 오토캐드처럼 열 개.
|
||||
* ZOOM 은 덧물음 — 도구를 안 바꿔 명령 중 `'Z` 도 같은 길(투명).
|
||||
*/
|
||||
import type { Point } from '@flatten-js/core';
|
||||
import { Point } from '@flatten-js/core';
|
||||
import type { OptionSpec, PromptSpec } from '../commands/prompt.ts';
|
||||
import { getScreenCanvasDrawController } from '../state.ts';
|
||||
import { notice, pushSubPrompt, type SubPrompt, type SubResult } from './prompt-input.ts';
|
||||
|
||||
type View = { scale: number; offset: Point };
|
||||
|
||||
@@ -46,3 +49,110 @@ export function isMiddleDouble(point: { x: number; y: number }, time = Date.now(
|
||||
lastMiddle = double ? null : { time, x: point.x, y: point.y };
|
||||
return double;
|
||||
}
|
||||
|
||||
/** 배율만 바꿈 — 화면 가운데 자리를 붙잡음 */
|
||||
function setScaleAboutCenter(scale: number): void {
|
||||
const controller = getScreenCanvasDrawController();
|
||||
const size = controller.getCanvasSize();
|
||||
const offset = controller.getScreenOffset();
|
||||
const old = controller.getScreenScale();
|
||||
const cx = offset.x + size.x / 2 / old;
|
||||
const cy = offset.y + size.y / 2 / old;
|
||||
controller.setScreenScale(scale);
|
||||
controller.setScreenOffset(new Point(cx - size.x / 2 / scale, cy - size.y / 2 / scale));
|
||||
}
|
||||
|
||||
/** 윈도우 줌 — 두 구석 상자를 화면에 꽉(종횡비 유지) */
|
||||
export function zoomWindow(a: Point, b: Point): void {
|
||||
const controller = getScreenCanvasDrawController();
|
||||
const size = controller.getCanvasSize();
|
||||
const width = Math.abs(a.x - b.x);
|
||||
const height = Math.abs(a.y - b.y);
|
||||
const scale = Math.min(
|
||||
width > 0 ? size.x / width : Number.POSITIVE_INFINITY,
|
||||
height > 0 ? size.y / height : Number.POSITIVE_INFINITY
|
||||
);
|
||||
if (!Number.isFinite(scale)) return; // 같은 점 두 번 — 안 바꿈
|
||||
const cx = (a.x + b.x) / 2;
|
||||
const cy = (a.y + b.y) / 2;
|
||||
changeView(() => {
|
||||
controller.setScreenScale(scale);
|
||||
controller.setScreenOffset(new Point(cx - size.x / 2 / scale, cy - size.y / 2 / scale));
|
||||
});
|
||||
}
|
||||
|
||||
const zoomOption = (key: string, kw: string, label: string): OptionSpec => ({
|
||||
key,
|
||||
kw,
|
||||
en: key,
|
||||
label,
|
||||
});
|
||||
|
||||
/** 오토캐드 ZOOM 첫 줄 — 넣은 넷 · 뺀 넷 · <실시간> */
|
||||
const ZOOM_PROMPT: PromptSpec = {
|
||||
text: '윈도우 구석 지정, 축척 비율 (nX 또는 nXP) 입력',
|
||||
options: [
|
||||
zoomOption('ALL', 'A', '전체'),
|
||||
zoomOption('EXTENTS', 'E', '범위'),
|
||||
zoomOption('PREVIOUS', 'P', '이전'),
|
||||
zoomOption('WINDOW', 'W', '윈도우'),
|
||||
],
|
||||
absent: [
|
||||
zoomOption('CENTER', 'C', '중심'),
|
||||
zoomOption('DYNAMIC', 'D', '동적'),
|
||||
zoomOption('SCALE', 'S', '축척'),
|
||||
zoomOption('OBJECT', 'O', '객체'),
|
||||
],
|
||||
default: { kind: 'enter', label: '실시간' },
|
||||
};
|
||||
const FIRST_CORNER: PromptSpec = { text: '첫 번째 구석 지정' };
|
||||
const OTHER_CORNER: PromptSpec = { text: '반대 구석 지정' };
|
||||
const SCALE_REGEXP = /^(\d*\.?\d+)\s*XP?$/i;
|
||||
|
||||
/** ZOOM — 덧물음 하나가 단계(첫 줄 → 첫 구석 → 반대 구석)를 품음 */
|
||||
export function startZoomPrompt(): void {
|
||||
let stage: 'main' | 'first' | 'other' = 'main';
|
||||
let corner: Point | null = null;
|
||||
const sub: SubPrompt = {
|
||||
kind: 'point',
|
||||
get prompt() {
|
||||
return stage === 'main' ? ZOOM_PROMPT : stage === 'first' ? FIRST_CORNER : OTHER_CORNER;
|
||||
},
|
||||
onPoint(point): SubResult {
|
||||
if (stage === 'other' && corner) {
|
||||
zoomWindow(corner, point);
|
||||
return 'done';
|
||||
}
|
||||
corner = point; // 첫 줄에서 누름 = 윈도우 첫 구석(오토캐드)
|
||||
stage = 'other';
|
||||
return 'stay';
|
||||
},
|
||||
onOption(key): SubResult {
|
||||
if (key === 'WINDOW') {
|
||||
stage = 'first';
|
||||
return 'stay';
|
||||
}
|
||||
if (key === 'PREVIOUS') {
|
||||
if (!zoomPrevious()) notice('앞 화면이 없음.');
|
||||
return 'done';
|
||||
}
|
||||
zoomExtents(); // 전체(A) = 범위(이 CAD 엔 도면 한계 없음)
|
||||
return 'done';
|
||||
},
|
||||
onEnter(): SubResult {
|
||||
if (stage !== 'main') return 'stay';
|
||||
notice('실시간 줌은 휠로.');
|
||||
return 'done';
|
||||
},
|
||||
onText(text) {
|
||||
const match = stage === 'main' ? SCALE_REGEXP.exec(text) : null;
|
||||
if (!match) return undefined;
|
||||
const factor = Number.parseFloat(match[1]);
|
||||
if (factor <= 0) return undefined;
|
||||
const scale = getScreenCanvasDrawController().getScreenScale() * factor;
|
||||
changeView(() => setScaleAboutCenter(scale));
|
||||
return 'done';
|
||||
},
|
||||
};
|
||||
pushSubPrompt(sub);
|
||||
}
|
||||
|
||||
@@ -1,6 +1,12 @@
|
||||
/** 반지름·지름·각도·호길이·세로좌표 치수와 중심 표식 (조사표 5절) */
|
||||
/**
|
||||
* 반지름·지름·각도·호길이·세로좌표 치수와 중심 표식 (조사표 5절).
|
||||
* 오토캐드 물음 차례(337 설계 1-2) — DIMRADIUS · DIMDIAMETER = 「호 또는 원 선택:」 →
|
||||
* 「치수선의 위치 지정 또는 [문자(T)]:」 · 한 번 뒤 끝 · 그 밖 치수 = 물음 글만.
|
||||
*/
|
||||
import { Arc, Circle, Point } from '@flatten-js/core';
|
||||
import { toast } from 'react-toastify';
|
||||
import { OPT, type OptionSpec } from '../../commands/prompt';
|
||||
import { logLine } from '../../commands/run-command';
|
||||
import { getDimDecimals } from '../../commands/dim-settings';
|
||||
import type { Entity } from '../../entities/Entity';
|
||||
import { MeasurementEntity } from '../../entities/MeasurementEntity';
|
||||
@@ -8,7 +14,7 @@ import { polylineLength, sampleEntityPoints } from '../../helpers/geometry/sampl
|
||||
import { addEntities, getEntities, setEntities, setSelectedEntityIds } from '../../state';
|
||||
import { Tool } from '../../tools';
|
||||
import { arcEntity, lineEntity } from '../factories/entity-factory';
|
||||
import { createSequenceTool } from '../factories/sequence-tool';
|
||||
import { createSequenceTool, type SequenceInput } from '../factories/sequence-tool';
|
||||
import { annotationText, arrowHead, dimensionText } from './annotation.helpers';
|
||||
|
||||
interface CircularShape {
|
||||
@@ -24,7 +30,24 @@ function circularOf(entity: Entity): CircularShape | null {
|
||||
return null;
|
||||
}
|
||||
|
||||
function radialDimension(entity: Entity, textPoint: Point, diameter: boolean): Entity[] | null {
|
||||
/** 잰 값 글(「R10.00」 · 「Ø20.00」) */
|
||||
function radialLabel(entity: Entity, diameter: boolean): string | null {
|
||||
const circular = circularOf(entity);
|
||||
if (!circular) return null;
|
||||
const value = diameter ? circular.radius * 2 : circular.radius;
|
||||
return `${diameter ? 'Ø' : 'R'}${value.toFixed(getDimDecimals())}`;
|
||||
}
|
||||
|
||||
/** 바꾼 글(`<>` = 잰 값) · 없으면 잰 값 */
|
||||
const labelWith = (measured: string, text?: string) =>
|
||||
text && text !== '<>' ? text.replaceAll('<>', measured) : measured;
|
||||
|
||||
function radialDimension(
|
||||
entity: Entity,
|
||||
textPoint: Point,
|
||||
diameter: boolean,
|
||||
text?: string
|
||||
): Entity[] | null {
|
||||
const circular = circularOf(entity);
|
||||
if (!circular) return null;
|
||||
|
||||
@@ -43,52 +66,71 @@ function radialDimension(entity: Entity, textPoint: Point, diameter: boolean): E
|
||||
return [
|
||||
lineEntity(start, textPoint),
|
||||
arrowHead(edge, circular.center),
|
||||
dimensionText(
|
||||
diameter ? circular.radius * 2 : circular.radius,
|
||||
textPoint,
|
||||
diameter ? 'Ø' : 'R'
|
||||
),
|
||||
text && text !== '<>'
|
||||
? annotationText(labelWith(radialLabel(entity, diameter) as string, text), textPoint)
|
||||
: dimensionText(
|
||||
diameter ? circular.radius * 2 : circular.radius,
|
||||
textPoint,
|
||||
diameter ? 'Ø' : 'R'
|
||||
),
|
||||
];
|
||||
}
|
||||
|
||||
export const dimRadiusToolStateMachine = createSequenceTool({
|
||||
tool: Tool.DIMRADIUS,
|
||||
steps: [
|
||||
{ kind: 'entity', instructions: '반지름을 기입할 원 또는 호를 선택하십시오.' },
|
||||
{ kind: 'point', instructions: '치수 문자 위치를 지정하십시오.' },
|
||||
],
|
||||
commit: (input) => {
|
||||
const parts = radialDimension(input.entity(0), input.point(1), false);
|
||||
if (!parts) {
|
||||
toast.warn('원 또는 호를 선택하십시오.');
|
||||
return;
|
||||
}
|
||||
addEntities(parts, true);
|
||||
},
|
||||
});
|
||||
const MTEXT: OptionSpec = { key: 'MTEXT', kw: 'M', en: 'MTEXT', label: '여러 줄 문자' };
|
||||
const ANGLE: OptionSpec = { key: 'ANGLE', kw: 'A', en: 'ANGLE', label: '각도' };
|
||||
|
||||
export const dimDiameterToolStateMachine = createSequenceTool({
|
||||
tool: Tool.DIMDIAMETER,
|
||||
steps: [
|
||||
{ kind: 'entity', instructions: '지름을 기입할 원 또는 호를 선택하십시오.' },
|
||||
{ kind: 'point', instructions: '치수 문자 위치를 지정하십시오.' },
|
||||
],
|
||||
commit: (input) => {
|
||||
const parts = radialDimension(input.entity(0), input.point(1), true);
|
||||
if (!parts) {
|
||||
toast.warn('원 또는 호를 선택하십시오.');
|
||||
return;
|
||||
}
|
||||
addEntities(parts, true);
|
||||
},
|
||||
});
|
||||
/** DIMRADIUS · DIMDIAMETER — 원 · 호 → [문자(T)] → 위치 */
|
||||
function radialTool(tool: Tool, diameter: boolean) {
|
||||
const measured = (input: SequenceInput) =>
|
||||
input.has('obj') ? radialLabel(input.get<Entity>('obj'), diameter) : null;
|
||||
return createSequenceTool({
|
||||
tool,
|
||||
steps: [
|
||||
{ kind: 'entity', id: 'obj', prompt: { text: '호 또는 원 선택' } },
|
||||
{
|
||||
kind: 'text',
|
||||
id: 'txt',
|
||||
when: (input) => input.mode === 'text',
|
||||
next: 'place',
|
||||
defaultValue: '<>',
|
||||
prompt: (input) => {
|
||||
const value = measured(input);
|
||||
return {
|
||||
text: '치수 문자 입력',
|
||||
...(value === null ? {} : { default: { kind: 'enter' as const, label: value } }),
|
||||
};
|
||||
},
|
||||
},
|
||||
{
|
||||
kind: 'point',
|
||||
id: 'place',
|
||||
prompt: { text: '치수선의 위치 지정', options: [OPT.TEXT], absent: [MTEXT, ANGLE] },
|
||||
options: { TEXT: { mode: 'text', next: 'txt' } },
|
||||
},
|
||||
],
|
||||
commit: (input) => {
|
||||
const entity = input.get<Entity>('obj');
|
||||
const text = input.has('txt') ? input.get<string>('txt') : undefined;
|
||||
const parts = radialDimension(entity, input.get<Point>('place'), diameter, text);
|
||||
if (!parts) {
|
||||
toast.warn('원 또는 호를 선택하십시오.');
|
||||
return;
|
||||
}
|
||||
addEntities(parts, true);
|
||||
logLine(`치수 문자 = ${labelWith(radialLabel(entity, diameter) as string, text)}`);
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export const dimRadiusToolStateMachine = radialTool(Tool.DIMRADIUS, false);
|
||||
export const dimDiameterToolStateMachine = radialTool(Tool.DIMDIAMETER, true);
|
||||
|
||||
export const dimAngularToolStateMachine = createSequenceTool({
|
||||
tool: Tool.DIMANGULAR,
|
||||
steps: [
|
||||
{ kind: 'point', instructions: '각의 꼭짓점을 지정하십시오.' },
|
||||
{ kind: 'point', instructions: '첫 번째 변 위의 점을 지정하십시오.' },
|
||||
{ kind: 'point', instructions: '두 번째 변 위의 점을 지정하십시오.' },
|
||||
{ kind: 'point', prompt: { text: '각도 정점 지정' } },
|
||||
{ kind: 'point', prompt: { text: '첫 번째 각도 끝점 지정' } },
|
||||
{ kind: 'point', prompt: { text: '두 번째 각도 끝점 지정' } },
|
||||
],
|
||||
commit: (input) => {
|
||||
const [vertex, first, second] = input.points();
|
||||
@@ -128,8 +170,8 @@ export const dimAngularToolStateMachine = createSequenceTool({
|
||||
export const dimArcToolStateMachine = createSequenceTool({
|
||||
tool: Tool.DIMARC,
|
||||
steps: [
|
||||
{ kind: 'entity', instructions: '호 길이를 기입할 호를 선택하십시오.' },
|
||||
{ kind: 'point', instructions: '치수 문자 위치를 지정하십시오.' },
|
||||
{ kind: 'entity', prompt: { text: '호 또는 폴리선 호 세그먼트 선택' } },
|
||||
{ kind: 'point', prompt: { text: '호 길이 치수 위치 지정' } },
|
||||
],
|
||||
commit: (input) => {
|
||||
const entity = input.entity(0);
|
||||
@@ -146,8 +188,8 @@ export const dimArcToolStateMachine = createSequenceTool({
|
||||
export const dimOrdinateToolStateMachine = createSequenceTool({
|
||||
tool: Tool.DIMORDINATE,
|
||||
steps: [
|
||||
{ kind: 'point', instructions: '좌표를 기입할 피처 위치를 지정하십시오.' },
|
||||
{ kind: 'point', instructions: '지시선 끝점을 지정하십시오.' },
|
||||
{ kind: 'point', prompt: { text: '피쳐 위치 지정' } },
|
||||
{ kind: 'point', prompt: { text: '지시선 끝점 지정' } },
|
||||
],
|
||||
commit: (input) => {
|
||||
const [feature, leaderEnd] = input.points();
|
||||
|
||||
@@ -1,27 +1,55 @@
|
||||
/** 선형 치수 계열 — 선형·정렬·기준선·연속·빠른 치수·자동 치수 (조사표 5절) */
|
||||
/**
|
||||
* 선형 치수 계열 — 선형·정렬·기준선·연속·빠른 치수·자동 치수 (조사표 5절).
|
||||
* 오토캐드 물음 차례(337 설계 1-2) — DIMLINEAR · DIMALIGNED = 원점 둘(빈 Enter = 객체 선택) → 치수선 위치
|
||||
* [문자(T)/수평(H)/수직(V)] · 한 번 뒤 끝 · DIMCONTINUE · DIMBASELINE = 원점 되풀이 [명령 취소(U)] · 빈 Enter 끝.
|
||||
*/
|
||||
import { Circle, Point } from '@flatten-js/core';
|
||||
import { toast } from 'react-toastify';
|
||||
import { EntityName } from '../../entities/Entity';
|
||||
import { assign, createMachine } from 'xstate';
|
||||
import { type OptionSpec, type PromptSpec, OPT, SELECT_OBJECTS } from '../../commands/prompt';
|
||||
import { logLine } from '../../commands/run-command';
|
||||
import { EntityName, type Entity } from '../../entities/Entity';
|
||||
import { MeasurementEntity } from '../../entities/MeasurementEntity';
|
||||
import type { PolyLineEntity } from '../../entities/PolyLineEntity';
|
||||
import { sampleEntityPoints } from '../../helpers/geometry/sample-entity';
|
||||
import { addEntities, getActiveLayerId, setSelectedEntityIds } from '../../state';
|
||||
import { getPointFromEvent } from '../../helpers/get-point-from-event';
|
||||
import { notice } from '../../inputController/prompt-input';
|
||||
import {
|
||||
addEntities,
|
||||
getActiveLayerId,
|
||||
setAngleGuideOriginPoint,
|
||||
setSelectedEntityIds,
|
||||
setShouldDrawHelpers,
|
||||
undo,
|
||||
} from '../../state';
|
||||
import { Tool } from '../../tools';
|
||||
import { styled } from '../factories/entity-factory';
|
||||
import { createSequenceTool } from '../factories/sequence-tool';
|
||||
import { createSequenceTool, type SequenceInput } from '../factories/sequence-tool';
|
||||
import { END_STATE, type PointInputEvent, type StateEvent, type ToolContext } from '../tool.types';
|
||||
import { getLastDimension, rememberDimension } from './annotation.helpers';
|
||||
|
||||
/** 치수 객체 하나 만들기 — 현재 선 특성을 입히고 직전 치수로 기억한다 */
|
||||
function makeDimension(start: Point, end: Point, offset: Point): MeasurementEntity {
|
||||
function makeDimension(start: Point, end: Point, offset: Point, text?: string): MeasurementEntity {
|
||||
const dimension = styled(new MeasurementEntity(getActiveLayerId(), start, end, offset));
|
||||
if (text && text !== '<>') dimension.textOverride = text;
|
||||
rememberDimension(dimension);
|
||||
return dimension;
|
||||
}
|
||||
|
||||
type Fixed = 'H' | 'V' | null;
|
||||
|
||||
/**
|
||||
* 선형 치수 — 치수선을 놓은 방향으로 수평/수직을 고른다.
|
||||
* 선형 치수 — 수평(H) · 수직(V) 고정이면 그 방향, 아니면 치수선을 놓은 방향으로 고른다.
|
||||
* 위·아래에 놓으면 가로 거리, 좌·우에 놓으면 세로 거리를 잰다 (AutoCAD와 같다).
|
||||
*/
|
||||
function projectForLinear(start: Point, end: Point, offset: Point): [Point, Point] {
|
||||
function projectForLinear(
|
||||
start: Point,
|
||||
end: Point,
|
||||
offset: Point,
|
||||
fixed: Fixed = null
|
||||
): [Point, Point] {
|
||||
if (fixed === 'H') return [start, new Point(end.x, start.y)];
|
||||
if (fixed === 'V') return [start, new Point(start.x, end.y)];
|
||||
const horizontalSpan = Math.abs(end.x - start.x);
|
||||
const verticalSpan = Math.abs(end.y - start.y);
|
||||
const offsetIsVertical =
|
||||
@@ -32,89 +60,268 @@ function projectForLinear(start: Point, end: Point, offset: Point): [Point, Poin
|
||||
return [start, new Point(start.x, end.y)];
|
||||
}
|
||||
|
||||
export const dimLinearToolStateMachine = createSequenceTool({
|
||||
tool: Tool.DIMLINEAR,
|
||||
steps: [
|
||||
{ kind: 'point', instructions: '첫 번째 치수보조선 원점을 지정하십시오.' },
|
||||
{ kind: 'point', instructions: '두 번째 치수보조선 원점을 지정하십시오.' },
|
||||
{ kind: 'point', instructions: '치수선 위치를 지정하십시오.' },
|
||||
],
|
||||
preview: (input) => {
|
||||
const points = input.points();
|
||||
if (points.length !== 2) return [];
|
||||
const [start, end] = projectForLinear(points[0], points[1], input.cursor);
|
||||
return [new MeasurementEntity(getActiveLayerId(), start, end, input.cursor)];
|
||||
},
|
||||
commit: (input) => {
|
||||
const [first, second, offset] = input.points();
|
||||
const [start, end] = projectForLinear(first, second, offset);
|
||||
addEntities([makeDimension(start, end, offset)], true);
|
||||
},
|
||||
});
|
||||
|
||||
export const dimAlignedToolStateMachine = createSequenceTool({
|
||||
tool: Tool.DIMALIGNED,
|
||||
steps: [
|
||||
{ kind: 'point', instructions: '첫 번째 치수보조선 원점을 지정하십시오.' },
|
||||
{ kind: 'point', instructions: '두 번째 치수보조선 원점을 지정하십시오.' },
|
||||
{ kind: 'point', instructions: '치수선 위치를 지정하십시오.' },
|
||||
],
|
||||
preview: (input) => {
|
||||
const points = input.points();
|
||||
if (points.length !== 2) return [];
|
||||
return [new MeasurementEntity(getActiveLayerId(), points[0], points[1], input.cursor)];
|
||||
},
|
||||
commit: (input) => {
|
||||
const [start, end, offset] = input.points();
|
||||
addEntities([makeDimension(start, end, offset)], true);
|
||||
},
|
||||
});
|
||||
|
||||
export const dimBaselineToolStateMachine = createSequenceTool({
|
||||
tool: Tool.DIMBASELINE,
|
||||
steps: [{ kind: 'point', instructions: '다음 치수보조선 원점을 지정하십시오.' }],
|
||||
commit: (input) => {
|
||||
const previous = getLastDimension();
|
||||
if (!previous) {
|
||||
toast.warn('먼저 치수를 하나 작성하십시오.');
|
||||
return;
|
||||
}
|
||||
const start = previous.getStartPoint();
|
||||
const offset = previous.getOffsetPoint();
|
||||
// 기준선 치수는 같은 시작점에서 재고, 치수선을 한 칸 더 띄운다
|
||||
const spacing = offset.distanceTo(start)[0] * 0.35 || 10;
|
||||
const direction = new Point(offset.x - start.x, offset.y - start.y);
|
||||
const length = Math.hypot(direction.x, direction.y) || 1;
|
||||
const nextOffset = new Point(
|
||||
offset.x + (direction.x / length) * spacing,
|
||||
offset.y + (direction.y / length) * spacing
|
||||
/** 객체 선택 — 선 · 폴리선(누른 토막) · 그 밖은 처음 · 끝 점 */
|
||||
function endsOf(entity: Entity, pick: Point): [Point, Point] | null {
|
||||
let target = entity;
|
||||
if (entity.getType() === EntityName.PolyLine) {
|
||||
const pieces = (entity as PolyLineEntity).getEntities();
|
||||
target = pieces.reduce(
|
||||
(best, piece) =>
|
||||
(piece.getShape()?.distanceTo(pick)[0] ?? Number.POSITIVE_INFINITY) <
|
||||
(best.getShape()?.distanceTo(pick)[0] ?? Number.POSITIVE_INFINITY)
|
||||
? piece
|
||||
: best,
|
||||
pieces[0] ?? entity
|
||||
);
|
||||
addEntities([makeDimension(start, input.point(0), nextOffset)], true);
|
||||
},
|
||||
});
|
||||
}
|
||||
const points = sampleEntityPoints(target);
|
||||
if (points.length < 2) return null;
|
||||
return [points[0], points[points.length - 1]];
|
||||
}
|
||||
|
||||
export const dimContinueToolStateMachine = createSequenceTool({
|
||||
tool: Tool.DIMCONTINUE,
|
||||
steps: [{ kind: 'point', instructions: '다음 치수보조선 원점을 지정하십시오.' }],
|
||||
commit: (input) => {
|
||||
const previous = getLastDimension();
|
||||
if (!previous) {
|
||||
toast.warn('먼저 치수를 하나 작성하십시오.');
|
||||
return;
|
||||
}
|
||||
addEntities(
|
||||
[makeDimension(previous.getEndPoint(), input.point(0), previous.getOffsetPoint())],
|
||||
true
|
||||
);
|
||||
},
|
||||
const option = (key: string, kw: string, label: string): OptionSpec => ({
|
||||
key,
|
||||
kw,
|
||||
en: key,
|
||||
label,
|
||||
});
|
||||
/** 빈 Enter = 객체 선택 — 숨은 옵션(그림은 <객체 선택>) */
|
||||
const OBJECT = { ...option('OBJECT', 'OBJECT', '객체 선택'), hidden: true as const };
|
||||
const HORIZONTAL = option('HORIZONTAL', 'H', '수평');
|
||||
const VERTICAL = option('VERTICAL', 'V', '수직');
|
||||
const MTEXT = option('MTEXT', 'M', '여러 줄 문자');
|
||||
const ANGLE = option('ANGLE', 'A', '각도');
|
||||
const ROTATED = option('ROTATED', 'R', '회전됨');
|
||||
const SELECT = option('SELECT', 'S', '선택');
|
||||
|
||||
/** 원점 둘 또는 객체 끝 */
|
||||
function originsOf(input: SequenceInput): [Point, Point] | null {
|
||||
if (input.has('obj')) {
|
||||
const entity = input.get<Entity>('obj');
|
||||
return endsOf(entity, input.pick(input.values.indexOf(entity)));
|
||||
}
|
||||
if (!input.has('p2')) return null;
|
||||
return [input.get<Point>('p1'), input.get<Point>('p2')];
|
||||
}
|
||||
|
||||
/** 수평 · 수직 고정 — 명령 하나 동안(확정 · Esc 에서 풂) */
|
||||
let fixed: Fixed = null;
|
||||
|
||||
/** DIMLINEAR · DIMALIGNED 공통 물음 */
|
||||
function linearSteps(tool: Tool, aligned: boolean) {
|
||||
const measure = (input: SequenceInput, offset: Point): [Point, Point] | null => {
|
||||
const origins = originsOf(input);
|
||||
if (!origins) return null;
|
||||
return aligned ? origins : projectForLinear(origins[0], origins[1], offset, fixed);
|
||||
};
|
||||
const measured = (input: SequenceInput): string | null => {
|
||||
const origins = originsOf(input);
|
||||
if (!origins) return null;
|
||||
const [a, b] = origins;
|
||||
const wide = Math.abs(b.x - a.x) >= Math.abs(b.y - a.y);
|
||||
const [start, end] = aligned ? origins : projectForLinear(a, b, b, fixed ?? (wide ? 'H' : 'V'));
|
||||
return new MeasurementEntity(getActiveLayerId(), start, end, end).getMeasuredText();
|
||||
};
|
||||
return createSequenceTool({
|
||||
tool,
|
||||
steps: [
|
||||
{
|
||||
kind: 'point',
|
||||
id: 'p1',
|
||||
prompt: {
|
||||
text: '첫 번째 치수보조선 원점 지정 또는',
|
||||
options: [OBJECT],
|
||||
default: { kind: 'option', key: 'OBJECT', label: '객체 선택' },
|
||||
},
|
||||
options: { OBJECT: { mode: 'object', next: 'obj' } },
|
||||
},
|
||||
{
|
||||
kind: 'entity',
|
||||
id: 'obj',
|
||||
when: (input) => input.mode === 'object' && !input.has('obj'),
|
||||
next: 'place',
|
||||
prompt: { text: '치수 기입할 객체 선택' },
|
||||
},
|
||||
{
|
||||
kind: 'point',
|
||||
id: 'p2',
|
||||
when: (input) => !input.has('obj'),
|
||||
prompt: { text: '두 번째 치수보조선 원점 지정' },
|
||||
},
|
||||
{
|
||||
kind: 'text',
|
||||
id: 'txt',
|
||||
when: (input) => input.mode === 'text',
|
||||
next: 'place',
|
||||
defaultValue: '<>',
|
||||
prompt: (input) => {
|
||||
const value = measured(input);
|
||||
return {
|
||||
text: '치수 문자 입력',
|
||||
...(value === null ? {} : { default: { kind: 'enter' as const, label: value } }),
|
||||
};
|
||||
},
|
||||
},
|
||||
{
|
||||
kind: 'point',
|
||||
id: 'place',
|
||||
prompt: {
|
||||
text: '치수선의 위치 지정',
|
||||
options: aligned ? [OPT.TEXT] : [OPT.TEXT, HORIZONTAL, VERTICAL],
|
||||
absent: aligned ? [MTEXT, ANGLE] : [MTEXT, ANGLE, ROTATED],
|
||||
},
|
||||
options: {
|
||||
TEXT: { mode: 'text', next: 'txt' },
|
||||
...(aligned
|
||||
? {}
|
||||
: {
|
||||
HORIZONTAL: {
|
||||
run: () => {
|
||||
fixed = 'H';
|
||||
},
|
||||
},
|
||||
VERTICAL: {
|
||||
run: () => {
|
||||
fixed = 'V';
|
||||
},
|
||||
},
|
||||
}),
|
||||
},
|
||||
},
|
||||
],
|
||||
preview: (input) => {
|
||||
if (input.has('place') || !originsOf(input)) return [];
|
||||
const span = measure(input, input.cursor);
|
||||
if (!span) return [];
|
||||
const ghost = new MeasurementEntity(getActiveLayerId(), span[0], span[1], input.cursor);
|
||||
if (input.has('txt')) ghost.textOverride = input.get<string>('txt');
|
||||
return [ghost];
|
||||
},
|
||||
commit: (input) => {
|
||||
const offset = input.get<Point>('place');
|
||||
const span = measure(input, offset);
|
||||
fixed = null;
|
||||
if (!span) {
|
||||
toast.warn('치수를 넣을 수 없는 객체입니다.');
|
||||
return;
|
||||
}
|
||||
const dimension = makeDimension(
|
||||
span[0],
|
||||
span[1],
|
||||
offset,
|
||||
input.has('txt') ? input.get<string>('txt') : undefined
|
||||
);
|
||||
addEntities([dimension], true);
|
||||
logLine(`치수 문자 = ${dimension.getLabel()}`);
|
||||
},
|
||||
onCancel: () => {
|
||||
fixed = null;
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export const dimLinearToolStateMachine = linearSteps(Tool.DIMLINEAR, false);
|
||||
export const dimAlignedToolStateMachine = linearSteps(Tool.DIMALIGNED, true);
|
||||
|
||||
/** 기준선 치수는 같은 시작점에서 재고, 치수선을 한 칸 더 띄운다 */
|
||||
function baselineDimension(previous: MeasurementEntity, point: Point): MeasurementEntity {
|
||||
const start = previous.getStartPoint();
|
||||
const offset = previous.getOffsetPoint();
|
||||
const spacing = offset.distanceTo(start)[0] * 0.35 || 10;
|
||||
const direction = new Point(offset.x - start.x, offset.y - start.y);
|
||||
const length = Math.hypot(direction.x, direction.y) || 1;
|
||||
const nextOffset = new Point(
|
||||
offset.x + (direction.x / length) * spacing,
|
||||
offset.y + (direction.y / length) * spacing
|
||||
);
|
||||
return makeDimension(start, point, nextOffset);
|
||||
}
|
||||
|
||||
const continueDimension = (previous: MeasurementEntity, point: Point): MeasurementEntity =>
|
||||
makeDimension(previous.getEndPoint(), point, previous.getOffsetPoint());
|
||||
|
||||
interface ChainContext extends ToolContext {
|
||||
/** 명령 시작 때 직전 치수 · 이 명령이 만든 치수(U 가 뒤에서 뺌) */
|
||||
first: MeasurementEntity | null;
|
||||
made: MeasurementEntity[];
|
||||
}
|
||||
type ChainCtx = { context: ChainContext; event: StateEvent };
|
||||
|
||||
const CHAIN_PROMPT: PromptSpec = {
|
||||
text: '두 번째 치수보조선 원점 지정',
|
||||
options: [OPT.UNDO],
|
||||
absent: [SELECT],
|
||||
};
|
||||
|
||||
/** DIMCONTINUE · DIMBASELINE — 앞 치수에 이어 원점 되풀이 · U = 이 명령 안 마지막 치수 · 빈 Enter · Esc = 끝 */
|
||||
function chainTool(
|
||||
tool: Tool,
|
||||
build: (previous: MeasurementEntity, point: Point) => MeasurementEntity
|
||||
) {
|
||||
const base = (context: ChainContext) => context.made[context.made.length - 1] ?? context.first;
|
||||
const hasBase = ({ context }: ChainCtx) => !!base(context);
|
||||
const place = {
|
||||
guard: hasBase,
|
||||
actions: assign(({ context, event }: ChainCtx) => {
|
||||
const previous = base(context) as MeasurementEntity;
|
||||
const point = getPointFromEvent(previous.getEndPoint(), event as PointInputEvent);
|
||||
const dimension = build(previous, point);
|
||||
addEntities([dimension], true);
|
||||
logLine(`치수 문자 = ${dimension.getLabel()}`);
|
||||
setAngleGuideOriginPoint(dimension.getEndPoint());
|
||||
return { made: [...context.made, dimension] };
|
||||
}),
|
||||
};
|
||||
const noBase = {
|
||||
target: END_STATE,
|
||||
actions: () => notice('먼저 치수를 하나 작성하십시오.'),
|
||||
};
|
||||
return createMachine({
|
||||
types: {} as { context: ChainContext; events: StateEvent },
|
||||
context: () => ({ type: tool, first: getLastDimension(), made: [] }),
|
||||
initial: 'PICK',
|
||||
states: {
|
||||
PICK: {
|
||||
meta: { kind: 'point', prompt: CHAIN_PROMPT },
|
||||
entry: ({ context }: ChainCtx) => {
|
||||
setShouldDrawHelpers(true);
|
||||
setAngleGuideOriginPoint(base(context)?.getEndPoint() ?? null);
|
||||
},
|
||||
on: {
|
||||
MOUSE_CLICK: [place, noBase],
|
||||
ABSOLUTE_POINT_INPUT: [place, noBase],
|
||||
RELATIVE_POINT_INPUT: [place, noBase],
|
||||
NUMBER_INPUT: [place, noBase],
|
||||
'OPTION.UNDO': {
|
||||
actions: assign(({ context }: ChainCtx) => {
|
||||
if (!context.made.length) return {};
|
||||
undo();
|
||||
const made = context.made.slice(0, -1);
|
||||
const previous = made[made.length - 1] ?? context.first;
|
||||
if (previous) rememberDimension(previous);
|
||||
setAngleGuideOriginPoint(previous?.getEndPoint() ?? null);
|
||||
return { made };
|
||||
}),
|
||||
},
|
||||
ENTER: END_STATE,
|
||||
ESC: END_STATE,
|
||||
},
|
||||
},
|
||||
[END_STATE]: { type: 'final' },
|
||||
// biome-ignore lint/suspicious/noExplicitAny: 손으로 짠 상태 — 사건마다 assign 꼴이 달라 풀어 둠(sequence-tool 과 같음)
|
||||
} as Record<string, any>,
|
||||
});
|
||||
}
|
||||
|
||||
export const dimBaselineToolStateMachine = chainTool(Tool.DIMBASELINE, baselineDimension);
|
||||
export const dimContinueToolStateMachine = chainTool(Tool.DIMCONTINUE, continueDimension);
|
||||
|
||||
export const qDimToolStateMachine = createSequenceTool({
|
||||
tool: Tool.QDIM,
|
||||
helpers: false,
|
||||
steps: [
|
||||
{ kind: 'selection', instructions: '치수를 넣을 객체를 선택한 뒤 ENTER.' },
|
||||
{ kind: 'point', instructions: '치수선 위치를 지정하십시오.' },
|
||||
{ kind: 'selection', prompt: { ...SELECT_OBJECTS, text: '치수 기입할 형상 선택' } },
|
||||
{ kind: 'point', prompt: { text: '치수선의 위치 지정' } },
|
||||
],
|
||||
commit: (input) => {
|
||||
const offset = input.point(1);
|
||||
@@ -140,8 +347,8 @@ export const dimAutoToolStateMachine = createSequenceTool({
|
||||
tool: Tool.DIM,
|
||||
helpers: false,
|
||||
steps: [
|
||||
{ kind: 'entity', instructions: '치수를 넣을 객체를 선택하십시오.' },
|
||||
{ kind: 'point', instructions: '치수선 위치를 지정하십시오.' },
|
||||
{ kind: 'entity', prompt: { text: '치수 기입할 객체 선택' } },
|
||||
{ kind: 'point', prompt: { text: '치수선의 위치 지정' } },
|
||||
],
|
||||
commit: (input) => {
|
||||
const entity = input.entity(0);
|
||||
|
||||
@@ -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);
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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,5 +1,9 @@
|
||||
/** 등분(DIVIDE)·길이분할(MEASURE) — 객체를 자르지 않고 점만 놓는다 */
|
||||
/**
|
||||
* 등분(DIVIDE)·길이분할(MEASURE) — 객체를 자르지 않고 점만 놓는다.
|
||||
* 오토캐드 물음 차례(337 설계 1-2) · 기본값 없음 · 한 번 뒤 끝 · 블록(B) 없음.
|
||||
*/
|
||||
import { toast } from 'react-toastify';
|
||||
import type { OptionSpec } from '../../commands/prompt';
|
||||
import {
|
||||
dividePoints,
|
||||
measurePoints,
|
||||
@@ -10,12 +14,21 @@ import { Tool } from '../../tools';
|
||||
import { pointEntity } from '../factories/entity-factory';
|
||||
import { createSequenceTool } from '../factories/sequence-tool';
|
||||
|
||||
const BLOCK: OptionSpec = { key: 'BLOCK', kw: 'B', en: 'BLOCK', label: '블록' };
|
||||
|
||||
export const divideToolStateMachine = createSequenceTool({
|
||||
tool: Tool.DIVIDE,
|
||||
helpers: false,
|
||||
steps: [
|
||||
{ kind: 'entity', instructions: '등분할 객체를 선택하십시오.' },
|
||||
{ kind: 'number', instructions: '세그먼트 수를 입력하십시오 <4>.', defaultValue: 4 },
|
||||
{ kind: 'entity', prompt: { text: '등분할 객체 선택' } },
|
||||
{
|
||||
kind: 'number',
|
||||
prompt: {
|
||||
text: '세그먼트 수 입력',
|
||||
absent: [BLOCK],
|
||||
number: { int: true, min: 2, max: 32767 },
|
||||
},
|
||||
},
|
||||
],
|
||||
commit: (input) => {
|
||||
const points = dividePoints(sampleEntityPoints(input.entity(0)), input.number(1));
|
||||
@@ -31,11 +44,19 @@ export const measureLengthToolStateMachine = createSequenceTool({
|
||||
tool: Tool.MEASURE_LENGTH,
|
||||
helpers: false,
|
||||
steps: [
|
||||
{ kind: 'entity', instructions: '길이로 분할할 객체를 선택하십시오.' },
|
||||
{ kind: 'number', instructions: '세그먼트 길이를 입력하십시오 <10>.', defaultValue: 10 },
|
||||
{ kind: 'entity', prompt: { text: '길이 분할 객체 선택' } },
|
||||
{
|
||||
kind: 'number',
|
||||
prompt: { text: '세그먼트의 길이 지정', absent: [BLOCK], number: { positive: true } },
|
||||
},
|
||||
],
|
||||
commit: (input) => {
|
||||
const points = measurePoints(sampleEntityPoints(input.entity(0)), input.number(1));
|
||||
// 누른 자리에 가까운 끝부터 잼(오토캐드)
|
||||
const path = sampleEntityPoints(input.entity(0));
|
||||
const pick = input.pick(0);
|
||||
const nearEnd =
|
||||
path.length > 1 && path[path.length - 1].distanceTo(pick)[0] < path[0].distanceTo(pick)[0];
|
||||
const points = measurePoints(nearEnd ? [...path].reverse() : path, input.number(1));
|
||||
if (!points.length) {
|
||||
toast.warn('지정한 길이로 나눌 수 없습니다.');
|
||||
return;
|
||||
|
||||
@@ -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));
|
||||
},
|
||||
},
|
||||
}
|
||||
);
|
||||
@@ -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,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,15 +1,68 @@
|
||||
import { type Circle, Point, type Segment } from '@flatten-js/core';
|
||||
import { type Circle, Point, Segment } from '@flatten-js/core';
|
||||
import { compact } from 'es-toolkit';
|
||||
import { ArcEntity } from '../entities/ArcEntity';
|
||||
import type { CircleEntity } from '../entities/CircleEntity';
|
||||
import type { Entity } from '../entities/Entity';
|
||||
import type { LineEntity } from '../entities/LineEntity';
|
||||
import { LineEntity } from '../entities/LineEntity';
|
||||
import { findClosestEntity } from '../helpers/find-closest-entity';
|
||||
import { findNeighboringPointsOnArc } from '../helpers/find-neighboring-points-on-arc';
|
||||
import { findNeighboringPointsOnCircle } from '../helpers/find-neighboring-points-on-circle';
|
||||
import { findNeighboringPointsOnLine } from '../helpers/find-neighboring-points-on-line';
|
||||
import { sampleEntityPoints } from '../helpers/geometry/sample-entity';
|
||||
import { intersectLines } from '../helpers/geometry/shape-points';
|
||||
import { getAngleWithXAxis } from '../helpers/get-angle-with-x-axis.ts';
|
||||
import { isPointEqual } from '../helpers/is-point-equal';
|
||||
import { addEntities, deleteEntities, getActiveLayerId } from '../state';
|
||||
import { pickRadius } from '../helpers/pick-radius';
|
||||
import { queryEntitiesNearPoint } from '../helpers/spatial-index';
|
||||
import { addEntities, deleteEntities, getActiveLayerId, getLayerById } from '../state';
|
||||
import { copyStyle, isBetween } from './modify/modify.helpers';
|
||||
|
||||
/** 누른 자리 집기 반경 안 가장 가까운 객체 — 잠금 도면층 뺌(순서 엔진과 같은 셈) */
|
||||
export function pickEntityAt(point: Point): Entity | null {
|
||||
const radius = pickRadius();
|
||||
const candidates = queryEntitiesNearPoint(point.x, point.y, radius).filter(
|
||||
(entity) => !getLayerById(entity.layerId)?.isLocked
|
||||
);
|
||||
const { distance, entity } = findClosestEntity(point, candidates);
|
||||
return entity && distance <= radius ? entity : null;
|
||||
}
|
||||
|
||||
/** 점을 객체 위로 내림(가장 가까운 자리) */
|
||||
export const closestPointOn = (entity: Entity, point: Point): Point =>
|
||||
findClosestEntity(point, [entity]).segment?.start ?? point;
|
||||
|
||||
/** EXTEND 빠른 모드 — 누른 쪽 끝을 그 방향 가장 가까운 경계(다른 모든 객체)까지 · 선만 */
|
||||
export function extendLineEnd(line: Entity, pick: Point, boundaries: Entity[]): Entity | null {
|
||||
const shape = line.getShape();
|
||||
if (!(shape instanceof Segment)) return null;
|
||||
const fromStart = pick.distanceTo(shape.start)[0] < pick.distanceTo(shape.end)[0];
|
||||
const tip = fromStart ? shape.start : shape.end;
|
||||
const tail = fromStart ? shape.end : shape.start;
|
||||
const length = tail.distanceTo(tip)[0];
|
||||
if (!length) return null;
|
||||
const dx = (tip.x - tail.x) / length;
|
||||
const dy = (tip.y - tail.y) / length;
|
||||
let best = Number.POSITIVE_INFINITY;
|
||||
let hit: Point | null = null;
|
||||
for (const boundary of boundaries) {
|
||||
if (boundary.id === line.id) continue;
|
||||
const points = sampleEntityPoints(boundary);
|
||||
for (let index = 1; index < points.length; index++) {
|
||||
const crossing = intersectLines(tail, tip, points[index - 1], points[index]);
|
||||
if (!crossing || !isBetween(crossing, points[index - 1], points[index])) continue;
|
||||
const ahead = (crossing.x - tip.x) * dx + (crossing.y - tip.y) * dy;
|
||||
if (ahead > 1e-9 && ahead < best) {
|
||||
best = ahead;
|
||||
hit = crossing;
|
||||
}
|
||||
}
|
||||
}
|
||||
if (!hit) return null;
|
||||
return copyStyle(
|
||||
line,
|
||||
new LineEntity(line.layerId, fromStart ? hit : tail, fromStart ? tail : hit)
|
||||
);
|
||||
}
|
||||
|
||||
export function getAllIntersectionPoints(entity: Entity, entities: Entity[]): Point[] {
|
||||
// TODO see if we need to make this list unique
|
||||
@@ -39,8 +92,10 @@ export function eraseLineSegment(
|
||||
);
|
||||
|
||||
const cutLines: Entity[] = line.cutAtPoints([firstCutPoint, secondCutPoint]);
|
||||
// Remove the segment that has the clickedPointOnShape point on it
|
||||
const remainingLines = cutLines.filter((line) => !line.containsPointOnShape(clickedPointOnShape));
|
||||
// Remove the segment that has the clickedPointOnShape point on it — 남는 토막은 원래 층 · 꼴(N9)
|
||||
const remainingLines = cutLines
|
||||
.filter((piece) => !piece.containsPointOnShape(clickedPointOnShape))
|
||||
.map((piece) => copyStyle(line, piece));
|
||||
deleteEntities([line], false);
|
||||
|
||||
// Helper functions should never trigger an undo state, since they can be called multiple times during one user operation
|
||||
|
||||
@@ -1,100 +1,91 @@
|
||||
/**
|
||||
* TRIM · EXTEND 빠른 모드(337 설계 1-2) — 누를 때마다 자름(연장) · Shift+누름 = 반대 일 · 되풀이 ·
|
||||
* 빈 Enter · Esc = 끝 · 집기 반경 밖 누름은 아무 일 없음(N9) · U = 이 명령 안 마지막 하나 ·
|
||||
* TRIM 지우기(R) = 고르기 → Enter 로 지우고 돌아옴.
|
||||
*/
|
||||
import type { Point, Polygon } from '@flatten-js/core';
|
||||
import { assign, createMachine } from 'xstate';
|
||||
import { assign, createMachine, sendTo } from 'xstate';
|
||||
import {
|
||||
OPT,
|
||||
type OptionSpec,
|
||||
type PromptSpec,
|
||||
SELECT_OBJECTS,
|
||||
rememberSelection,
|
||||
} from '../commands/prompt';
|
||||
import type { ArcEntity } from '../entities/ArcEntity';
|
||||
import type { CircleEntity } from '../entities/CircleEntity';
|
||||
import { EntityName } from '../entities/Entity';
|
||||
import { LineEntity } from '../entities/LineEntity';
|
||||
import type { RectangleEntity } from '../entities/RectangleEntity';
|
||||
import { findClosestEntity } from '../helpers/find-closest-entity';
|
||||
import { pickRadius } from '../helpers/pick-radius';
|
||||
import { polygonToSegments } from '../helpers/polygon-to-segments';
|
||||
import {
|
||||
addEntities,
|
||||
deleteEntities,
|
||||
getActiveLayerId,
|
||||
getEntities,
|
||||
getPickableEntities,
|
||||
getSelectedEntities,
|
||||
setEntities,
|
||||
setGhostHelperEntities,
|
||||
setSelectedEntityIds,
|
||||
setShouldDrawHelpers,
|
||||
undo,
|
||||
} from '../state';
|
||||
import { Tool } from '../tools';
|
||||
import {
|
||||
eraseArcSegment,
|
||||
eraseCircleSegment,
|
||||
eraseLineSegment,
|
||||
extendLineEnd,
|
||||
getAllIntersectionPoints,
|
||||
pickEntityAt,
|
||||
} from './eraser-tool.helpers';
|
||||
import type { MouseClickEvent, StateEvent, ToolContext } from './tool.types';
|
||||
import { copyStyle } from './modify/modify.helpers';
|
||||
import { selectToolStateMachine } from './select-tool';
|
||||
import { END_STATE, type MouseClickEvent, type StateEvent, type ToolContext } from './tool.types';
|
||||
|
||||
export interface EraserContext extends ToolContext {
|
||||
startPoint: Point | null;
|
||||
/** 이 명령에서 바꾼 횟수(U) */
|
||||
made: number;
|
||||
}
|
||||
|
||||
export enum EraserState {
|
||||
INIT = 'INIT',
|
||||
WAITING_FOR_FIRST_CLICK = 'WAITING_FOR_FIRST_CLICK',
|
||||
ERASE_PICK = 'ERASE_PICK',
|
||||
}
|
||||
|
||||
export enum EraserAction {
|
||||
INIT_ERASER_TOOL = 'INIT_ERASER_TOOL',
|
||||
HANDLE_MOUSE_CLICK = 'HANDLE_MOUSE_CLICK',
|
||||
}
|
||||
const absent = (key: string, kw: string, label: string): OptionSpec => ({
|
||||
key,
|
||||
kw,
|
||||
en: key,
|
||||
label,
|
||||
});
|
||||
const HIDDEN_UNDO: OptionSpec = { ...OPT.UNDO, hidden: true };
|
||||
const CROSSING = absent('CROSSING', 'C', '걸치기');
|
||||
const MODE = absent('MODE', 'O', '모드');
|
||||
const PROJECT = absent('PROJECT', 'P', '투영');
|
||||
|
||||
export const eraserToolStateMachine = createMachine(
|
||||
{
|
||||
types: {} as {
|
||||
context: EraserContext;
|
||||
events: StateEvent;
|
||||
},
|
||||
context: {
|
||||
startPoint: null,
|
||||
type: Tool.ERASER,
|
||||
},
|
||||
initial: EraserState.INIT,
|
||||
states: {
|
||||
[EraserState.INIT]: {
|
||||
description: 'Initializing the eraser tool',
|
||||
always: {
|
||||
actions: EraserAction.INIT_ERASER_TOOL,
|
||||
target: EraserState.WAITING_FOR_FIRST_CLICK,
|
||||
},
|
||||
},
|
||||
[EraserState.WAITING_FOR_FIRST_CLICK]: {
|
||||
description: '지울 선 선택',
|
||||
meta: {
|
||||
instructions: '지울 선 선택',
|
||||
},
|
||||
on: {
|
||||
// TODO implement a DRAW action to draw the segment that will be erased in dotted line
|
||||
MOUSE_CLICK: {
|
||||
actions: EraserAction.HANDLE_MOUSE_CLICK,
|
||||
target: EraserState.WAITING_FOR_FIRST_CLICK,
|
||||
},
|
||||
},
|
||||
},
|
||||
// TODO implement rectangle selection to delete
|
||||
},
|
||||
},
|
||||
{
|
||||
actions: {
|
||||
[EraserAction.INIT_ERASER_TOOL]: assign(() => {
|
||||
setShouldDrawHelpers(false);
|
||||
setGhostHelperEntities([]);
|
||||
return {};
|
||||
}),
|
||||
[EraserAction.HANDLE_MOUSE_CLICK]: assign(({ context, event }) => {
|
||||
handleMouseClick((event as MouseClickEvent).worldMouseLocation);
|
||||
const TRIM_PROMPT: PromptSpec = {
|
||||
text: '자를 객체 선택 또는 Shift 키를 누른 채 선택하여 연장',
|
||||
options: [{ key: 'ERASE', kw: 'R', en: 'ERASE', label: '지우기' }, HIDDEN_UNDO],
|
||||
absent: [absent('CUTTINGEDGES', 'T', '절단 모서리'), CROSSING, MODE, PROJECT],
|
||||
};
|
||||
const EXTEND_PROMPT: PromptSpec = {
|
||||
text: '연장할 객체 선택 또는 Shift 키를 누른 채 선택하여 자르기',
|
||||
options: [HIDDEN_UNDO],
|
||||
absent: [absent('BOUNDARYEDGES', 'B', '경계 모서리'), CROSSING, MODE, PROJECT],
|
||||
};
|
||||
|
||||
return context;
|
||||
}),
|
||||
},
|
||||
}
|
||||
);
|
||||
|
||||
export function handleMouseClick(worldMouseLocation: Point) {
|
||||
/** TRIM 한 번 — 누른 자리 반경 안 객체만(없으면 false) · 사각형 변은 원래 층 · 꼴 */
|
||||
export function handleMouseClick(
|
||||
worldMouseLocation: Point,
|
||||
radius = Number.POSITIVE_INFINITY
|
||||
): boolean {
|
||||
const closestEntity = findClosestEntity(worldMouseLocation, getPickableEntities());
|
||||
if (!closestEntity) {
|
||||
return;
|
||||
if (!closestEntity.entity || closestEntity.distance > radius) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const clickedPointOnShape = closestEntity.segment.start;
|
||||
@@ -125,8 +116,8 @@ export function handleMouseClick(worldMouseLocation: Point) {
|
||||
case EntityName.Rectangle: {
|
||||
const rectangle = closestEntity.entity as RectangleEntity;
|
||||
const segments = polygonToSegments(rectangle.getShape() as Polygon);
|
||||
const segmentEntities = segments.map(
|
||||
(segment) => new LineEntity(getActiveLayerId(), segment)
|
||||
const segmentEntities = segments.map((segment) =>
|
||||
copyStyle(rectangle, new LineEntity(rectangle.layerId, segment))
|
||||
);
|
||||
|
||||
// Find the closest segment to the clicked point
|
||||
@@ -151,11 +142,129 @@ export function handleMouseClick(worldMouseLocation: Point) {
|
||||
break;
|
||||
}
|
||||
|
||||
case EntityName.Image: {
|
||||
// TODO implement image eraser
|
||||
// Switch to rectangle entity and delete the image and the line that was closest to the cursor
|
||||
}
|
||||
default:
|
||||
return false;
|
||||
}
|
||||
|
||||
setEntities(getEntities(), true); // Force a new undo state entry
|
||||
return true;
|
||||
}
|
||||
|
||||
/** EXTEND 한 번 — 누른 쪽 끝을 가장 가까운 경계까지 · 못 늘리면 false */
|
||||
export function extendAt(worldMouseLocation: Point): boolean {
|
||||
const target = pickEntityAt(worldMouseLocation);
|
||||
if (!target) return false;
|
||||
const extended = extendLineEnd(target, worldMouseLocation, getPickableEntities());
|
||||
if (!extended) return false;
|
||||
deleteEntities([target], false);
|
||||
addEntities([extended], true);
|
||||
return true;
|
||||
}
|
||||
|
||||
const trimAt = (location: Point) => handleMouseClick(location, pickRadius());
|
||||
|
||||
type Ctx = { context: EraserContext; event: StateEvent };
|
||||
|
||||
/** 빠른 자르기 · 연장 머신 — `act(점, shift)` 가 바꿨으면 true */
|
||||
function createQuickEditTool(
|
||||
tool: Tool,
|
||||
prompt: PromptSpec,
|
||||
act: (location: Point, shift: boolean) => boolean
|
||||
) {
|
||||
const undoLast = assign(({ context }: Ctx) => {
|
||||
if (context.made === 0) return {};
|
||||
undo();
|
||||
return { made: context.made - 1 };
|
||||
});
|
||||
const end = { target: END_STATE };
|
||||
const forward = { actions: sendTo('trimErase', ({ event }: Ctx) => event) };
|
||||
const takesErase = prompt.options?.some((option) => option.key === 'ERASE');
|
||||
|
||||
return createMachine(
|
||||
{
|
||||
types: {} as { context: EraserContext; events: StateEvent },
|
||||
context: { made: 0, type: tool },
|
||||
initial: EraserState.INIT,
|
||||
// biome-ignore lint/suspicious/noExplicitAny: 상태마다 사건 꼴이 달라 느슨하게(순서 엔진과 같음)
|
||||
states: <Record<string, any>>{
|
||||
[EraserState.INIT]: {
|
||||
always: {
|
||||
actions: () => {
|
||||
setShouldDrawHelpers(false);
|
||||
setGhostHelperEntities([]);
|
||||
},
|
||||
target: EraserState.WAITING_FOR_FIRST_CLICK,
|
||||
},
|
||||
},
|
||||
[EraserState.WAITING_FOR_FIRST_CLICK]: {
|
||||
meta: { kind: 'entity', prompt },
|
||||
on: {
|
||||
MOUSE_CLICK: {
|
||||
actions: assign(({ context, event }: Ctx) => {
|
||||
const click = event as MouseClickEvent;
|
||||
return act(click.worldMouseLocation, !!click.holdingShift)
|
||||
? { made: context.made + 1 }
|
||||
: {};
|
||||
}),
|
||||
},
|
||||
...(takesErase ? { 'OPTION.ERASE': { target: EraserState.ERASE_PICK } } : {}),
|
||||
'OPTION.UNDO': { actions: undoLast },
|
||||
ENTER: end,
|
||||
ESC: end,
|
||||
},
|
||||
},
|
||||
...(takesErase
|
||||
? {
|
||||
[EraserState.ERASE_PICK]: {
|
||||
meta: {
|
||||
kind: 'selection',
|
||||
prompt: { text: '지울 객체 선택', options: SELECT_OBJECTS.options },
|
||||
},
|
||||
entry: () => setShouldDrawHelpers(false),
|
||||
invoke: {
|
||||
id: 'trimErase',
|
||||
src: selectToolStateMachine,
|
||||
onDone: {
|
||||
actions: assign(({ context }: Ctx) => {
|
||||
const picked = getSelectedEntities();
|
||||
if (!picked.length) return {};
|
||||
rememberSelection(picked.map((entity) => entity.id));
|
||||
setSelectedEntityIds([]);
|
||||
deleteEntities(picked, true);
|
||||
return { made: context.made + 1 };
|
||||
}),
|
||||
target: EraserState.WAITING_FOR_FIRST_CLICK,
|
||||
},
|
||||
},
|
||||
on: {
|
||||
MOUSE_CLICK: forward,
|
||||
ENTER: forward,
|
||||
DRAW: forward,
|
||||
'OPTION.*': forward,
|
||||
ESC: { actions: () => setSelectedEntityIds([]), target: END_STATE },
|
||||
},
|
||||
},
|
||||
}
|
||||
: {}),
|
||||
[END_STATE]: { type: 'final' },
|
||||
},
|
||||
},
|
||||
{
|
||||
// 고르기 자식(선택 도구)의 액션 구현을 함께 넘긴다
|
||||
// biome-ignore lint/suspicious/noExplicitAny: 자식 머신의 컨텍스트 타입이 달라 그대로 넘긴다
|
||||
actions: { ...(selectToolStateMachine.implementations.actions as any) },
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
export const eraserToolStateMachine = createQuickEditTool(
|
||||
Tool.ERASER,
|
||||
TRIM_PROMPT,
|
||||
(location, shift) => (shift ? extendAt(location) : trimAt(location))
|
||||
);
|
||||
|
||||
export const extendToolStateMachine = createQuickEditTool(
|
||||
Tool.EXTEND,
|
||||
EXTEND_PROMPT,
|
||||
(location, shift) => (shift ? trimAt(location) : extendAt(location))
|
||||
);
|
||||
|
||||
@@ -1,10 +1,11 @@
|
||||
/**
|
||||
* 그립 편집 도구 — 선택 도구에서 그립을 집으면 켜진다(뜨거운 그립).
|
||||
* 오토캐드처럼 누르고-옮기고-누르기 · 끌어 놓기 둘 다(누름 · 뗌 가름은 input-controller) ·
|
||||
* 거리 숫자 = 그립 점에서 커서 쪽(N8) · 좌표 · Esc = 그립 놓음.
|
||||
* 거리 숫자 = 그립 점에서 커서 쪽(N8) · 좌표 · Esc · 종료(X) = 그립 놓음.
|
||||
*/
|
||||
import type { Point } from '@flatten-js/core';
|
||||
import { Actor, assign, createMachine } from 'xstate';
|
||||
import type { OptionSpec, PromptSpec } from '../commands/prompt';
|
||||
import type { Entity } from '../entities/Entity';
|
||||
import { getPointFromEvent } from '../helpers/get-point-from-event';
|
||||
import { type Grip, applyGrip } from '../helpers/grips';
|
||||
@@ -20,6 +21,24 @@ import { Tool } from '../tools';
|
||||
import { selectToolStateMachine } from './select-tool';
|
||||
import { ActorEvent, type PointInputEvent, type StateEvent, type ToolContext } from './tool.types';
|
||||
|
||||
const gripOption = (key: string, kw: string, label: string): OptionSpec => ({
|
||||
key,
|
||||
kw,
|
||||
en: key,
|
||||
label,
|
||||
});
|
||||
|
||||
/** 오토캐드 그립 신축 줄 — 종료(X)만 · 기준점(B) · 복사(C) · 명령 취소(U) 는 뺌 */
|
||||
const GRIP_PROMPT: PromptSpec = {
|
||||
text: '** 신축 ** 신축점 지정',
|
||||
options: [gripOption('EXIT', 'X', '종료')],
|
||||
absent: [
|
||||
gripOption('BASE', 'B', '기준점'),
|
||||
gripOption('COPY', 'C', '복사'),
|
||||
gripOption('UNDO', 'U', '명령 취소'),
|
||||
],
|
||||
};
|
||||
|
||||
let editedEntity: Entity | null = null;
|
||||
let editedGrip: Grip | null = null;
|
||||
|
||||
@@ -64,7 +83,7 @@ export const gripEditToolStateMachine = createMachine(
|
||||
},
|
||||
[GripEditState.WAITING_FOR_TARGET_POINT]: {
|
||||
description: '그립을 옮길 위치를 지정한다',
|
||||
meta: { instructions: '** 신축 ** 신축점 지정' },
|
||||
meta: { kind: 'point', prompt: GRIP_PROMPT },
|
||||
on: {
|
||||
[ActorEvent.DRAW]: { actions: GripEditAction.DRAW_TEMP_GRIP_EDIT },
|
||||
[ActorEvent.MOUSE_CLICK]: {
|
||||
@@ -80,6 +99,7 @@ export const gripEditToolStateMachine = createMachine(
|
||||
actions: [GripEditAction.APPLY_GRIP_EDIT, GripEditAction.SWITCH_TO_SELECT_TOOL],
|
||||
},
|
||||
[ActorEvent.ESC]: { actions: GripEditAction.SWITCH_TO_SELECT_TOOL },
|
||||
'OPTION.EXIT': { actions: GripEditAction.SWITCH_TO_SELECT_TOOL },
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
@@ -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,57 +1,290 @@
|
||||
/** 모깎기·모따기·곡선 혼합·끊기 (조사표 2절) */
|
||||
/**
|
||||
* 모깎기·모따기·곡선 혼합·끊기 (조사표 2절 · 337 설계 1-2)
|
||||
* FILLET · CHAMFER = 오토캐드 차례 — 첫 객체(또는 U · R/D · T · M) → 둘째 객체(Shift = 구석) · 값 · 자르기 모드 기억 ·
|
||||
* 다중이면 쌍 되풀이 · 빈 Enter · Esc = 끝. BREAK = 누른 점이 첫 끊기점 · F = 첫 점 다시 · `@0,0` = 같은 점.
|
||||
*/
|
||||
import type { Point } from '@flatten-js/core';
|
||||
import { toast } from 'react-toastify';
|
||||
import { assign, createMachine } from 'xstate';
|
||||
import { OPT, type OptionSpec, type PromptSpec, recall, remember } from '../../commands/prompt';
|
||||
import type { Entity } from '../../entities/Entity';
|
||||
import { addEntities, deleteEntities } from '../../state';
|
||||
import { getPointFromEvent } from '../../helpers/get-point-from-event';
|
||||
import {
|
||||
addEntities,
|
||||
deleteEntities,
|
||||
getEntities,
|
||||
setAngleGuideOriginPoint,
|
||||
setGhostHelperEntities,
|
||||
setShouldDrawHelpers,
|
||||
undo,
|
||||
} from '../../state';
|
||||
import { Tool } from '../../tools';
|
||||
import { closestPointOn, pickEntityAt } from '../eraser-tool.helpers';
|
||||
import { createSequenceTool } from '../factories/sequence-tool';
|
||||
import {
|
||||
END_STATE,
|
||||
type MouseClickEvent,
|
||||
type PointInputEvent,
|
||||
type StateEvent,
|
||||
type ToolContext,
|
||||
} from '../tool.types';
|
||||
import { blendEntities, breakEntity, chamferLines, filletLines } from './corner.helpers';
|
||||
import type { CornerResult } from './corner.helpers';
|
||||
|
||||
function applyCorner(first: Entity, second: Entity, result: CornerResult | null): void {
|
||||
const option = (key: string, kw: string, label: string): OptionSpec => ({
|
||||
key,
|
||||
kw,
|
||||
en: key,
|
||||
label,
|
||||
});
|
||||
const entityById = (id: string | null): Entity | undefined =>
|
||||
getEntities().find((entity) => entity.id === id);
|
||||
|
||||
// ---- FILLET · CHAMFER ----
|
||||
|
||||
interface CornerContext extends ToolContext {
|
||||
firstId: string | null;
|
||||
firstPick: Point | null;
|
||||
multiple: boolean;
|
||||
/** 이 명령에서 만든 쌍 수(U) */
|
||||
made: number;
|
||||
/** 값 · 자르기 물음 뒤 돌아갈 물음 */
|
||||
back: 'FIRST' | 'SECOND';
|
||||
/** 방금 누른 쌍을 처리했는가 */
|
||||
ok: boolean;
|
||||
}
|
||||
|
||||
interface CornerSpec {
|
||||
tool: Tool;
|
||||
first: string;
|
||||
second: string;
|
||||
/** 반지름(R) · 거리(D) */
|
||||
value: OptionSpec;
|
||||
absentFirst: OptionSpec[];
|
||||
absentSecond: OptionSpec[];
|
||||
/** 값 물음 차례 — shown = 그 열쇠 값을 <기본> 으로 보임 */
|
||||
values: Array<{ text: string; remember: string; shown?: string }>;
|
||||
/** corner = Shift+누름(반지름 · 거리 0) */
|
||||
apply(
|
||||
first: Entity,
|
||||
firstPick: Point,
|
||||
second: Entity,
|
||||
secondPick: Point,
|
||||
corner: boolean
|
||||
): CornerResult | null;
|
||||
}
|
||||
|
||||
const TRIM_OPTION = option('TRIM', 'T', '자르기');
|
||||
const POLYLINE = option('POLYLINE', 'P', '폴리선');
|
||||
const TRIM_MODE_PROMPT = (): PromptSpec => ({
|
||||
text: '자르기 모드 옵션 입력',
|
||||
options: [TRIM_OPTION, option('NOTRIM', 'N', '자르지 않기')],
|
||||
default: { kind: 'option', key: recall<string>('TRIMMODE') ?? 'TRIM' },
|
||||
});
|
||||
|
||||
type CornerCtx = { context: CornerContext; event: StateEvent };
|
||||
|
||||
function applyPair(spec: CornerSpec, context: CornerContext, click: MouseClickEvent): boolean {
|
||||
const first = entityById(context.firstId);
|
||||
const second = pickEntityAt(click.worldMouseLocation);
|
||||
if (!first || !second || !context.firstPick) return false;
|
||||
const result = spec.apply(
|
||||
first,
|
||||
context.firstPick,
|
||||
second,
|
||||
click.worldMouseLocation,
|
||||
!!click.holdingShift
|
||||
);
|
||||
if (!result) {
|
||||
toast.warn('두 직선을 선택해야 합니다. 두 선이 평행하면 처리할 수 없습니다.');
|
||||
return;
|
||||
return false;
|
||||
}
|
||||
if (recall('TRIMMODE') === 'NOTRIM') {
|
||||
if (!result.corner) return false;
|
||||
addEntities([result.corner], true);
|
||||
return true;
|
||||
}
|
||||
deleteEntities([first, second], false);
|
||||
addEntities([...result.trimmed, ...(result.corner ? [result.corner] : [])], true);
|
||||
return true;
|
||||
}
|
||||
|
||||
export const filletToolStateMachine = createSequenceTool({
|
||||
function createCornerTool(spec: CornerSpec) {
|
||||
const end = { target: END_STATE };
|
||||
const back = [
|
||||
{ guard: ({ context }: CornerCtx) => context.back === 'SECOND', target: 'SECOND' },
|
||||
{ target: 'FIRST' },
|
||||
];
|
||||
// 값 물음 — 마지막 값 뒤 = 돌아갈 물음
|
||||
const valueStates = Object.fromEntries(
|
||||
spec.values.map((value, index) => {
|
||||
const store = {
|
||||
actions: ({ event }: CornerCtx) =>
|
||||
remember(value.remember, (event as { value: number }).value),
|
||||
};
|
||||
const isLast = index === spec.values.length - 1;
|
||||
return [
|
||||
`VALUE_${index}`,
|
||||
{
|
||||
meta: {
|
||||
kind: 'number',
|
||||
prompt: {
|
||||
text: value.text,
|
||||
default: { kind: 'value', remember: value.shown ?? value.remember, initial: 0 },
|
||||
number: { min: 0 },
|
||||
} satisfies PromptSpec,
|
||||
},
|
||||
on: {
|
||||
NUMBER_INPUT: isLast
|
||||
? back.map((transition) => ({ ...transition, ...store }))
|
||||
: { ...store, target: `VALUE_${index + 1}` },
|
||||
ESC: end,
|
||||
},
|
||||
},
|
||||
];
|
||||
})
|
||||
);
|
||||
|
||||
return createMachine({
|
||||
types: {} as { context: CornerContext; events: StateEvent },
|
||||
context: {
|
||||
firstId: null,
|
||||
firstPick: null,
|
||||
multiple: false,
|
||||
made: 0,
|
||||
back: 'FIRST',
|
||||
ok: false,
|
||||
type: spec.tool,
|
||||
},
|
||||
initial: 'INIT',
|
||||
// biome-ignore lint/suspicious/noExplicitAny: 상태마다 사건 꼴이 달라 느슨하게(순서 엔진과 같음)
|
||||
states: <Record<string, any>>{
|
||||
INIT: {
|
||||
always: {
|
||||
actions: () => {
|
||||
setShouldDrawHelpers(false);
|
||||
setGhostHelperEntities([]);
|
||||
},
|
||||
target: 'FIRST',
|
||||
},
|
||||
},
|
||||
FIRST: {
|
||||
meta: {
|
||||
kind: 'entity',
|
||||
prompt: {
|
||||
text: spec.first,
|
||||
options: [OPT.UNDO, spec.value, TRIM_OPTION, OPT.MULTIPLE],
|
||||
absent: spec.absentFirst,
|
||||
} satisfies PromptSpec,
|
||||
},
|
||||
on: {
|
||||
MOUSE_CLICK: {
|
||||
guard: ({ event }: CornerCtx) =>
|
||||
!!pickEntityAt((event as MouseClickEvent).worldMouseLocation),
|
||||
actions: assign(({ event }: CornerCtx) => {
|
||||
const location = (event as MouseClickEvent).worldMouseLocation;
|
||||
return { firstId: pickEntityAt(location)?.id ?? null, firstPick: location };
|
||||
}),
|
||||
target: 'SECOND',
|
||||
},
|
||||
[`OPTION.${spec.value.key}`]: {
|
||||
actions: assign({ back: 'FIRST' as const }),
|
||||
target: 'VALUE_0',
|
||||
},
|
||||
'OPTION.TRIM': { target: 'TRIM_MODE' },
|
||||
'OPTION.MULTIPLE': { actions: assign({ multiple: true }) },
|
||||
'OPTION.UNDO': {
|
||||
actions: assign(({ context }: CornerCtx) => {
|
||||
if (context.made === 0) return {};
|
||||
undo();
|
||||
return { made: context.made - 1 };
|
||||
}),
|
||||
},
|
||||
ENTER: end,
|
||||
ESC: end,
|
||||
},
|
||||
},
|
||||
SECOND: {
|
||||
meta: {
|
||||
kind: 'entity',
|
||||
prompt: {
|
||||
text: spec.second,
|
||||
options: [spec.value],
|
||||
absent: spec.absentSecond,
|
||||
} satisfies PromptSpec,
|
||||
},
|
||||
on: {
|
||||
MOUSE_CLICK: {
|
||||
actions: assign(({ context, event }: CornerCtx) => {
|
||||
const ok = applyPair(spec, context, event as MouseClickEvent);
|
||||
return { ok, made: context.made + (ok ? 1 : 0) };
|
||||
}),
|
||||
target: 'APPLIED',
|
||||
},
|
||||
[`OPTION.${spec.value.key}`]: {
|
||||
actions: assign({ back: 'SECOND' as const }),
|
||||
target: 'VALUE_0',
|
||||
},
|
||||
ENTER: end,
|
||||
ESC: end,
|
||||
},
|
||||
},
|
||||
APPLIED: {
|
||||
always: [
|
||||
{ guard: ({ context }: CornerCtx) => !context.ok, target: 'SECOND' },
|
||||
{ guard: ({ context }: CornerCtx) => context.multiple, target: 'FIRST' },
|
||||
{ target: END_STATE },
|
||||
],
|
||||
},
|
||||
TRIM_MODE: {
|
||||
meta: { kind: 'keyword', prompt: TRIM_MODE_PROMPT },
|
||||
on: {
|
||||
'OPTION.TRIM': { actions: () => remember('TRIMMODE', 'TRIM'), target: 'FIRST' },
|
||||
'OPTION.NOTRIM': { actions: () => remember('TRIMMODE', 'NOTRIM'), target: 'FIRST' },
|
||||
ESC: end,
|
||||
},
|
||||
},
|
||||
...valueStates,
|
||||
[END_STATE]: { type: 'final' },
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
const radius = () => recall<number>('FILLETRAD') ?? 0;
|
||||
|
||||
export const filletToolStateMachine = createCornerTool({
|
||||
tool: Tool.FILLET,
|
||||
helpers: false,
|
||||
steps: [
|
||||
{ kind: 'number', instructions: '모깎기 반지름을 입력하십시오 <0>.', defaultValue: 0 },
|
||||
{ kind: 'entity', instructions: '첫 번째 객체를 남길 쪽에서 선택하십시오.' },
|
||||
{ kind: 'entity', instructions: '두 번째 객체를 남길 쪽에서 선택하십시오.' },
|
||||
],
|
||||
commit: (input) => {
|
||||
const first = input.entity(1);
|
||||
const second = input.entity(2);
|
||||
applyCorner(
|
||||
first,
|
||||
second,
|
||||
filletLines(first, input.pick(1), second, input.pick(2), input.number(0))
|
||||
);
|
||||
},
|
||||
first: '첫 번째 객체 선택',
|
||||
second: '두 번째 객체 선택 또는 Shift 키를 누른 채 선택하여 구석 적용',
|
||||
value: option('RADIUS', 'R', '반지름'),
|
||||
absentFirst: [POLYLINE],
|
||||
absentSecond: [],
|
||||
values: [{ text: '모깎기 반지름 지정', remember: 'FILLETRAD' }],
|
||||
apply: (first, firstPick, second, secondPick, corner) =>
|
||||
filletLines(first, firstPick, second, secondPick, corner ? 0 : radius()),
|
||||
});
|
||||
|
||||
export const chamferToolStateMachine = createSequenceTool({
|
||||
const ANGLE = option('ANGLE', 'A', '각도');
|
||||
const METHOD = option('METHOD', 'E', '방법');
|
||||
|
||||
export const chamferToolStateMachine = createCornerTool({
|
||||
tool: Tool.CHAMFER,
|
||||
helpers: false,
|
||||
steps: [
|
||||
{ kind: 'number', instructions: '첫 번째 모따기 거리를 입력하십시오 <1>.', defaultValue: 1 },
|
||||
{ kind: 'number', instructions: '두 번째 모따기 거리를 입력하십시오 <1>.', defaultValue: 1 },
|
||||
{ kind: 'entity', instructions: '첫 번째 객체를 남길 쪽에서 선택하십시오.' },
|
||||
{ kind: 'entity', instructions: '두 번째 객체를 남길 쪽에서 선택하십시오.' },
|
||||
first: '첫 번째 선 선택',
|
||||
second: '두 번째 선 선택 또는 Shift 키를 누른 채 선택하여 구석 적용',
|
||||
value: option('DISTANCE', 'D', '거리'),
|
||||
absentFirst: [POLYLINE, ANGLE, METHOD],
|
||||
absentSecond: [ANGLE, METHOD],
|
||||
values: [
|
||||
{ text: '첫 번째 모따기 거리 지정', remember: 'CHAMFERA' },
|
||||
{ text: '두 번째 모따기 거리 지정', remember: 'CHAMFERB', shown: 'CHAMFERA' },
|
||||
],
|
||||
commit: (input) => {
|
||||
const first = input.entity(2);
|
||||
const second = input.entity(3);
|
||||
applyCorner(
|
||||
first,
|
||||
second,
|
||||
chamferLines(first, input.pick(2), second, input.pick(3), input.number(0), input.number(1))
|
||||
);
|
||||
apply: (first, firstPick, second, secondPick, corner) => {
|
||||
const a = recall<number>('CHAMFERA') ?? 0;
|
||||
const b = recall<number>('CHAMFERB') ?? 0;
|
||||
// 거리 0 · Shift = 구석(모깎기 반지름 0 과 같음)
|
||||
return corner || (a === 0 && b === 0)
|
||||
? filletLines(first, firstPick, second, secondPick, 0)
|
||||
: chamferLines(first, firstPick, second, secondPick, a, b);
|
||||
},
|
||||
});
|
||||
|
||||
@@ -72,18 +305,111 @@ export const blendToolStateMachine = createSequenceTool({
|
||||
},
|
||||
});
|
||||
|
||||
export const breakToolStateMachine = createSequenceTool({
|
||||
tool: Tool.BREAK,
|
||||
steps: [
|
||||
{ kind: 'entity', instructions: '끊을 객체를 선택하십시오.' },
|
||||
{ kind: 'point', instructions: '첫 번째 끊기 점을 지정하십시오.' },
|
||||
{ kind: 'point', instructions: '두 번째 끊기 점을 지정하십시오.' },
|
||||
],
|
||||
commit: (input) => {
|
||||
const entity = input.entity(0);
|
||||
const pieces = breakEntity(entity, input.point(1), input.point(2));
|
||||
deleteEntities([entity], false);
|
||||
addEntities(pieces, true);
|
||||
// ---- BREAK ----
|
||||
|
||||
interface BreakContext extends ToolContext {
|
||||
entityId: string | null;
|
||||
first: Point | null;
|
||||
}
|
||||
|
||||
type BreakCtx = { context: BreakContext; event: StateEvent };
|
||||
|
||||
/** 두 끊기점 사이를 지움 · 같은 점 = 틈 없이 둘로 */
|
||||
function breakNow(context: BreakContext, event: StateEvent) {
|
||||
const entity = entityById(context.entityId);
|
||||
if (!entity || !context.first) return;
|
||||
const first = closestPointOn(entity, context.first);
|
||||
const second = closestPointOn(entity, getPointFromEvent(context.first, event as PointInputEvent));
|
||||
const same = Math.hypot(first.x - second.x, first.y - second.y) < 1e-9;
|
||||
const pieces = same ? breakEntity(entity, first) : breakEntity(entity, first, second);
|
||||
if (same && pieces.length < 2) return;
|
||||
deleteEntities([entity], false);
|
||||
addEntities(pieces, true);
|
||||
}
|
||||
|
||||
const POINT_EVENTS = [
|
||||
'MOUSE_CLICK',
|
||||
'ABSOLUTE_POINT_INPUT',
|
||||
'RELATIVE_POINT_INPUT',
|
||||
'NUMBER_INPUT',
|
||||
];
|
||||
const onPoint = (transition: object) =>
|
||||
Object.fromEntries(POINT_EVENTS.map((type) => [type, transition]));
|
||||
const FIRST_POINT = option('FIRST', 'F', '첫 번째 점');
|
||||
|
||||
export const breakToolStateMachine = createMachine({
|
||||
types: {} as { context: BreakContext; events: StateEvent },
|
||||
context: { entityId: null, first: null, type: Tool.BREAK },
|
||||
initial: 'INIT',
|
||||
states: {
|
||||
INIT: {
|
||||
always: {
|
||||
actions: () => {
|
||||
setShouldDrawHelpers(false);
|
||||
setGhostHelperEntities([]);
|
||||
},
|
||||
target: 'OBJECT',
|
||||
},
|
||||
},
|
||||
OBJECT: {
|
||||
meta: { kind: 'entity', prompt: { text: '객체 선택' } satisfies PromptSpec },
|
||||
on: {
|
||||
MOUSE_CLICK: {
|
||||
guard: ({ event }: BreakCtx) =>
|
||||
!!pickEntityAt((event as MouseClickEvent).worldMouseLocation),
|
||||
actions: assign(({ event }: BreakCtx) => {
|
||||
const location = (event as MouseClickEvent).worldMouseLocation;
|
||||
const entity = pickEntityAt(location) as Entity;
|
||||
const first = closestPointOn(entity, location);
|
||||
setAngleGuideOriginPoint(first);
|
||||
return { entityId: entity.id, first };
|
||||
}),
|
||||
target: 'SECOND',
|
||||
},
|
||||
ENTER: { target: END_STATE },
|
||||
ESC: { target: END_STATE },
|
||||
},
|
||||
},
|
||||
SECOND: {
|
||||
meta: {
|
||||
kind: 'point',
|
||||
prompt: { text: '두 번째 끊기점 지정', options: [FIRST_POINT] } satisfies PromptSpec,
|
||||
},
|
||||
entry: () => setShouldDrawHelpers(true),
|
||||
on: {
|
||||
...onPoint({
|
||||
actions: ({ context, event }: BreakCtx) => breakNow(context, event),
|
||||
target: END_STATE,
|
||||
}),
|
||||
'OPTION.FIRST': { target: 'FIRST_POINT' },
|
||||
ESC: { target: END_STATE },
|
||||
},
|
||||
},
|
||||
FIRST_POINT: {
|
||||
meta: { kind: 'point', prompt: { text: '첫 번째 끊기점 지정' } satisfies PromptSpec },
|
||||
on: {
|
||||
...onPoint({
|
||||
actions: assign(({ context, event }: BreakCtx) => {
|
||||
const first = getPointFromEvent(context.first, event as PointInputEvent);
|
||||
setAngleGuideOriginPoint(first);
|
||||
return { first };
|
||||
}),
|
||||
target: 'SECOND_POINT',
|
||||
}),
|
||||
ESC: { target: END_STATE },
|
||||
},
|
||||
},
|
||||
SECOND_POINT: {
|
||||
meta: { kind: 'point', prompt: { text: '두 번째 끊기점 지정' } satisfies PromptSpec },
|
||||
on: {
|
||||
...onPoint({
|
||||
actions: ({ context, event }: BreakCtx) => breakNow(context, event),
|
||||
target: END_STATE,
|
||||
}),
|
||||
ESC: { target: END_STATE },
|
||||
},
|
||||
},
|
||||
[END_STATE]: { type: 'final' },
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
@@ -0,0 +1,212 @@
|
||||
/**
|
||||
* LENGTHEN(LEN) — 오토캐드 물음 차례(337 설계 1-2).
|
||||
* 「측정할 객체 선택 또는 [증분(DE)/퍼센트(P)/전체(T)] <전체(T)>:」(누름 = 현재 길이 알림)
|
||||
* → 방식 값 → 「변경할 객체 선택 또는 [명령 취소(U)]:」 되풀이(빈 Enter · Esc = 끝 · 바꾼 것 남음).
|
||||
* 손으로 짠 도구(설계 2-14) — 바꾸고 같은 물음에 머무는 되풀이 · U 는 이 명령 안 바꿈만 되돌림.
|
||||
*/
|
||||
import { Arc, type Point, Segment } from '@flatten-js/core';
|
||||
import { assign, createMachine } from 'xstate';
|
||||
import { type OptionSpec, type PromptSpec, recall, remember } from '../../commands/prompt';
|
||||
import { ArcEntity } from '../../entities/ArcEntity';
|
||||
import type { Entity } from '../../entities/Entity';
|
||||
import { findClosestEntity } from '../../helpers/find-closest-entity';
|
||||
import { polylineLength, sampleEntityPoints } from '../../helpers/geometry/sample-entity';
|
||||
import { pickRadius } from '../../helpers/pick-radius';
|
||||
import { queryEntitiesNearPoint } from '../../helpers/spatial-index';
|
||||
import { notice } from '../../inputController/prompt-input';
|
||||
import { addEntities, deleteEntities, getLayerById, setShouldDrawHelpers, undo } from '../../state';
|
||||
import { Tool } from '../../tools';
|
||||
import {
|
||||
END_STATE,
|
||||
type MouseClickEvent,
|
||||
type NumberInputEvent,
|
||||
type StateEvent,
|
||||
type ToolContext,
|
||||
} from '../tool.types';
|
||||
import { copyStyle, lengthenLine } from './modify.helpers';
|
||||
|
||||
type Mode = 'DELTA' | 'PERCENT' | 'TOTAL';
|
||||
|
||||
const MODE_OPTIONS: Record<Mode, OptionSpec> = {
|
||||
DELTA: { key: 'DELTA', kw: 'DE', en: 'DELTA', label: '증분' },
|
||||
PERCENT: { key: 'PERCENT', kw: 'P', en: 'PERCENT', label: '퍼센트' },
|
||||
TOTAL: { key: 'TOTAL', kw: 'T', en: 'TOTAL', label: '전체' },
|
||||
};
|
||||
const DYNAMIC: OptionSpec = { key: 'DYNAMIC', kw: 'DY', en: 'DYNAMIC', label: '동적' };
|
||||
const ANGLE: OptionSpec = { key: 'ANGLE', kw: 'A', en: 'ANGLE', label: '각도' };
|
||||
const UNDO: OptionSpec = { key: 'UNDO', kw: 'U', en: 'UNDO', label: '명령 취소' };
|
||||
|
||||
/** 방식별 값 물음 — 기억 열쇠 LENGTHEN.DE · .P · .T */
|
||||
const VALUE_PROMPTS: Record<Mode, PromptSpec> = {
|
||||
DELTA: {
|
||||
text: '증분 길이 입력',
|
||||
absent: [ANGLE],
|
||||
default: { kind: 'value', remember: 'LENGTHEN.DE', initial: 0 },
|
||||
},
|
||||
PERCENT: {
|
||||
text: '퍼센트 길이 입력',
|
||||
number: { positive: true },
|
||||
default: { kind: 'value', remember: 'LENGTHEN.P', initial: 100 },
|
||||
},
|
||||
TOTAL: {
|
||||
text: '전체 길이 지정',
|
||||
absent: [ANGLE],
|
||||
number: { positive: true },
|
||||
default: { kind: 'value', remember: 'LENGTHEN.T', initial: 1 },
|
||||
},
|
||||
};
|
||||
const REMEMBER_KEY: Record<Mode, string> = {
|
||||
DELTA: 'LENGTHEN.DE',
|
||||
PERCENT: 'LENGTHEN.P',
|
||||
TOTAL: 'LENGTHEN.T',
|
||||
};
|
||||
|
||||
const lastMode = (): Mode => recall<Mode>('LENGTHEN.MODE') ?? 'TOTAL';
|
||||
|
||||
const MEASURE_PROMPT = (): PromptSpec => {
|
||||
const mode = MODE_OPTIONS[lastMode()];
|
||||
return {
|
||||
text: '측정할 객체 선택',
|
||||
options: [MODE_OPTIONS.DELTA, MODE_OPTIONS.PERCENT, MODE_OPTIONS.TOTAL],
|
||||
absent: [DYNAMIC],
|
||||
default: { kind: 'option', key: mode.key, label: `${mode.label}(${mode.kw})` },
|
||||
};
|
||||
};
|
||||
const TARGET_PROMPT: PromptSpec = { text: '변경할 객체 선택', options: [UNDO] };
|
||||
|
||||
/** 누른 자리 가장 가까운 객체(잠금 층 뺌 · 반경 안만) */
|
||||
function pickAt(point: Point): Entity | null {
|
||||
const radius = pickRadius();
|
||||
const candidates = queryEntitiesNearPoint(point.x, point.y, radius).filter(
|
||||
(entity) => !getLayerById(entity.layerId)?.isLocked
|
||||
);
|
||||
const { distance, entity } = findClosestEntity(point, candidates);
|
||||
return entity && distance <= radius ? entity : null;
|
||||
}
|
||||
|
||||
export function entityLength(entity: Entity): number {
|
||||
const shape = entity.getShape();
|
||||
if (shape instanceof Segment || shape instanceof Arc) return shape.length;
|
||||
return polylineLength(sampleEntityPoints(entity));
|
||||
}
|
||||
|
||||
/** 호 — 누른 쪽 끝을 호 길이 delta 만큼(사이각 0 ~ 360° 안) */
|
||||
function lengthenArc(entity: Entity, arc: Arc, delta: number, near: Point): Entity | null {
|
||||
const radius = Number(arc.r);
|
||||
const turn = delta / radius;
|
||||
const sweep = arc.sweep + turn;
|
||||
if (sweep <= 0 || sweep >= 2 * Math.PI) return null;
|
||||
const atStart = arc.start.distanceTo(near)[0] < arc.end.distanceTo(near)[0];
|
||||
const sign = arc.counterClockwise ? 1 : -1;
|
||||
const startAngle = atStart ? arc.startAngle - sign * turn : arc.startAngle;
|
||||
const endAngle = atStart ? arc.endAngle : arc.endAngle + sign * turn;
|
||||
return copyStyle(
|
||||
entity,
|
||||
new ArcEntity(entity.layerId, arc.center, radius, startAngle, endAngle, arc.counterClockwise)
|
||||
);
|
||||
}
|
||||
|
||||
/** 방식 · 값 → 바뀐 객체(못 바꾸면 null) */
|
||||
function changed(entity: Entity, mode: Mode, value: number, near: Point): Entity | null {
|
||||
const shape = entity.getShape();
|
||||
if (!(shape instanceof Segment) && !(shape instanceof Arc)) return null;
|
||||
const length = shape.length;
|
||||
const target =
|
||||
mode === 'DELTA' ? length + value : mode === 'PERCENT' ? (length * value) / 100 : value;
|
||||
if (target <= 1e-9) return null;
|
||||
const delta = target - length;
|
||||
return shape instanceof Arc
|
||||
? lengthenArc(entity, shape, delta, near)
|
||||
: lengthenLine(entity, delta, near);
|
||||
}
|
||||
|
||||
interface LengthenContext extends ToolContext {
|
||||
mode: Mode;
|
||||
value: number;
|
||||
/** 이 명령 안에서 바꾼 횟수 — U 는 이만큼만 되돌림 */
|
||||
changes: number;
|
||||
}
|
||||
|
||||
type Ctx = { context: LengthenContext; event: StateEvent };
|
||||
|
||||
const toValue = (mode: Mode) => ({
|
||||
target: `VALUE_${mode}`,
|
||||
actions: assign({ mode: () => mode }),
|
||||
});
|
||||
|
||||
const clickPoint = (event: StateEvent) => (event as MouseClickEvent).worldMouseLocation;
|
||||
|
||||
export const lengthenToolStateMachine = createMachine({
|
||||
types: {} as { context: LengthenContext; events: StateEvent },
|
||||
context: { type: Tool.LENGTHEN, mode: 'TOTAL', value: 1, changes: 0 },
|
||||
initial: 'MEASURE',
|
||||
states: {
|
||||
MEASURE: {
|
||||
meta: { kind: 'entity', prompt: MEASURE_PROMPT },
|
||||
entry: () => setShouldDrawHelpers(false),
|
||||
on: {
|
||||
MOUSE_CLICK: {
|
||||
actions: ({ event }: Ctx) => {
|
||||
const entity = pickAt(clickPoint(event));
|
||||
if (entity) notice(`현재 길이: ${entityLength(entity).toFixed(4)}`);
|
||||
},
|
||||
},
|
||||
'OPTION.DELTA': toValue('DELTA'),
|
||||
'OPTION.PERCENT': toValue('PERCENT'),
|
||||
'OPTION.TOTAL': toValue('TOTAL'),
|
||||
ESC: END_STATE,
|
||||
},
|
||||
},
|
||||
...Object.fromEntries(
|
||||
(Object.keys(VALUE_PROMPTS) as Mode[]).map((mode) => [
|
||||
`VALUE_${mode}`,
|
||||
{
|
||||
meta: { kind: 'number', prompt: VALUE_PROMPTS[mode] },
|
||||
on: {
|
||||
NUMBER_INPUT: {
|
||||
target: 'TARGET',
|
||||
actions: assign(({ event }: Ctx) => {
|
||||
const value = (event as NumberInputEvent).value;
|
||||
remember('LENGTHEN.MODE', mode);
|
||||
remember(REMEMBER_KEY[mode], value);
|
||||
return { value };
|
||||
}),
|
||||
},
|
||||
ESC: END_STATE,
|
||||
},
|
||||
},
|
||||
])
|
||||
),
|
||||
TARGET: {
|
||||
meta: { kind: 'entity', prompt: TARGET_PROMPT },
|
||||
on: {
|
||||
MOUSE_CLICK: {
|
||||
actions: assign(({ context, event }: Ctx) => {
|
||||
const near = clickPoint(event);
|
||||
const entity = pickAt(near);
|
||||
if (!entity) return {};
|
||||
const next = changed(entity, context.mode, context.value, near);
|
||||
if (!next) {
|
||||
notice('이 객체는 길이를 바꿀 수 없음.');
|
||||
return {};
|
||||
}
|
||||
deleteEntities([entity], false);
|
||||
addEntities([next], true);
|
||||
return { changes: context.changes + 1 };
|
||||
}),
|
||||
},
|
||||
'OPTION.UNDO': {
|
||||
actions: assign(({ context }: Ctx) => {
|
||||
if (!context.changes) return {};
|
||||
undo();
|
||||
return { changes: context.changes - 1 };
|
||||
}),
|
||||
},
|
||||
ENTER: END_STATE,
|
||||
ESC: END_STATE,
|
||||
},
|
||||
},
|
||||
[END_STATE]: { type: 'final' },
|
||||
// biome-ignore lint/suspicious/noExplicitAny: 손으로 짠 상태 — 사건마다 assign 꼴이 달라 풀어 둠(sequence-tool 과 같음)
|
||||
} as Record<string, any>,
|
||||
});
|
||||
@@ -0,0 +1,219 @@
|
||||
/**
|
||||
* OFFSET(337 설계 1-2) — 오토캐드 간격띄우기 차례: 거리(또는 통과점(T) · 지우기(E)) → 객체 → 면의 점 →
|
||||
* 객체로 되풀이 · 다중(M) = 방금 만든 것을 같은 거리로 되풀이 · U = 이 명령 안 마지막 하나 · 빈 Enter · Esc = 끝.
|
||||
* 거리 기억 = OFFSETDIST(처음 <통과점>) · 원본 지우기 기억 = OFFSETERASE.
|
||||
*/
|
||||
import { type Point, Segment } from '@flatten-js/core';
|
||||
import { toast } from 'react-toastify';
|
||||
import { assign, createMachine } from 'xstate';
|
||||
import { OPT, type OptionSpec, type PromptSpec, recall, remember } from '../../commands/prompt';
|
||||
import type { Entity } from '../../entities/Entity';
|
||||
import { findClosestEntity } from '../../helpers/find-closest-entity';
|
||||
import { getPointFromEvent } from '../../helpers/get-point-from-event';
|
||||
import {
|
||||
addEntities,
|
||||
deleteEntities,
|
||||
getEntities,
|
||||
setAngleGuideOriginPoint,
|
||||
setGhostHelperEntities,
|
||||
setShouldDrawHelpers,
|
||||
undo,
|
||||
} from '../../state';
|
||||
import { Tool } from '../../tools';
|
||||
import { pickEntityAt } from '../eraser-tool.helpers';
|
||||
import {
|
||||
type DrawEvent,
|
||||
END_STATE,
|
||||
type MouseClickEvent,
|
||||
type PointInputEvent,
|
||||
type StateEvent,
|
||||
type ToolContext,
|
||||
} from '../tool.types';
|
||||
import { offsetEntity } from './modify.helpers';
|
||||
|
||||
interface OffsetContext extends ToolContext {
|
||||
/** 간격띄울 객체 */
|
||||
sourceId: string | null;
|
||||
/** 처음 고른 객체 — 원본 지우기는 이것만 */
|
||||
pickedId: string | null;
|
||||
multiple: boolean;
|
||||
/** 이 명령에서 만든 수(U) */
|
||||
made: number;
|
||||
}
|
||||
|
||||
type Ctx = { context: OffsetContext; event: StateEvent };
|
||||
|
||||
const option = (key: string, kw: string, label: string): OptionSpec => ({
|
||||
key,
|
||||
kw,
|
||||
en: key,
|
||||
label,
|
||||
});
|
||||
const THROUGH = option('THROUGH', 'T', '통과점');
|
||||
|
||||
/** 기억한 거리 · null = 통과점 */
|
||||
const offsetDistance = (): number | null => {
|
||||
const distance = recall('OFFSETDIST');
|
||||
return typeof distance === 'number' ? distance : null;
|
||||
};
|
||||
|
||||
const DISTANCE_PROMPT = (): PromptSpec => {
|
||||
const distance = offsetDistance();
|
||||
return {
|
||||
text: '간격띄우기 거리 지정',
|
||||
options: [THROUGH, option('ERASE', 'E', '지우기')],
|
||||
absent: [option('LAYER', 'L', '도면층')],
|
||||
default:
|
||||
distance === null
|
||||
? { kind: 'option', key: 'THROUGH' }
|
||||
: { kind: 'value', remember: 'OFFSETDIST', initial: distance },
|
||||
number: { positive: true },
|
||||
};
|
||||
};
|
||||
const ERASE_PROMPT = (): PromptSpec => ({
|
||||
text: '간격띄우기 후 원본 객체를 지우시겠습니까?',
|
||||
options: [OPT.YES, OPT.NO],
|
||||
default: { kind: 'option', key: recall('OFFSETERASE') ? 'YES' : 'NO' },
|
||||
});
|
||||
const OBJECT_PROMPT: PromptSpec = {
|
||||
text: '간격띄우기할 객체 선택',
|
||||
options: [OPT.EXIT, OPT.UNDO],
|
||||
default: { kind: 'enter', label: '종료' },
|
||||
};
|
||||
const SIDE_PROMPT = (context: Partial<OffsetContext>): PromptSpec => ({
|
||||
text: offsetDistance() === null ? '통과점 지정' : '간격띄우기할 면의 점 지정',
|
||||
options: context.multiple ? [OPT.EXIT, OPT.UNDO] : [OPT.EXIT, OPT.MULTIPLE, OPT.UNDO],
|
||||
default: { kind: 'enter', label: context.multiple ? '다음 객체' : '종료' },
|
||||
});
|
||||
|
||||
/** 통과점까지 거리 — 선은 무한 직선까지(오토캐드) · 그 밖은 모양까지 */
|
||||
function throughDistance(entity: Entity, point: Point): number {
|
||||
const shape = entity.getShape();
|
||||
if (shape instanceof Segment) {
|
||||
const { start, end } = shape;
|
||||
const length = start.distanceTo(end)[0] || 1;
|
||||
return (
|
||||
Math.abs((end.x - start.x) * (point.y - start.y) - (end.y - start.y) * (point.x - start.x)) /
|
||||
length
|
||||
);
|
||||
}
|
||||
return findClosestEntity(point, [entity]).distance;
|
||||
}
|
||||
|
||||
function offsetThrough(source: Entity, point: Point): Entity | null {
|
||||
return offsetEntity(source, offsetDistance() ?? throughDistance(source, point), point);
|
||||
}
|
||||
|
||||
const sourceOf = (context: OffsetContext) =>
|
||||
getEntities().find((entity) => entity.id === context.sourceId);
|
||||
|
||||
/** 면의 점 하나 → 새 객체 · 다중이면 그것이 다음 원본 */
|
||||
const placeOffset = assign(({ context, event }: Ctx): Partial<OffsetContext> => {
|
||||
const source = sourceOf(context);
|
||||
if (!source) return { sourceId: null };
|
||||
const point = getPointFromEvent(null, event as PointInputEvent);
|
||||
const result = offsetThrough(source, point);
|
||||
setGhostHelperEntities([]);
|
||||
if (!result) {
|
||||
toast.warn('이 객체는 지정한 거리로 간격을 띄울 수 없습니다.');
|
||||
return {};
|
||||
}
|
||||
if (recall('OFFSETERASE') && source.id === context.pickedId) deleteEntities([source], false);
|
||||
addEntities([result], true);
|
||||
return { made: context.made + 1, sourceId: context.multiple ? result.id : null };
|
||||
});
|
||||
|
||||
const undoLast = assign(({ context }: Ctx): Partial<OffsetContext> => {
|
||||
setGhostHelperEntities([]);
|
||||
if (context.made === 0) return {};
|
||||
undo();
|
||||
return { made: context.made - 1, sourceId: null, multiple: false };
|
||||
});
|
||||
|
||||
const end = { target: END_STATE };
|
||||
const toObject = { actions: assign({ sourceId: null, multiple: false }), target: 'OBJECT' };
|
||||
|
||||
// biome-ignore lint/suspicious/noExplicitAny: 상태마다 사건 꼴이 달라 느슨하게(순서 엔진과 같음)
|
||||
const states: Record<string, any> = {
|
||||
INIT: {
|
||||
always: {
|
||||
actions: () => {
|
||||
setShouldDrawHelpers(false);
|
||||
setGhostHelperEntities([]);
|
||||
setAngleGuideOriginPoint(null);
|
||||
},
|
||||
target: 'DISTANCE',
|
||||
},
|
||||
},
|
||||
DISTANCE: {
|
||||
meta: { kind: 'number', prompt: DISTANCE_PROMPT },
|
||||
on: {
|
||||
NUMBER_INPUT: {
|
||||
actions: ({ event }: Ctx) => remember('OFFSETDIST', (event as { value: number }).value),
|
||||
target: 'OBJECT',
|
||||
},
|
||||
'OPTION.THROUGH': { actions: () => remember('OFFSETDIST', 'THROUGH'), target: 'OBJECT' },
|
||||
'OPTION.ERASE': { target: 'ERASE' },
|
||||
ESC: end,
|
||||
},
|
||||
},
|
||||
ERASE: {
|
||||
meta: { kind: 'keyword', prompt: ERASE_PROMPT },
|
||||
on: {
|
||||
'OPTION.YES': { actions: () => remember('OFFSETERASE', true), target: 'DISTANCE' },
|
||||
'OPTION.NO': { actions: () => remember('OFFSETERASE', false), target: 'DISTANCE' },
|
||||
ESC: end,
|
||||
},
|
||||
},
|
||||
OBJECT: {
|
||||
meta: { kind: 'entity', prompt: OBJECT_PROMPT },
|
||||
entry: () => setShouldDrawHelpers(false),
|
||||
on: {
|
||||
MOUSE_CLICK: {
|
||||
guard: ({ event }: Ctx) => !!pickEntityAt((event as MouseClickEvent).worldMouseLocation),
|
||||
actions: assign(({ event }: Ctx) => {
|
||||
const id = pickEntityAt((event as MouseClickEvent).worldMouseLocation)?.id ?? null;
|
||||
return { sourceId: id, pickedId: id };
|
||||
}),
|
||||
target: 'SIDE',
|
||||
},
|
||||
'OPTION.EXIT': end,
|
||||
'OPTION.UNDO': { actions: undoLast },
|
||||
ENTER: end,
|
||||
ESC: end,
|
||||
},
|
||||
},
|
||||
SIDE: {
|
||||
meta: { kind: 'point', prompt: SIDE_PROMPT },
|
||||
entry: () => setShouldDrawHelpers(true),
|
||||
on: {
|
||||
DRAW: {
|
||||
actions: ({ context, event }: Ctx) => {
|
||||
const source = sourceOf(context);
|
||||
const preview =
|
||||
source && offsetThrough(source, getPointFromEvent(null, event as DrawEvent));
|
||||
setGhostHelperEntities(preview ? [preview] : []);
|
||||
},
|
||||
},
|
||||
MOUSE_CLICK: { actions: placeOffset, target: 'PLACED' },
|
||||
ABSOLUTE_POINT_INPUT: { actions: placeOffset, target: 'PLACED' },
|
||||
'OPTION.EXIT': end,
|
||||
'OPTION.MULTIPLE': { actions: assign({ multiple: true }) },
|
||||
'OPTION.UNDO': { actions: undoLast, target: 'OBJECT' },
|
||||
ENTER: [{ guard: ({ context }: Ctx) => context.multiple, ...toObject }, end],
|
||||
ESC: end,
|
||||
},
|
||||
},
|
||||
/** 다중이면 같은 물음 · 아니면 객체로 */
|
||||
PLACED: {
|
||||
always: [{ guard: ({ context }: Ctx) => !!context.sourceId, target: 'SIDE' }, toObject],
|
||||
},
|
||||
[END_STATE]: { type: 'final' },
|
||||
};
|
||||
|
||||
export const offsetToolStateMachine = createMachine({
|
||||
types: {} as { context: OffsetContext; events: StateEvent },
|
||||
context: { sourceId: null, pickedId: null, multiple: false, made: 0, type: Tool.OFFSET },
|
||||
initial: 'INIT',
|
||||
states,
|
||||
});
|
||||
@@ -1,35 +1,27 @@
|
||||
/** 연장·결합·분해·지우기 (조사표 2절) */
|
||||
import { toast } from 'react-toastify';
|
||||
import { SELECT_OBJECTS } from '../../commands/prompt';
|
||||
import type { Entity } from '../../entities/Entity';
|
||||
import { addEntities, deleteEntities, setSelectedEntityIds } from '../../state';
|
||||
import { Tool } from '../../tools';
|
||||
import { createSequenceTool } from '../factories/sequence-tool';
|
||||
import { explodeEntity, extendLineToBoundary, joinEntities } from './modify.helpers';
|
||||
import { explodeEntity, joinEntities } from './modify.helpers';
|
||||
|
||||
export const extendToolStateMachine = createSequenceTool({
|
||||
tool: Tool.EXTEND,
|
||||
helpers: false,
|
||||
steps: [
|
||||
{ kind: 'entity', instructions: '경계로 쓸 객체를 선택하십시오.' },
|
||||
{ kind: 'entity', instructions: '연장할 선을 선택하십시오.' },
|
||||
],
|
||||
commit: (input) => {
|
||||
const boundary = input.entity(0);
|
||||
const target = input.entity(1);
|
||||
const extended = extendLineToBoundary(target, boundary);
|
||||
if (!extended) {
|
||||
toast.warn('경계와 만나도록 연장할 수 없습니다.');
|
||||
return;
|
||||
}
|
||||
deleteEntities([target], false);
|
||||
addEntities([extended], true);
|
||||
},
|
||||
});
|
||||
/** EXTEND 빠른 모드는 TRIM 과 한 머신 꼴(eraser-tool.ts) */
|
||||
export { extendToolStateMachine } from '../eraser-tool';
|
||||
|
||||
export const joinToolStateMachine = createSequenceTool({
|
||||
tool: Tool.JOIN,
|
||||
helpers: false,
|
||||
steps: [{ kind: 'selection', instructions: '결합할 객체를 선택한 뒤 ENTER.' }],
|
||||
steps: [
|
||||
{
|
||||
kind: 'selection',
|
||||
prompt: {
|
||||
text: '한 번에 결합할 원본 객체 또는 여러 객체 선택',
|
||||
options: SELECT_OBJECTS.options,
|
||||
},
|
||||
},
|
||||
],
|
||||
commit: (input) => {
|
||||
const entities = input.entities(0);
|
||||
const joined = joinEntities(entities);
|
||||
|
||||
@@ -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 };
|
||||
}),
|
||||
},
|
||||
}
|
||||
);
|
||||
|
||||
@@ -31,6 +31,7 @@ import {
|
||||
setSelectedEntityIds,
|
||||
} from '../state';
|
||||
import { Tool } from '../tools';
|
||||
import { previousSelection } from '../commands/prompt';
|
||||
import { startGripEdit } from './grip-edit-tool';
|
||||
import type { SelectContext } from './select-tool';
|
||||
import type { MouseClickEvent } from './tool.types';
|
||||
@@ -113,6 +114,26 @@ export function combineSelection(ids: string[], mode: 'add' | 'remove' | 'toggle
|
||||
return [...before, ...ids.filter((id) => !before.includes(id))];
|
||||
}
|
||||
|
||||
/** 알림 줄 — 입력 층이 붙임(`ic/selecting.ts` · prompt-input 을 바로 부르면 불러오기 고리) */
|
||||
let notice: (text: string) => void = () => {};
|
||||
export const setKeywordNotice = (sink: (text: string) => void): void => {
|
||||
notice = sink;
|
||||
};
|
||||
|
||||
/** 고르기 키워드 — 전체(ALL = Ctrl+A 와 같은 목록) · 최종(LAST = 집을 수 있는 마지막) · 이전(P) · 앞 고름에 더함 */
|
||||
export function selectByKeyword(key: 'ALL' | 'LAST' | 'PREVIOUS'): void {
|
||||
const pickable = getPickableEntities();
|
||||
const found =
|
||||
key === 'ALL'
|
||||
? pickable.map((entity) => entity.id)
|
||||
: key === 'LAST'
|
||||
? pickable.slice(-1).map((entity) => entity.id)
|
||||
: previousSelection().filter((id) => pickable.some((entity) => entity.id === id));
|
||||
const ids = combineSelection(expandSelectionWithGroups(found), 'add');
|
||||
setSelectedEntityIds(ids);
|
||||
notice(`${found.length}개를 찾음, 총 ${ids.length}개`);
|
||||
}
|
||||
|
||||
/** 그립 집기 · 그리기 — 쉴 때 · 고칠 수 있는 도면만 */
|
||||
export const gripsEditable = (): boolean =>
|
||||
!isViewOnly() &&
|
||||
|
||||
@@ -14,6 +14,7 @@ import { assign, createMachine } from 'xstate';
|
||||
import {
|
||||
drawTempSelectionRectangle,
|
||||
handleFirstSelectionPoint,
|
||||
selectByKeyword,
|
||||
selectEntitiesInsideRectangle,
|
||||
} from './select-tool.helpers';
|
||||
|
||||
@@ -76,6 +77,10 @@ export const selectToolStateMachine = createMachine(
|
||||
actions: SelectAction.DELETE_SELECTED_ENTITIES,
|
||||
target: SelectState.INIT,
|
||||
},
|
||||
// 고르기 숨은 키워드(SELECT_OBJECTS) — 전체 · 최종 · 이전
|
||||
'OPTION.ALL': { actions: () => selectByKeyword('ALL') },
|
||||
'OPTION.LAST': { actions: () => selectByKeyword('LAST') },
|
||||
'OPTION.PREVIOUS': { actions: () => selectByKeyword('PREVIOUS') },
|
||||
},
|
||||
},
|
||||
[SelectState.CHECK_SELECTION]: {
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
/** 조회 명령 — 거리·반지름·각도·면적·좌표·리스트·계산기 (조사표 3절 유틸리티) */
|
||||
import { Circle, type Point } from '@flatten-js/core';
|
||||
import { toast } from 'react-toastify';
|
||||
import type { OptionSpec } from '../../commands/prompt';
|
||||
import { logLine } from '../../commands/run-command';
|
||||
import { EntityName } from '../../entities/Entity';
|
||||
import { entitiesToLoop } from '../../helpers/geometry/entity-loop';
|
||||
import { polylineLength, sampleEntityPoints } from '../../helpers/geometry/sample-entity';
|
||||
@@ -22,21 +24,30 @@ export function polygonArea(points: Point[]): number {
|
||||
return Math.abs(total) / 2;
|
||||
}
|
||||
|
||||
const MULTIPLE_POINTS: OptionSpec = { key: 'MULTIPLE', kw: 'M', en: 'MULTIPLE', label: '다중 점' };
|
||||
|
||||
/** DIST 결과 두 줄(오토캐드 꼴 · 길이 소수 넷 · 각도 AUPREC 0) */
|
||||
export function distanceReport(first: Point, second: Point): [string, string] {
|
||||
const dx = second.x - first.x;
|
||||
const dy = second.y - first.y;
|
||||
const angle = (toDegrees(Math.atan2(dy, dx)) + 360) % 360;
|
||||
return [
|
||||
`거리 = ${format(Math.hypot(dx, dy), 4)}, XY 평면에서의 각도 = ${Math.round(angle) % 360}, XY 평면으로부터의 각도 = 0`,
|
||||
`X 증분 = ${format(dx, 4)}, Y 증분 = ${format(dy, 4)}, Z 증분 = ${format(0, 4)}`,
|
||||
];
|
||||
}
|
||||
|
||||
export const distanceToolStateMachine = createSequenceTool({
|
||||
tool: Tool.DIST,
|
||||
steps: [
|
||||
{ kind: 'point', instructions: '거리를 잴 첫 점을 지정하십시오.' },
|
||||
{ kind: 'point', instructions: '두 번째 점을 지정하십시오.' },
|
||||
{ kind: 'point', prompt: { text: '첫 번째 점 지정' } },
|
||||
{ kind: 'point', prompt: { text: '두 번째 점 지정', absent: [MULTIPLE_POINTS] } },
|
||||
],
|
||||
commit: (input) => {
|
||||
const [first, second] = input.points();
|
||||
const distance = first.distanceTo(second)[0];
|
||||
const angle = toDegrees(Math.atan2(second.y - first.y, second.x - first.x));
|
||||
toast.info(
|
||||
`거리 ${format(distance)} · X 증분 ${format(second.x - first.x)} · Y 증분 ${format(
|
||||
second.y - first.y
|
||||
)} · 각도 ${format(angle, 2)}°`
|
||||
);
|
||||
const lines = distanceReport(first, second);
|
||||
for (const line of lines) logLine(line);
|
||||
toast.info(lines.join('\n'));
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -137,19 +137,18 @@ describe("331-1 각도 고정 → 길이", () => {
|
||||
expectXy(lines()[1].end, -10, -0.5);
|
||||
});
|
||||
|
||||
it("PLINE 첫 점에 숫자 · @x,y → 무시 · 도구 살아 있음 · 알림 0", () => {
|
||||
it("PLINE 첫 점에 숫자 → 거절 · 도구 살아 있음 · 알림 0(첫 점 @x,y = 마지막 점 기준 — prompt-frame)", () => {
|
||||
type(controller, "pline");
|
||||
type(controller, "100");
|
||||
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("점 또는 옵션 키워드를 요구함."); // 기준점 없는 거리 · 상대 좌표 = 거절
|
||||
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 +209,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 는 둘째 선부터 — 한 구간이면 무시", () => {
|
||||
|
||||
@@ -0,0 +1,363 @@
|
||||
/**
|
||||
* 337 다-2 — 늘이기 · 재기 · 치수 오토캐드 조작(설계 1-2 · 6-3 다 줄 중 LENGTHEN · DIVIDE · MEASURE · DIST · DIM).
|
||||
* 물음 글 · 옵션 글자 · 기본값 · 빈 Enter · Esc · 끝난 뒤를 키 · 마우스로 넣어 숫자로 판정.
|
||||
* 객체는 앱 코드(fromJson)로만 만듦 — 시험 쪽 flatten-js 는 다른 사본.
|
||||
* 실행: cd B07_DesignDetail/openwebcad && npx vitest run --dir ../../resources/tester/cad measure-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 { getCommandHistory } from "../../../B07_DesignDetail/openwebcad/src/commands/run-command";
|
||||
import { getDimDecimals } from "../../../B07_DesignDetail/openwebcad/src/commands/dim-settings";
|
||||
import { ArcEntity } from "../../../B07_DesignDetail/openwebcad/src/entities/ArcEntity";
|
||||
import { CircleEntity } from "../../../B07_DesignDetail/openwebcad/src/entities/CircleEntity";
|
||||
import { EntityName, type Entity } from "../../../B07_DesignDetail/openwebcad/src/entities/Entity";
|
||||
import { LineEntity } from "../../../B07_DesignDetail/openwebcad/src/entities/LineEntity";
|
||||
import type { MeasurementEntity } from "../../../B07_DesignDetail/openwebcad/src/entities/MeasurementEntity";
|
||||
import { getActiveLayerId, setEntities } from "../../../B07_DesignDetail/openwebcad/src/state";
|
||||
import { Tool } from "../../../B07_DesignDetail/openwebcad/src/tools";
|
||||
import {
|
||||
activeTool,
|
||||
click,
|
||||
entities,
|
||||
initIdle,
|
||||
key,
|
||||
noticeLine,
|
||||
promptLine,
|
||||
type,
|
||||
typeEnter,
|
||||
} from "./helpers/cad-drive";
|
||||
|
||||
type Xy = { x: number; y: number };
|
||||
const enter = () => key("Enter");
|
||||
const esc = () => key("Escape");
|
||||
const base = () => ({ lineColor: "#fff", lineWidth: 1, layerId: getActiveLayerId() });
|
||||
|
||||
async function addLine(x1: number, y1: number, x2: number, y2: number): Promise<Entity> {
|
||||
const line = await LineEntity.fromJson({
|
||||
id: `line-${x1}-${y1}-${x2}-${y2}`,
|
||||
type: EntityName.Line,
|
||||
...base(),
|
||||
shapeData: { startPoint: { x: x1, y: y1 }, endPoint: { x: x2, y: y2 } },
|
||||
} as never);
|
||||
setEntities([...entities(), line], true); // 그린 것처럼 되돌림 기록에 올림
|
||||
return line;
|
||||
}
|
||||
|
||||
async function addArc(): Promise<Entity> {
|
||||
// 중심 (0, 0) · 반지름 50 · 0° → 90° 반시계(길이 25π)
|
||||
const arc = await ArcEntity.fromJson({
|
||||
id: "arc",
|
||||
type: EntityName.Arc,
|
||||
...base(),
|
||||
shapeData: { center: { x: 0, y: 0 }, radius: 50, startAngle: 0, endAngle: Math.PI / 2, counterClockwise: true },
|
||||
} as never);
|
||||
setEntities([...entities(), arc], true); // 그린 것처럼 되돌림 기록에 올림
|
||||
return arc;
|
||||
}
|
||||
|
||||
async function addCircle(): Promise<Entity> {
|
||||
const circle = await CircleEntity.fromJson({
|
||||
id: "circle",
|
||||
type: EntityName.Circle,
|
||||
...base(),
|
||||
shapeData: { center: { x: 300, y: 300 }, radius: 10 },
|
||||
} as never);
|
||||
setEntities([...entities(), circle], true); // 그린 것처럼 되돌림 기록에 올림
|
||||
return circle;
|
||||
}
|
||||
|
||||
const ends = (entity: Entity): [Xy, Xy] => {
|
||||
const shape = entity.getShape() as unknown as { start: Xy; end: Xy };
|
||||
return [
|
||||
{ x: shape.start.x, y: shape.start.y },
|
||||
{ x: shape.end.x, y: shape.end.y },
|
||||
];
|
||||
};
|
||||
const onlyLine = () => entities("Line")[0];
|
||||
const lineLength = () => {
|
||||
const [a, b] = ends(onlyLine());
|
||||
return Math.hypot(b.x - a.x, b.y - a.y);
|
||||
};
|
||||
const points = () =>
|
||||
entities("Point")
|
||||
.map((entity) => {
|
||||
const p = entity.getShape() as unknown as Xy;
|
||||
return { x: Math.round(p.x * 1000) / 1000, y: Math.round(p.y * 1000) / 1000 };
|
||||
})
|
||||
.sort((a, b) => a.x - b.x);
|
||||
const dims = () => entities("Measurement") as MeasurementEntity[];
|
||||
const lastLog = (n = 1) => getCommandHistory().slice(-n);
|
||||
/** 선형 치수 글(반올림 · 끝 0 뗌) · 반지름 치수 글(소수 자릿수 그대로) */
|
||||
const fmt = (value: number) => String(Number(value.toFixed(getDimDecimals())));
|
||||
const fixed = (value: number) => value.toFixed(getDimDecimals());
|
||||
|
||||
beforeEach(() => {
|
||||
initIdle();
|
||||
});
|
||||
|
||||
describe("LENGTHEN(LEN)", () => {
|
||||
const FIRST = "측정할 객체 선택 또는 [증분(DE)/퍼센트(P)/전체(T)]";
|
||||
|
||||
it("누름 = 현재 길이 · DE → 되풀이 · U = 이 명령 안 바꿈만 · 빈 Enter = 끝", async () => {
|
||||
await addLine(0, 0, 100, 0);
|
||||
typeEnter("len");
|
||||
expect(activeTool()).toBe(Tool.LENGTHEN);
|
||||
expect(promptLine()).toBe(`${FIRST} <전체(T)>:`);
|
||||
click(50, 0);
|
||||
expect(noticeLine()).toBe("현재 길이: 100.0000");
|
||||
expect(promptLine()).toBe(`${FIRST} <전체(T)>:`);
|
||||
type("d"); // DE 보다 짧음 — 안 맞음
|
||||
expect(noticeLine()).toBe("*선택이 잘못됨*");
|
||||
type("dy");
|
||||
expect(noticeLine()).toBe("「동적(DY)」 옵션은 이 CAD 에 없음.");
|
||||
type("de");
|
||||
expect(promptLine()).toBe("증분 길이 입력 <0.0000>:");
|
||||
type("10");
|
||||
expect(promptLine()).toBe("변경할 객체 선택 또는 [명령 취소(U)]:");
|
||||
click(95, 0); // 끝 쪽
|
||||
expect(ends(onlyLine())[1].x).toBeCloseTo(110);
|
||||
click(105, 0);
|
||||
expect(ends(onlyLine())[1].x).toBeCloseTo(120);
|
||||
expect(activeTool()).toBe(Tool.LENGTHEN); // 되풀이
|
||||
type("u");
|
||||
expect(ends(onlyLine())[1].x).toBeCloseTo(110);
|
||||
type("u");
|
||||
type("u"); // 이 명령 안 바꿈 다 되돌린 뒤 = 아무 일 없음(앞 객체 지키기)
|
||||
expect(entities("Line")).toHaveLength(1);
|
||||
expect(lineLength()).toBeCloseTo(100);
|
||||
click(5, 0); // 시작 쪽
|
||||
expect(ends(onlyLine())[0].x).toBeCloseTo(-10);
|
||||
enter();
|
||||
expect(activeTool()).toBe(Tool.SELECT);
|
||||
});
|
||||
|
||||
it("마지막 방식 · 값 기억 — 첫 물음 빈 Enter = 그 방식 · 값 빈 Enter = 앞 값", async () => {
|
||||
await addLine(0, 0, 100, 0);
|
||||
typeEnter("len");
|
||||
type("de");
|
||||
type("10");
|
||||
esc();
|
||||
typeEnter("len");
|
||||
expect(promptLine()).toBe(`${FIRST} <증분(DE)>:`);
|
||||
enter();
|
||||
expect(promptLine()).toBe("증분 길이 입력 <10.0000>:");
|
||||
enter();
|
||||
click(95, 0);
|
||||
expect(lineLength()).toBeCloseTo(110);
|
||||
esc(); // Esc = 끝 · 바꾼 것 남음
|
||||
expect(activeTool()).toBe(Tool.SELECT);
|
||||
expect(lineLength()).toBeCloseTo(110);
|
||||
});
|
||||
|
||||
it("P · T — 퍼센트 · 전체 · 양수 거절 · 호", async () => {
|
||||
await addLine(0, 0, 100, 0);
|
||||
typeEnter("len");
|
||||
type("p");
|
||||
expect(promptLine()).toBe("퍼센트 길이 입력 <100.0000>:");
|
||||
type("-5");
|
||||
expect(noticeLine()).toBe("값은 양수이고 0이 아니어야 함.");
|
||||
type("50");
|
||||
click(90, 0);
|
||||
expect(ends(onlyLine())).toEqual([
|
||||
{ x: 0, y: 0 },
|
||||
{ x: 50, y: 0 },
|
||||
]);
|
||||
esc();
|
||||
typeEnter("len");
|
||||
expect(promptLine()).toBe(`${FIRST} <퍼센트(P)>:`);
|
||||
type("t");
|
||||
expect(promptLine()).toBe("전체 길이 지정 <1.0000>:");
|
||||
type("a");
|
||||
expect(noticeLine()).toBe("「각도(A)」 옵션은 이 CAD 에 없음.");
|
||||
type("200");
|
||||
click(45, 0);
|
||||
expect(lineLength()).toBeCloseTo(200);
|
||||
await addArc();
|
||||
click(0, 50); // 호 끝(90°) 쪽
|
||||
const arc = entities("Arc")[0].getShape() as unknown as { length: number; endAngle: number };
|
||||
expect(arc.length).toBeCloseTo(200);
|
||||
expect(arc.endAngle).toBeCloseTo(4); // 200 / 50 rad
|
||||
esc();
|
||||
});
|
||||
});
|
||||
|
||||
describe("DIVIDE(DIV) · MEASURE(ME)", () => {
|
||||
it("DIVIDE — 기본 없음 · 정수 2~32767 · 블록 없음 · 한 번 뒤 끝", async () => {
|
||||
await addLine(0, 0, 100, 0);
|
||||
typeEnter("div");
|
||||
expect(promptLine()).toBe("등분할 객체 선택:");
|
||||
click(50, 0);
|
||||
expect(promptLine()).toBe("세그먼트 수 입력:");
|
||||
enter(); // 기본 없음 — 그대로
|
||||
expect(promptLine()).toBe("세그먼트 수 입력:");
|
||||
type("2.5");
|
||||
expect(noticeLine()).toBe("정수 값을 요구함.");
|
||||
type("1");
|
||||
expect(noticeLine()).toBe("2에서 32767 사이의 정수를 요구함.");
|
||||
type("b");
|
||||
expect(noticeLine()).toBe("「블록(B)」 옵션은 이 CAD 에 없음.");
|
||||
type("4");
|
||||
expect(points()).toEqual([
|
||||
{ x: 25, y: 0 },
|
||||
{ x: 50, y: 0 },
|
||||
{ x: 75, y: 0 },
|
||||
]);
|
||||
expect(activeTool()).toBe(Tool.SELECT);
|
||||
});
|
||||
|
||||
it("MEASURE — 누른 자리 가까운 끝부터 · 양수", async () => {
|
||||
await addLine(0, 0, 100, 0);
|
||||
typeEnter("me");
|
||||
expect(promptLine()).toBe("길이 분할 객체 선택:");
|
||||
click(90, 0); // 끝 쪽
|
||||
expect(promptLine()).toBe("세그먼트의 길이 지정:");
|
||||
type("0");
|
||||
expect(noticeLine()).toBe("값은 양수이고 0이 아니어야 함.");
|
||||
type("30");
|
||||
expect(points().map((p) => p.x)).toEqual([10, 40, 70]);
|
||||
expect(activeTool()).toBe(Tool.SELECT);
|
||||
});
|
||||
});
|
||||
|
||||
describe("DIST(DI)", () => {
|
||||
it("두 점 → 오토캐드 꼴 두 줄 기록 · 한 번 뒤 끝 · 다중 점 없음", () => {
|
||||
typeEnter("di");
|
||||
expect(promptLine()).toBe("첫 번째 점 지정:");
|
||||
type("0,0");
|
||||
expect(promptLine()).toBe("두 번째 점 지정:");
|
||||
type("m");
|
||||
expect(noticeLine()).toBe("「다중 점(M)」 옵션은 이 CAD 에 없음.");
|
||||
type("30,40");
|
||||
expect(lastLog(2)).toEqual([
|
||||
"거리 = 50.0000, XY 평면에서의 각도 = 53, XY 평면으로부터의 각도 = 0",
|
||||
"X 증분 = 30.0000, Y 증분 = 40.0000, Z 증분 = 0.0000",
|
||||
]);
|
||||
expect(activeTool()).toBe(Tool.SELECT);
|
||||
});
|
||||
});
|
||||
|
||||
describe("DIMLINEAR(DLI) · DIMALIGNED(DAL)", () => {
|
||||
const PLACE = "치수선의 위치 지정 또는 [문자(T)/수평(H)/수직(V)]:";
|
||||
|
||||
it("원점 둘 → V 고정 → T 문자(`<>` = 잰 값) → 놓음 · 기록 · 한 번 뒤 끝", () => {
|
||||
typeEnter("dli");
|
||||
expect(promptLine()).toBe("첫 번째 치수보조선 원점 지정 또는 <객체 선택>:");
|
||||
type("0,0");
|
||||
expect(promptLine()).toBe("두 번째 치수보조선 원점 지정:");
|
||||
type("100,30");
|
||||
expect(promptLine()).toBe(PLACE);
|
||||
type("m");
|
||||
expect(noticeLine()).toBe("「여러 줄 문자(M)」 옵션은 이 CAD 에 없음.");
|
||||
type("v");
|
||||
expect(promptLine()).toBe(PLACE);
|
||||
type("t");
|
||||
expect(promptLine()).toBe(`치수 문자 입력 <${fmt(30)}>:`);
|
||||
typeEnter("L=<>");
|
||||
expect(promptLine()).toBe(PLACE);
|
||||
type("120,15");
|
||||
const [dim] = dims();
|
||||
expect(dim.getLabel()).toBe(`L=${fmt(30)}`);
|
||||
expect(dim.getEndPoint().x).toBeCloseTo(0); // 수직 — 끝점 x 를 시작점에 맞춤
|
||||
expect(dim.getEndPoint().y).toBeCloseTo(30);
|
||||
expect(lastLog()).toEqual([`치수 문자 = L=${fmt(30)}`]);
|
||||
expect(activeTool()).toBe(Tool.SELECT);
|
||||
});
|
||||
|
||||
it("첫 물음 빈 Enter = 객체 선택 → H 고정 · T 빈 Enter = 잰 값 그대로", async () => {
|
||||
await addLine(0, 0, 80, 60);
|
||||
typeEnter("dli");
|
||||
enter();
|
||||
expect(promptLine()).toBe("치수 기입할 객체 선택:");
|
||||
click(40, 30);
|
||||
expect(promptLine()).toBe(PLACE);
|
||||
type("h");
|
||||
type("t");
|
||||
expect(promptLine()).toBe(`치수 문자 입력 <${fmt(80)}>:`);
|
||||
enter();
|
||||
type("40,-20"); // 수평 고정이라 옆에 놓아도 가로
|
||||
const [dim] = dims();
|
||||
expect(dim.getLabel()).toBe(fmt(80));
|
||||
expect(dim.textOverride).toBeUndefined();
|
||||
expect(activeTool()).toBe(Tool.SELECT);
|
||||
});
|
||||
|
||||
it("Esc = 끝 · 안 만듦 · DIMALIGNED 는 [문자(T)] 만", () => {
|
||||
typeEnter("dli");
|
||||
type("0,0");
|
||||
type("10,0");
|
||||
esc();
|
||||
expect(activeTool()).toBe(Tool.SELECT);
|
||||
expect(dims()).toHaveLength(0);
|
||||
typeEnter("dal");
|
||||
type("0,0");
|
||||
type("30,40");
|
||||
expect(promptLine()).toBe("치수선의 위치 지정 또는 [문자(T)]:");
|
||||
type("h");
|
||||
expect(noticeLine()).toBe("점 또는 옵션 키워드를 요구함.");
|
||||
type("0,50");
|
||||
expect(dims()[0].getLabel()).toBe(fmt(50));
|
||||
});
|
||||
});
|
||||
|
||||
describe("DIMCONTINUE(DCO) · DIMBASELINE(DBA)", () => {
|
||||
const CHAIN = "두 번째 치수보조선 원점 지정 또는 [명령 취소(U)]:";
|
||||
|
||||
it("되풀이 · U = 이 명령 안 마지막 치수 · 빈 Enter = 끝", () => {
|
||||
typeEnter("dli");
|
||||
type("0,0");
|
||||
type("100,0");
|
||||
type("50,20");
|
||||
expect(dims()).toHaveLength(1);
|
||||
typeEnter("dco");
|
||||
expect(promptLine()).toBe(CHAIN);
|
||||
type("s");
|
||||
expect(noticeLine()).toBe("「선택(S)」 옵션은 이 CAD 에 없음.");
|
||||
type("150,0");
|
||||
type("200,0");
|
||||
expect(dims()).toHaveLength(3);
|
||||
expect(dims()[2].getStartPoint().x).toBeCloseTo(150); // 앞 치수 끝에서 이어짐
|
||||
expect(promptLine()).toBe(CHAIN);
|
||||
type("u");
|
||||
expect(dims()).toHaveLength(2);
|
||||
type("u");
|
||||
type("u"); // 이 명령 밖 치수는 안 지움
|
||||
expect(dims()).toHaveLength(1);
|
||||
type("130,0");
|
||||
expect(dims()[1].getStartPoint().x).toBeCloseTo(100); // U 뒤 앞 치수에서 다시 이음
|
||||
enter();
|
||||
expect(activeTool()).toBe(Tool.SELECT);
|
||||
typeEnter("dba");
|
||||
expect(promptLine()).toBe(CHAIN);
|
||||
type("180,0");
|
||||
expect(dims()).toHaveLength(3);
|
||||
expect(dims()[2].getStartPoint().x).toBeCloseTo(100); // 기준선 = 앞 치수 시작점
|
||||
esc();
|
||||
expect(activeTool()).toBe(Tool.SELECT);
|
||||
});
|
||||
});
|
||||
|
||||
describe("DIMRADIUS(DRA) · DIMDIAMETER(DDI)", () => {
|
||||
it("호 또는 원 → [문자(T)] → 위치 · 기록 · 한 번 뒤 끝", async () => {
|
||||
await addCircle();
|
||||
typeEnter("dra");
|
||||
expect(promptLine()).toBe("호 또는 원 선택:");
|
||||
click(310, 300);
|
||||
expect(promptLine()).toBe("치수선의 위치 지정 또는 [문자(T)]:");
|
||||
type("a");
|
||||
expect(noticeLine()).toBe("「각도(A)」 옵션은 이 CAD 에 없음.");
|
||||
type("t");
|
||||
expect(promptLine()).toBe(`치수 문자 입력 <R${fixed(10)}>:`);
|
||||
typeEnter("<> BASE");
|
||||
type("330,300");
|
||||
expect(lastLog()).toEqual([`치수 문자 = R${fixed(10)} BASE`]);
|
||||
expect(activeTool()).toBe(Tool.SELECT);
|
||||
typeEnter("ddi");
|
||||
click(310, 300);
|
||||
type("330,300");
|
||||
expect(lastLog()).toEqual([`치수 문자 = Ø${fixed(20)}`]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,374 @@
|
||||
/**
|
||||
* 337 다-1 고치기 7 — OFFSET · FILLET · CHAMFER · TRIM · EXTEND · BREAK · JOIN 을 오토캐드 물음 차례 · 옵션 · 기본값 ·
|
||||
* 빈 Enter · Esc · 끝난 뒤 동작으로(설계 `docs/raw/조사/2026-10-15_337_CAD오토캐드조작_설계.md` 1-2 · 6-3 다 줄).
|
||||
* 기억(OFFSETDIST · FILLETRAD · CHAMFERA/B · TRIMMODE)은 파일 안 세션 하나 — 시험 차례대로 이어짐.
|
||||
* 실행: cd B07_DesignDetail/openwebcad && npx vitest run --dir ../../resources/tester/cad modify-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 { Tool } from "../../../B07_DesignDetail/openwebcad/src/tools";
|
||||
import { activeTool, click, entities, initIdle, key, promptLine, type } from "./helpers/cad-drive";
|
||||
|
||||
type Shape = { start: { x: number; y: number }; end: { x: number; y: number } };
|
||||
const enter = () => key("Enter");
|
||||
const r = (value: number) => Math.round(value * 1e4) / 1e4;
|
||||
/** 선 하나 — 쉼에서 그리고 Esc 로 끝 */
|
||||
const line = (a: string, b: string) => {
|
||||
type("l");
|
||||
type(a);
|
||||
type(b);
|
||||
key("Escape");
|
||||
};
|
||||
/** 선마다 [x1, y1, x2, y2] — 차례 무관(정렬) */
|
||||
const segs = () =>
|
||||
entities("Line")
|
||||
.map((entity) => {
|
||||
const { start, end } = entity.getShape() as unknown as Shape;
|
||||
return [r(start.x), r(start.y), r(end.x), r(end.y)];
|
||||
})
|
||||
.sort((a, b) => a[0] - b[0] || a[1] - b[1] || a[2] - b[2] || a[3] - b[3]);
|
||||
const ys = () =>
|
||||
segs()
|
||||
.map((s) => s[1])
|
||||
.sort((a, b) => a - b);
|
||||
|
||||
beforeEach(() => {
|
||||
initIdle();
|
||||
});
|
||||
|
||||
describe("OFFSET", () => {
|
||||
it("처음 <통과점> · 통과점 · 되풀이 · 거리 기억 · T · 빈 Enter 끝", () => {
|
||||
line("0,0", "100,0");
|
||||
type("o");
|
||||
expect(activeTool()).toBe(Tool.OFFSET);
|
||||
expect(promptLine()).toBe("간격띄우기 거리 지정 또는 [통과점(T)/지우기(E)] <통과점>:");
|
||||
enter(); // <통과점>
|
||||
expect(promptLine()).toBe("간격띄우기할 객체 선택 또는 [종료(E)/명령 취소(U)] <종료>:");
|
||||
click(50, 0);
|
||||
expect(promptLine()).toBe("통과점 지정 또는 [종료(E)/다중(M)/명령 취소(U)] <종료>:");
|
||||
type("30,20");
|
||||
expect(ys()).toEqual([0, 20]);
|
||||
expect(promptLine()).toBe("간격띄우기할 객체 선택 또는 [종료(E)/명령 취소(U)] <종료>:"); // 되풀이
|
||||
key("Escape");
|
||||
expect(activeTool()).toBe(Tool.SELECT);
|
||||
|
||||
type("o");
|
||||
expect(promptLine()).toBe("간격띄우기 거리 지정 또는 [통과점(T)/지우기(E)] <통과점>:"); // 통과점 기억
|
||||
type("0");
|
||||
expect(activeTool()).toBe(Tool.OFFSET); // 0 거절
|
||||
type("5");
|
||||
click(50, 0);
|
||||
expect(promptLine()).toBe(
|
||||
"간격띄우기할 면의 점 지정 또는 [종료(E)/다중(M)/명령 취소(U)] <종료>:",
|
||||
);
|
||||
type("50,-10");
|
||||
click(50, 20);
|
||||
type("50,30");
|
||||
expect(ys()).toEqual([-5, 0, 20, 25]);
|
||||
enter(); // <종료>
|
||||
expect(activeTool()).toBe(Tool.SELECT);
|
||||
|
||||
type("o");
|
||||
expect(promptLine()).toBe("간격띄우기 거리 지정 또는 [통과점(T)/지우기(E)] <5.0000>:");
|
||||
type("t");
|
||||
click(50, 25);
|
||||
expect(promptLine()).toBe("통과점 지정 또는 [종료(E)/다중(M)/명령 취소(U)] <종료>:");
|
||||
type("e"); // 종료
|
||||
expect(activeTool()).toBe(Tool.SELECT);
|
||||
type("o");
|
||||
expect(promptLine()).toBe("간격띄우기 거리 지정 또는 [통과점(T)/지우기(E)] <통과점>:");
|
||||
key("Escape");
|
||||
});
|
||||
|
||||
it("지우기(E) 예 · 다중(M) · 명령 취소(U) · 없는 옵션 L 안내", () => {
|
||||
line("0,0", "100,0");
|
||||
type("o");
|
||||
type("l");
|
||||
expect(activeTool()).toBe(Tool.OFFSET); // 도면층(L) = 이 CAD 에 없음
|
||||
type("e");
|
||||
expect(promptLine()).toBe(
|
||||
"간격띄우기 후 원본 객체를 지우시겠습니까? [예(Y)/아니오(N)] <아니오>:",
|
||||
);
|
||||
type("y");
|
||||
type("10");
|
||||
click(50, 0);
|
||||
type("50,5");
|
||||
expect(ys()).toEqual([10]); // 원본 지움
|
||||
key("Escape");
|
||||
|
||||
type("o");
|
||||
type("e");
|
||||
expect(promptLine()).toBe("간격띄우기 후 원본 객체를 지우시겠습니까? [예(Y)/아니오(N)] <예>:");
|
||||
type("n");
|
||||
enter(); // <10.0000>
|
||||
click(50, 10);
|
||||
type("m");
|
||||
expect(promptLine()).toBe("간격띄우기할 면의 점 지정 또는 [종료(E)/명령 취소(U)] <다음 객체>:");
|
||||
type("50,100");
|
||||
type("50,100");
|
||||
expect(ys()).toEqual([10, 20, 30]); // 방금 만든 것에서 되풀이 · 원본 둠
|
||||
type("u");
|
||||
expect(ys()).toEqual([10, 20]);
|
||||
expect(promptLine()).toBe("간격띄우기할 객체 선택 또는 [종료(E)/명령 취소(U)] <종료>:");
|
||||
type("u");
|
||||
type("u"); // 이 명령에서 만든 것 없음 = 그대로
|
||||
expect(ys()).toEqual([10]);
|
||||
enter();
|
||||
expect(activeTool()).toBe(Tool.SELECT);
|
||||
});
|
||||
});
|
||||
|
||||
/** ㄱ 자 — 가로 (0,0)~(100,0) · 세로 (80,−20)~(80,100) · 구석 (80, 0) */
|
||||
const corner = () => {
|
||||
line("0,0", "100,0");
|
||||
line("80,-20", "80,100");
|
||||
};
|
||||
|
||||
describe("FILLET", () => {
|
||||
it("물음 글 · R 기억 · 쌍 하나 뒤 끝", () => {
|
||||
corner();
|
||||
type("f");
|
||||
expect(promptLine()).toBe("첫 번째 객체 선택 또는 [명령 취소(U)/반지름(R)/자르기(T)/다중(M)]:");
|
||||
type("r");
|
||||
expect(promptLine()).toBe("모깎기 반지름 지정 <0.0000>:");
|
||||
type("10");
|
||||
expect(promptLine()).toBe("첫 번째 객체 선택 또는 [명령 취소(U)/반지름(R)/자르기(T)/다중(M)]:");
|
||||
click(30, 0);
|
||||
expect(promptLine()).toBe(
|
||||
"두 번째 객체 선택 또는 Shift 키를 누른 채 선택하여 구석 적용 또는 [반지름(R)]:",
|
||||
);
|
||||
click(80, 60);
|
||||
expect(segs()).toEqual([
|
||||
[0, 0, 70, 0],
|
||||
[80, 100, 80, 10],
|
||||
]);
|
||||
expect(entities("Arc")).toHaveLength(1);
|
||||
expect(activeTool()).toBe(Tool.SELECT);
|
||||
|
||||
type("f");
|
||||
type("r");
|
||||
expect(promptLine()).toBe("모깎기 반지름 지정 <10.0000>:");
|
||||
key("Escape");
|
||||
expect(activeTool()).toBe(Tool.SELECT);
|
||||
});
|
||||
|
||||
it("다중(M) · Shift = 반지름 0 구석 · 명령 취소(U) · 자르기(T) 자르지 않기 · 빈 Enter 끝", () => {
|
||||
corner();
|
||||
const before = segs();
|
||||
type("f");
|
||||
type("m");
|
||||
click(30, 0);
|
||||
click(80, 60, { shiftKey: true });
|
||||
expect(segs()).toEqual([
|
||||
[0, 0, 80, 0],
|
||||
[80, 100, 80, 0],
|
||||
]);
|
||||
expect(entities("Arc")).toHaveLength(0);
|
||||
expect(promptLine()).toBe("첫 번째 객체 선택 또는 [명령 취소(U)/반지름(R)/자르기(T)/다중(M)]:"); // 다중 되풀이
|
||||
type("u");
|
||||
expect(segs()).toEqual(before);
|
||||
type("t");
|
||||
expect(promptLine()).toBe("자르기 모드 옵션 입력 [자르기(T)/자르지 않기(N)] <자르기>:");
|
||||
type("n");
|
||||
click(30, 0);
|
||||
click(80, 60);
|
||||
expect(segs()).toEqual(before); // 안 자름
|
||||
expect(entities("Arc")).toHaveLength(1);
|
||||
enter();
|
||||
expect(activeTool()).toBe(Tool.SELECT);
|
||||
|
||||
type("f");
|
||||
type("t");
|
||||
expect(promptLine()).toBe("자르기 모드 옵션 입력 [자르기(T)/자르지 않기(N)] <자르지 않기>:");
|
||||
type("t");
|
||||
key("Escape");
|
||||
});
|
||||
});
|
||||
|
||||
describe("CHAMFER", () => {
|
||||
it("D 기억(둘째 기본 = 첫째) · 모따기 선 · 한 번 뒤 끝", () => {
|
||||
corner();
|
||||
type("cha");
|
||||
expect(activeTool()).toBe(Tool.CHAMFER);
|
||||
expect(promptLine()).toBe("첫 번째 선 선택 또는 [명령 취소(U)/거리(D)/자르기(T)/다중(M)]:");
|
||||
type("d");
|
||||
expect(promptLine()).toBe("첫 번째 모따기 거리 지정 <0.0000>:");
|
||||
type("10");
|
||||
expect(promptLine()).toBe("두 번째 모따기 거리 지정 <10.0000>:");
|
||||
enter();
|
||||
click(30, 0);
|
||||
expect(promptLine()).toBe(
|
||||
"두 번째 선 선택 또는 Shift 키를 누른 채 선택하여 구석 적용 또는 [거리(D)]:",
|
||||
);
|
||||
click(80, 60);
|
||||
expect(segs()).toEqual([
|
||||
[0, 0, 70, 0],
|
||||
[70, 0, 80, 10],
|
||||
[80, 100, 80, 10],
|
||||
]);
|
||||
expect(activeTool()).toBe(Tool.SELECT);
|
||||
|
||||
type("cha");
|
||||
type("d");
|
||||
expect(promptLine()).toBe("첫 번째 모따기 거리 지정 <10.0000>:");
|
||||
type("4");
|
||||
expect(promptLine()).toBe("두 번째 모따기 거리 지정 <4.0000>:");
|
||||
key("Escape");
|
||||
});
|
||||
|
||||
it("Shift = 거리 0 구석", () => {
|
||||
corner();
|
||||
type("cha");
|
||||
click(30, 0);
|
||||
click(80, 60, { shiftKey: true });
|
||||
expect(segs()).toEqual([
|
||||
[0, 0, 80, 0],
|
||||
[80, 100, 80, 0],
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("TRIM", () => {
|
||||
/** 가로 (0,0)~(100,0) · 세로 둘 x = 30 · 70 */
|
||||
const fence = () => {
|
||||
line("0,0", "100,0");
|
||||
line("30,-20", "30,20");
|
||||
line("70,-20", "70,20");
|
||||
};
|
||||
|
||||
it("되풀이 · 반경 밖 무시 · U · 지우기(R) · 빈 Enter 끝", () => {
|
||||
fence();
|
||||
type("tr");
|
||||
expect(activeTool()).toBe(Tool.ERASER);
|
||||
expect(promptLine()).toBe(
|
||||
"자를 객체 선택 또는 Shift 키를 누른 채 선택하여 연장 또는 [지우기(R)]:",
|
||||
);
|
||||
click(50, 0);
|
||||
expect(segs()).toContainEqual([0, 0, 30, 0]);
|
||||
expect(segs()).toContainEqual([70, 0, 100, 0]);
|
||||
expect(segs()).toHaveLength(4);
|
||||
click(50, 50); // 집기 반경 밖
|
||||
expect(segs()).toHaveLength(4);
|
||||
type("u");
|
||||
expect(segs()).toContainEqual([0, 0, 100, 0]);
|
||||
click(50, 0);
|
||||
click(30, 15);
|
||||
expect(segs()).toContainEqual([30, -20, 30, 0]);
|
||||
expect(activeTool()).toBe(Tool.ERASER); // 되풀이
|
||||
type("r");
|
||||
expect(promptLine()).toBe("지울 객체 선택:");
|
||||
click(70, 10);
|
||||
enter();
|
||||
expect(segs().some((s) => s[0] === 70 && s[2] === 70)).toBe(false);
|
||||
expect(promptLine()).toBe(
|
||||
"자를 객체 선택 또는 Shift 키를 누른 채 선택하여 연장 또는 [지우기(R)]:",
|
||||
);
|
||||
type("c");
|
||||
expect(activeTool()).toBe(Tool.ERASER); // 걸치기(C) = 이 CAD 에 없음
|
||||
enter();
|
||||
expect(activeTool()).toBe(Tool.SELECT);
|
||||
});
|
||||
|
||||
it("Esc 한 번 = 끝 · Shift = 연장", () => {
|
||||
line("0,0", "50,0");
|
||||
line("80,-20", "80,20");
|
||||
type("tr");
|
||||
click(40, 0, { shiftKey: true });
|
||||
expect(segs()).toContainEqual([0, 0, 80, 0]);
|
||||
key("Escape");
|
||||
expect(activeTool()).toBe(Tool.SELECT);
|
||||
});
|
||||
|
||||
it("사각형 변 = 원래 도면층 · 꼴(N9)", () => {
|
||||
type("rec");
|
||||
type("0,0");
|
||||
type("100,40");
|
||||
key("Escape");
|
||||
const rectangle = entities("Rectangle")[0];
|
||||
rectangle.layerId = "L2";
|
||||
rectangle.lineColor = "#ff0000";
|
||||
line("20,-10", "20,50");
|
||||
type("tr");
|
||||
click(60, 40);
|
||||
const sides = entities("Line").filter((entity) => entity.layerId === "L2");
|
||||
expect(sides.length).toBe(4);
|
||||
expect(sides.every((entity) => entity.lineColor === "#ff0000")).toBe(true);
|
||||
expect(segs()).toContainEqual([0, 40, 20, 40]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("EXTEND", () => {
|
||||
it("빠른 연장 · 되풀이 · Shift = 자르기 · 빈 Enter 끝", () => {
|
||||
line("0,0", "50,0");
|
||||
line("80,-20", "80,20");
|
||||
type("ex");
|
||||
expect(activeTool()).toBe(Tool.EXTEND);
|
||||
expect(promptLine()).toBe("연장할 객체 선택 또는 Shift 키를 누른 채 선택하여 자르기:");
|
||||
click(40, 0);
|
||||
expect(segs()).toContainEqual([0, 0, 80, 0]);
|
||||
click(80, 10, { shiftKey: true });
|
||||
expect(segs()).toContainEqual([80, -20, 80, 0]);
|
||||
expect(activeTool()).toBe(Tool.EXTEND);
|
||||
enter();
|
||||
expect(activeTool()).toBe(Tool.SELECT);
|
||||
});
|
||||
});
|
||||
|
||||
describe("BREAK", () => {
|
||||
it("누른 점 = 첫 끊기점 · 둘째 점 · 한 번 뒤 끝", () => {
|
||||
line("0,0", "100,0");
|
||||
type("br");
|
||||
expect(promptLine()).toBe("객체 선택:");
|
||||
click(20, 0);
|
||||
expect(promptLine()).toBe("두 번째 끊기점 지정 또는 [첫 번째 점(F)]:");
|
||||
type("60,0");
|
||||
expect(segs()).toEqual([
|
||||
[0, 0, 20, 0],
|
||||
[60, 0, 100, 0],
|
||||
]);
|
||||
expect(activeTool()).toBe(Tool.SELECT);
|
||||
});
|
||||
|
||||
it("첫 번째 점(F) · `@0,0` = 같은 점(틈 없이 둘)", () => {
|
||||
line("0,0", "100,0");
|
||||
type("br");
|
||||
click(50, 0);
|
||||
type("f");
|
||||
expect(promptLine()).toBe("첫 번째 끊기점 지정:");
|
||||
type("10,0");
|
||||
expect(promptLine()).toBe("두 번째 끊기점 지정:");
|
||||
type("30,0");
|
||||
expect(segs()).toEqual([
|
||||
[0, 0, 10, 0],
|
||||
[30, 0, 100, 0],
|
||||
]);
|
||||
|
||||
type("br");
|
||||
click(60, 0);
|
||||
type("@0,0");
|
||||
expect(segs()).toEqual([
|
||||
[0, 0, 10, 0],
|
||||
[30, 0, 60, 0],
|
||||
[60, 0, 100, 0],
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("JOIN", () => {
|
||||
it("고르기 물음 글 · 빈 Enter = 결합 · 한 번 뒤 끝", () => {
|
||||
line("0,0", "40,0");
|
||||
line("40,0", "40,40");
|
||||
type("j");
|
||||
expect(promptLine()).toBe("한 번에 결합할 원본 객체 또는 여러 객체 선택:");
|
||||
click(20, 0);
|
||||
click(40, 20);
|
||||
enter();
|
||||
expect(entities("Line")).toHaveLength(0);
|
||||
expect(entities("PolyLine")).toHaveLength(1);
|
||||
expect(activeTool()).toBe(Tool.SELECT);
|
||||
});
|
||||
});
|
||||
@@ -1,7 +1,8 @@
|
||||
/**
|
||||
* CAD 마우스 · 고르기 · 그립 · 직교 · 휠 — 오토캐드 조작(PLAN 337-2 마 · 요구 229-3 · 설계 4 장 · 6-3 마).
|
||||
* 누름 더함 · Shift 뺌 · 상자 더함/뺌 · 명령 중 끌기 상자 · 스침 · N7 · 그립 누름-뗌 대기 · 끌어 놓음 · 그립 숫자 ·
|
||||
* H2 문 · 직교 강제 · Shift 임시 직교 · 스냅 점 안 덮음 · F3 꺼도 직교 · H9 · 휠 두 번 범위 · 휠 기본 밂.
|
||||
* H2 문 · 직교 강제 · Shift 임시 직교 · 스냅 점 안 덮음 · F3 꺼도 직교 · H9 · 휠 두 번 범위 · 휠 기본 밂 ·
|
||||
* 마-2: 우클릭(쉼 반복 · 명령 중 Enter) · ZOOM(E · A · W · P · 2X · 'Z) · Shift+우클릭 스냅 메뉴 · 고르기 ALL/L/P · 그립 X · Esc.
|
||||
* 시험 쪽 flatten-js 는 다른 사본 — 점은 앱 코드(LINE 도구)로만 만들고 숫자로 판정.
|
||||
* 실행: cd B07_DesignDetail/openwebcad && npx vitest run --dir ../../resources/tester/cad mouse-acad
|
||||
*/
|
||||
@@ -14,6 +15,17 @@ vi.mock("react-toastify", () => ({
|
||||
import { Actor } from "xstate";
|
||||
import { InputController } from "../../../B07_DesignDetail/openwebcad/src/inputController/input-controller";
|
||||
import { zoomPrevious } from "../../../B07_DesignDetail/openwebcad/src/inputController/zoom-prompt";
|
||||
import {
|
||||
isSnapMenuOpen,
|
||||
pickSnapMenu,
|
||||
} from "../../../B07_DesignDetail/openwebcad/src/inputController/snap-menu";
|
||||
import { getSnapOverride } from "../../../B07_DesignDetail/openwebcad/src/inputController/input-snap-override";
|
||||
import { selectingActor } from "../../../B07_DesignDetail/openwebcad/src/inputController/selecting";
|
||||
import {
|
||||
currentNotice,
|
||||
currentPrompt,
|
||||
} from "../../../B07_DesignDetail/openwebcad/src/inputController/prompt-input";
|
||||
import { rememberSelection } from "../../../B07_DesignDetail/openwebcad/src/commands/prompt";
|
||||
import { wheelZoom } from "../../../B07_DesignDetail/openwebcad/src/inputController/input-wheel";
|
||||
import {
|
||||
getActiveToolActor,
|
||||
@@ -66,6 +78,11 @@ const release = (x: number, y: number, mods?: Mods) => {
|
||||
move(x, y, mods);
|
||||
controller.handleMouseUp(ev(x, y, mods));
|
||||
};
|
||||
/** 가운데 단추 누름 + 뗌(뗌 = 팬 끝) */
|
||||
const middle = (x: number, y: number) => {
|
||||
press(x, y, { button: 1 });
|
||||
controller.handleMouseUp(ev(x, y, { button: 1 }));
|
||||
};
|
||||
const click = (x: number, y: number, mods?: Mods) => {
|
||||
press(x, y, mods);
|
||||
release(x, y, mods);
|
||||
@@ -85,6 +102,8 @@ const key = (k: string) =>
|
||||
preventDefault: vi.fn(),
|
||||
stopPropagation: vi.fn(),
|
||||
} as unknown as KeyboardEvent);
|
||||
const rightClick = (shift = false) =>
|
||||
controller.handleMouseUp(ev(0, 0, { button: 2, shiftKey: shift }));
|
||||
const typeEnter = (text: string) => {
|
||||
for (const ch of text) key(ch);
|
||||
key("Enter");
|
||||
@@ -184,6 +203,20 @@ describe("그립 — 누르고-옮기고-누르기 · 끌기 · 숫자 · N7 ·
|
||||
expect(toolType()).toBe(Tool.SELECT);
|
||||
});
|
||||
|
||||
it("뜨거운 그립 Esc · 종료(X) = 놓음(안 바뀜) · 물음 줄", () => {
|
||||
click(50, 0);
|
||||
click(100, 0);
|
||||
expect(currentPrompt().line).toBe("** 신축 ** 신축점 지정 또는 [종료(X)]:");
|
||||
move(150, 90);
|
||||
key("Escape");
|
||||
expect(toolType()).toBe(Tool.SELECT);
|
||||
expectXy(segOf(a).end, 100, 0);
|
||||
click(100, 0); // 고름은 남음 — 다시 그립
|
||||
typeEnter("x");
|
||||
expect(toolType()).toBe(Tool.SELECT);
|
||||
expectXy(segOf(a).end, 100, 0);
|
||||
});
|
||||
|
||||
it("그립 끌어 놓음 = 놓은 자리", () => {
|
||||
click(50, 0);
|
||||
press(100, 0);
|
||||
@@ -292,17 +325,128 @@ describe("휠", () => {
|
||||
view.zoomToFitScreen = vi.fn(() => view.setScreenScale(3));
|
||||
const now = vi.spyOn(Date, "now");
|
||||
now.mockReturnValue(1000);
|
||||
press(10, 10, { button: 1 });
|
||||
middle(10, 10);
|
||||
now.mockReturnValue(1300);
|
||||
press(11, 11, { button: 1 });
|
||||
middle(11, 11);
|
||||
expect(view.zoomToFitScreen).toHaveBeenCalledTimes(1);
|
||||
expect(view.getScreenScale()).toBe(3);
|
||||
expect(zoomPrevious()).toBe(true);
|
||||
expect(view.getScreenScale()).toBe(1);
|
||||
now.mockReturnValue(5000);
|
||||
press(10, 10, { button: 1 });
|
||||
middle(10, 10);
|
||||
now.mockReturnValue(5600);
|
||||
press(10, 10, { button: 1 });
|
||||
middle(10, 10);
|
||||
expect(view.zoomToFitScreen).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe("우클릭 · 고르기 키워드(마-2)", () => {
|
||||
it("우클릭 = 쉴 때 앞 명령 반복 · 명령 중 Enter(고르기 끝)", () => {
|
||||
typeEnter("DRAWORDER");
|
||||
expect(toolType()).toBe(Tool.DRAWORDER_FRONT);
|
||||
key("Escape");
|
||||
expect(toolType()).toBe(Tool.SELECT);
|
||||
rightClick();
|
||||
expect(toolType()).toBe(Tool.DRAWORDER_FRONT);
|
||||
expect(selectingActor()).not.toBeNull();
|
||||
click(50, 0);
|
||||
rightClick(); // 고르기 끝 → 명령 확정
|
||||
expect(selectingActor()?.getSnapshot()?.context?.type).toBe(Tool.SELECT);
|
||||
expect(toolType()).toBe(Tool.SELECT); // 쉼으로
|
||||
});
|
||||
|
||||
it("고르기 ALL · L(최종) · P(이전) — 앞 고름에 더함 · 「n개를 찾음」", () => {
|
||||
typeEnter("DRAWORDER");
|
||||
typeEnter("all");
|
||||
expect(getSelectedEntityIds().sort()).toEqual([a, b].sort());
|
||||
expect(currentNotice()).toBe("2개를 찾음, 총 2개");
|
||||
key("Escape");
|
||||
setSelectedEntityIds([]);
|
||||
typeEnter("DRAWORDER");
|
||||
typeEnter("L");
|
||||
expect(getSelectedEntityIds()).toEqual([b]);
|
||||
key("Escape");
|
||||
setSelectedEntityIds([]);
|
||||
rememberSelection([a]);
|
||||
typeEnter("DRAWORDER");
|
||||
typeEnter("p");
|
||||
expect(getSelectedEntityIds()).toEqual([a]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("ZOOM 덧물음 · 스냅 메뉴(마-2)", () => {
|
||||
const view = () =>
|
||||
getScreenCanvasDrawController() as unknown as {
|
||||
zoomToFitScreen: () => void;
|
||||
setScreenScale: (s: number) => void;
|
||||
getScreenScale: () => number;
|
||||
getCanvasSize: () => Xy;
|
||||
};
|
||||
|
||||
it("Z 물음 줄 · E = 범위 · P = 앞 화면 · Enter = 실시간 안내", () => {
|
||||
view().zoomToFitScreen = vi.fn(() => view().setScreenScale(4));
|
||||
typeEnter("z");
|
||||
expect(currentPrompt().line).toBe(
|
||||
"윈도우 구석 지정, 축척 비율 (nX 또는 nXP) 입력 또는 [전체(A)/범위(E)/이전(P)/윈도우(W)] <실시간>:",
|
||||
);
|
||||
typeEnter("e");
|
||||
expect(view().getScreenScale()).toBe(4);
|
||||
expect(toolType()).toBe(Tool.SELECT);
|
||||
typeEnter("Z");
|
||||
typeEnter("P");
|
||||
expect(view().getScreenScale()).toBe(1);
|
||||
typeEnter("Z");
|
||||
typeEnter("C");
|
||||
expect(currentNotice()).toBe("「중심(C)」 옵션은 이 CAD 에 없음.");
|
||||
key("Enter");
|
||||
expect(currentNotice()).toBe("실시간 줌은 휠로.");
|
||||
expect(currentPrompt().kind).toBe("idle");
|
||||
});
|
||||
|
||||
it("2X = 배율 두 배(가운데 붙잡음) · W = 두 구석 · 누름 = 바로 윈도우", () => {
|
||||
typeEnter("Z");
|
||||
typeEnter("2x");
|
||||
expect(view().getScreenScale()).toBe(2);
|
||||
expect(zoomPrevious()).toBe(true); // 배율 · 원점 되돌림
|
||||
const size = view().getCanvasSize();
|
||||
typeEnter("Z");
|
||||
typeEnter("W");
|
||||
expect(currentPrompt().line).toBe("첫 번째 구석 지정:");
|
||||
click(0, 0);
|
||||
expect(currentPrompt().line).toBe("반대 구석 지정:");
|
||||
click(100, 50);
|
||||
expect(view().getScreenScale()).toBeCloseTo(Math.min(size.x / 100, size.y / 50), 6);
|
||||
expect(getSelectedEntityIds()).toEqual([]); // 고르기로 안 샘
|
||||
zoomPrevious();
|
||||
typeEnter("Z");
|
||||
click(0, 0);
|
||||
click(50, 100);
|
||||
expect(view().getScreenScale()).toBeCloseTo(Math.min(size.x / 50, size.y / 100), 6);
|
||||
zoomPrevious();
|
||||
});
|
||||
|
||||
it("'Z 투명 — 선 그리는 중 2X 뒤 선 이어 감", () => {
|
||||
lineTool();
|
||||
click(300, 300);
|
||||
typeEnter("'z");
|
||||
typeEnter("2X");
|
||||
expect(view().getScreenScale()).toBe(2);
|
||||
expect(toolType()).toBe(Tool.LINE);
|
||||
zoomPrevious();
|
||||
});
|
||||
|
||||
it("Shift+우클릭 = 점 물음에서만 스냅 메뉴 → 한 번 스냅", () => {
|
||||
rightClick(true); // 쉼 — 메뉴 없음(앞 명령 반복)
|
||||
expect(isSnapMenuOpen()).toBe(false);
|
||||
key("Escape"); // 반복된 명령 끝
|
||||
lineTool();
|
||||
click(300, 300);
|
||||
rightClick(true);
|
||||
expect(isSnapMenuOpen()).toBe(true);
|
||||
pickSnapMenu("END");
|
||||
expect(isSnapMenuOpen()).toBe(false);
|
||||
expect(getSnapOverride()).toBe("END");
|
||||
click(96, 4);
|
||||
expectXy(lastSeg().end, 100, 0);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
/**
|
||||
* 337 가-1 옵션 틀 — 물음 꼴 · 옵션 맞추기 · 친 글 사다리 · 거절 · Esc · 빈 Enter · 우클릭 · 명령 끝 · 엔진 v2 · 기억 · 덧물음 · Ctrl.
|
||||
* 337 가-1 · 가-2 옵션 틀 — 물음 꼴 · 옵션 맞추기 · 친 글 사다리 · 거절 · Esc · 빈 Enter · 우클릭 · 명령 끝 · 엔진 v2 · 기억 · 덧물음 · Ctrl.
|
||||
* 설계 `docs/raw/조사/2026-10-15_337_CAD오토캐드조작_설계.md` 2 장 · 6-3 가 줄.
|
||||
* 실행: cd B07_DesignDetail/openwebcad && npx vitest run --dir ../../resources/tester/cad prompt-frame
|
||||
*/
|
||||
@@ -35,6 +35,9 @@ import {
|
||||
setActiveToolActor,
|
||||
setSelectedEntityIds,
|
||||
} from "../../../B07_DesignDetail/openwebcad/src/state";
|
||||
import { commandCandidate } from "../../../B07_DesignDetail/openwebcad/src/inputController/input-history";
|
||||
import { getSnapOverride } from "../../../B07_DesignDetail/openwebcad/src/inputController/input-snap-override";
|
||||
import { getScreenCanvasDrawController } from "../../../B07_DesignDetail/openwebcad/src/state";
|
||||
import { Tool } from "../../../B07_DesignDetail/openwebcad/src/tools";
|
||||
import { createSequenceTool } from "../../../B07_DesignDetail/openwebcad/src/tools/factories/sequence-tool";
|
||||
import {
|
||||
@@ -556,3 +559,141 @@ describe("Ctrl · Alt 글자(H1)", () => {
|
||||
expect(activeTool()).toBe(Tool.LINE);
|
||||
});
|
||||
});
|
||||
|
||||
// ---- 가-2 — ↑↓ · Tab · FROM · M2P · @ · # · 스냅 이름 · H11 ----
|
||||
|
||||
const segs = () =>
|
||||
(entities("Line") as unknown as Array<{ segment: { start: Xy; end: Xy } }>).map(
|
||||
(line) => line.segment,
|
||||
);
|
||||
const expectSeg = (seg: { start: Xy; end: Xy }, x1: number, y1: number, x2: number, y2: number) => {
|
||||
expect(
|
||||
[seg.start.x, seg.start.y, seg.end.x, seg.end.y].map((v) => Math.round(v * 1000) / 1000),
|
||||
).toEqual([x1, y1, x2, y2]);
|
||||
};
|
||||
|
||||
describe("가-2 ↑↓ 기록 · 최근 값 · 후보(2-12 · H11)", () => {
|
||||
it("쉴 때 ↑↓ = 친 명령만(알 수 없는 글 · 알림 줄 안 섞임) · 끝을 지나면 빈 글", () => {
|
||||
type("l");
|
||||
key("Escape");
|
||||
type("zz"); // 알 수 없는 명령 — 기록 안 됨
|
||||
type("circle");
|
||||
key("Escape");
|
||||
key("ArrowUp");
|
||||
expect(inputText()).toBe("CIRCLE");
|
||||
key("ArrowUp");
|
||||
expect(inputText()).toBe("L");
|
||||
key("ArrowDown");
|
||||
expect(inputText()).toBe("CIRCLE");
|
||||
key("ArrowDown");
|
||||
expect(inputText()).toBe("");
|
||||
});
|
||||
|
||||
it("명령 중 ↑ = 그 물음 최근 값", () => {
|
||||
start(polygonLike);
|
||||
type("7");
|
||||
key("Escape");
|
||||
start(polygonLike);
|
||||
key("ArrowUp");
|
||||
expect(inputText()).toBe("7");
|
||||
key("Escape");
|
||||
});
|
||||
|
||||
it("Tab · ↑↓ = 후보 돌림(처음 친 글 기준) · Shift+↑ = 화면 옮김", () => {
|
||||
typed("ci");
|
||||
key("Tab");
|
||||
expect(inputText()).toBe(commandCandidate("CI", 0));
|
||||
key("Tab");
|
||||
expect(inputText()).toBe(commandCandidate("CI", 1));
|
||||
key("ArrowUp");
|
||||
expect(inputText()).toBe(commandCandidate("CI", 0));
|
||||
key("Escape");
|
||||
const before = getScreenCanvasDrawController().getScreenOffset().y;
|
||||
key("ArrowUp", { shiftKey: true });
|
||||
expect(getScreenCanvasDrawController().getScreenOffset().y).not.toBe(before);
|
||||
key("ArrowDown", { shiftKey: true });
|
||||
});
|
||||
});
|
||||
|
||||
describe("가-2 점 물음 보조(1-4)", () => {
|
||||
it("FROM — 기준점 · <간격띄우기> @dx,dy → 그 점이 원래 물음에", () => {
|
||||
type("l");
|
||||
type("from");
|
||||
expect(promptLine()).toBe("기준점:");
|
||||
type("10,10");
|
||||
expect(promptLine()).toBe("<간격띄우기>:");
|
||||
type("@5,0");
|
||||
type("@10,0");
|
||||
expectSeg(segs()[0], 15, 10, 25, 10);
|
||||
});
|
||||
|
||||
it("M2P — 두 점의 가운데", () => {
|
||||
type("l");
|
||||
type("m2p");
|
||||
expect(promptLine()).toBe("중간점의 첫 번째 점:");
|
||||
type("0,0");
|
||||
expect(promptLine()).toBe("중간점의 두 번째 점:");
|
||||
type("10,0");
|
||||
type("@0,5");
|
||||
expectSeg(segs()[0], 5, 0, 5, 5);
|
||||
});
|
||||
|
||||
it("@ = 마지막 점 · 기준점 없는 첫 점 @dx,dy = 마지막 점 기준 · # = 절대", () => {
|
||||
type("l");
|
||||
type("0,0");
|
||||
type("10,0");
|
||||
key("Escape");
|
||||
type("l");
|
||||
type("@");
|
||||
type("@0,10");
|
||||
expectSeg(segs()[1], 10, 0, 10, 10);
|
||||
key("Escape");
|
||||
type("l");
|
||||
type("@1,1"); // 마지막 점 (10, 10) 기준
|
||||
type("#0,0");
|
||||
expectSeg(segs()[2], 11, 11, 0, 0);
|
||||
});
|
||||
|
||||
it("스냅 이름 — 세 글자~영문 전체 · QUA · NOD · 없는 스냅 안내 · 두 글자는 아님", () => {
|
||||
type("l");
|
||||
for (const [text, snap] of [
|
||||
["endp", "END"],
|
||||
["endpoint", "END"],
|
||||
["qua", "QUA"],
|
||||
["node", "NOD"],
|
||||
["_non", "NON"],
|
||||
]) {
|
||||
type(text);
|
||||
expect(getSnapOverride()).toBe(snap);
|
||||
}
|
||||
type("tangent");
|
||||
expect(noticeLine()).toBe("「TAN」 스냅은 이 CAD 에 없음.");
|
||||
type("ext");
|
||||
expect(noticeLine()).toBe("「EXT」 스냅은 이 CAD 에 없음.");
|
||||
expect(activeTool()).toBe(Tool.LINE); // EXT · INS 가 명령으로 안 돎(N14)
|
||||
key("Escape");
|
||||
expect(getSnapOverride()).toBeNull();
|
||||
});
|
||||
|
||||
it("옵션 영문 전체 = 옵션 · 줄임 CEN = 스냅", () => {
|
||||
const seen: string[] = [];
|
||||
const tool = createSequenceTool({
|
||||
tool: Tool.DIST,
|
||||
steps: [
|
||||
{
|
||||
kind: "point",
|
||||
prompt: { text: "호의 두 번째 점 지정", options: [opt("CENTER", "C", "중심")] },
|
||||
options: { CENTER: { run: () => seen.push("CENTER") } },
|
||||
},
|
||||
],
|
||||
commit: () => {},
|
||||
});
|
||||
start(tool);
|
||||
type("cen");
|
||||
expect(getSnapOverride()).toBe("CEN");
|
||||
expect(seen).toEqual([]);
|
||||
type("center");
|
||||
expect(seen).toEqual(["CENTER"]);
|
||||
key("Escape");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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([]);
|
||||
});
|
||||
});
|
||||
@@ -3,6 +3,7 @@
|
||||
같음을 이김 · 맞음 · 다른 점 · 후보 · 철근 둘 · 고임돌 인력 10% · 맞춤셈 캐시 · 「셈 중」.
|
||||
실물 = 북부청 임도 · 사방(읽기만) · 층 · 캐시 폴더는 임시."""
|
||||
|
||||
import queue
|
||||
import shutil
|
||||
import time
|
||||
|
||||
@@ -95,8 +96,12 @@ def layer(tmp_path, monkeypatch, got):
|
||||
return tmp_path, brief["키"], cache
|
||||
|
||||
|
||||
def test_뒤_셈_캐시_셈_중(layer):
|
||||
def test_뒤_셈_캐시_셈_중(layer, monkeypatch):
|
||||
folder, key, cache = layer
|
||||
# 앞 시험들의 올리기가 쌓은 일감(북부청 통째)을 기다리지 않게 — 이 시험만의 일감 줄 · 일꾼
|
||||
monkeypatch.setattr(fit, "_JOBS", queue.Queue())
|
||||
monkeypatch.setattr(fit, "_PENDING", set())
|
||||
monkeypatch.setattr(fit, "_WORKER", {"스레드": None})
|
||||
before = master_hash()
|
||||
assert fit.result(folder, key) is None
|
||||
first = fit.ensure(folder, key)
|
||||
|
||||
Reference in New Issue
Block a user