feat(B07): CAD를 AutoCAD 명령 체계로 재구성한다 (조사표 1·2·3·5절)
저장소 사고로 잃은 4개 커밋(38eb7cd4·b8a11756·281528bf·f60b488d)의 작업물을 하나로 다시 담았다. 내용은 동일하다. ■ 구조 - commands/: 명령 정의(id·AutoCAD 별칭·글리프·도구/즉시실행)를 단일 소스로 두고 리본·명령행·단축키가 모두 이 레지스트리를 읽는다. tools/tool.consts.ts 폐지. - ribbon/: 탭→패널→명령 데이터(ribbon.config.ts)와 범용 렌더러 분리. AutoCAD 배치(홈·삽입·주석·뷰·출력)와 패널 확장(▾)을 따른다. - tools/factories/sequence-tool.ts: 점·숫자·문자·객체·선택 단계를 선언하면 xstate 머신을 만들어 주는 공장. 명령당 170줄 보일러플레이트 제거. - helpers/geometry/: 3점 호·정다각형·타원·스플라인·구름형·평행이동·해치 스캔선· 점렬 샘플링 등 상태 없는 순수 함수. - Toolbar 483줄을 QuickAccessBar/Ribbon/InspectorPanel/StatusBar/CommandLine/ ViewControls/PropertiesEditor/QuickProperties로 분해. ■ 명령 (조사표 기준) - 1절 그리기 21건, 2절 수정 27건, 3절 도면층·특성·그룹·유틸리티 39건 전부 반영. - 5절 주석 34건 중 26건 반영(문자·치수 16종·지시선·표·구름형·주석 축척). - HatchEntity 추가(solid·pattern·cross·gradient) + JSON 왕복, Layer에 색·선가중치· 선종류·동결·투명도 필드 추가, 그리기 루프가 동결·숨김·투명도를 반영. ■ 화면 실측에서 고친 결함 - 명령행 포커스 상태에서 ENTER가 도구로 가지 않던 문제 - 명령행 문자 입력이 접두사가 같은 명령으로 실행되던 문제 - 명령이 끝나도 입력을 계속 먹던 문제(점 입력 명령만 반복, 나머지는 선택 도구 복귀) - 시퀀스 단계 인덱스 오사용 9건 + 단계 값 종류 검사 추가 - 해치 내부를 클릭해도 선택되지 않던 문제 미반영 8건(맞춤법 검사·꺾기 치수·치수 끊기/재연관/검사 치수·기하공차·지시선 수집· 축척 리스트 편집)은 조사표 `반영` 열과 PLAN.md에 사유를 적었다.
This commit is contained in:
@@ -0,0 +1,91 @@
|
||||
/** 클립보드 명령 — 잘라내기·복사·붙여넣기 (조사표 3절 클립보드 패널) */
|
||||
import { toast } from 'react-toastify';
|
||||
import { copyToClipboard, hasClipboardContent, pasteFromClipboard } from '../../helpers/cad-clipboard';
|
||||
import {
|
||||
addEntities,
|
||||
deleteEntities,
|
||||
getSelectedEntities,
|
||||
setSelectedEntityIds,
|
||||
} from '../../state';
|
||||
import { Tool } from '../../tools';
|
||||
import { createSequenceTool } from '../factories/sequence-tool';
|
||||
|
||||
/** COPYCLIP — 선택 객체를 클립보드로 복사 */
|
||||
export function copySelectionToClipboard(): string {
|
||||
const count = copyToClipboard(getSelectedEntities());
|
||||
if (!count) {
|
||||
toast.info('복사할 객체를 먼저 선택하십시오.');
|
||||
return '선택 없음';
|
||||
}
|
||||
toast.success(`${count}개 객체를 복사했습니다.`);
|
||||
return `복사 ${count}개`;
|
||||
}
|
||||
|
||||
/** CUTCLIP — 선택 객체를 클립보드로 옮기고 도면에서 지운다 */
|
||||
export function cutSelectionToClipboard(): string {
|
||||
const selected = getSelectedEntities();
|
||||
const count = copyToClipboard(selected);
|
||||
if (!count) {
|
||||
toast.info('잘라낼 객체를 먼저 선택하십시오.');
|
||||
return '선택 없음';
|
||||
}
|
||||
deleteEntities(selected, true);
|
||||
setSelectedEntityIds([]);
|
||||
toast.success(`${count}개 객체를 잘라냈습니다.`);
|
||||
return `잘라내기 ${count}개`;
|
||||
}
|
||||
|
||||
/** PASTEORIG — 복사한 좌표 그대로 붙여넣기 */
|
||||
export function pasteAtOriginalCoordinates(): string {
|
||||
const pasted = pasteFromClipboard();
|
||||
if (!pasted.length) {
|
||||
toast.info('클립보드가 비어 있습니다.');
|
||||
return '클립보드 비어 있음';
|
||||
}
|
||||
addEntities(pasted, true);
|
||||
toast.success(`${pasted.length}개 객체를 원래 좌표에 붙여넣었습니다.`);
|
||||
return `붙여넣기 ${pasted.length}개`;
|
||||
}
|
||||
|
||||
/** PASTEBLOCK — 붙여넣으면서 하나의 그룹으로 묶는다 (블록 대체) */
|
||||
export function pasteAsGroup(): string {
|
||||
const pasted = pasteFromClipboard();
|
||||
if (!pasted.length) {
|
||||
toast.info('클립보드가 비어 있습니다.');
|
||||
return '클립보드 비어 있음';
|
||||
}
|
||||
const groupId = crypto.randomUUID();
|
||||
for (const entity of pasted) {
|
||||
entity.groupId = groupId;
|
||||
}
|
||||
addEntities(pasted, true);
|
||||
toast.success(`${pasted.length}개 객체를 그룹으로 붙여넣었습니다.`);
|
||||
return `그룹 붙여넣기 ${pasted.length}개`;
|
||||
}
|
||||
|
||||
export const copyBaseToolStateMachine = createSequenceTool({
|
||||
tool: Tool.COPYBASE,
|
||||
steps: [
|
||||
{ kind: 'selection', instructions: '복사할 객체를 선택한 뒤 ENTER.' },
|
||||
{ kind: 'point', instructions: '기준점을 지정하십시오.' },
|
||||
],
|
||||
commit: (input) => {
|
||||
const count = copyToClipboard(input.entities(0), input.point(1));
|
||||
setSelectedEntityIds([]);
|
||||
toast.success(`기준점과 함께 ${count}개 객체를 복사했습니다.`);
|
||||
},
|
||||
});
|
||||
|
||||
export const pasteToolStateMachine = createSequenceTool({
|
||||
tool: Tool.PASTECLIP,
|
||||
steps: [{ kind: 'point', instructions: '붙여넣을 위치를 지정하십시오.' }],
|
||||
preview: (input) => (hasClipboardContent() ? pasteFromClipboard(input.cursor) : []),
|
||||
commit: (input) => {
|
||||
const pasted = pasteFromClipboard(input.point(0));
|
||||
if (!pasted.length) {
|
||||
toast.info('클립보드가 비어 있습니다.');
|
||||
return;
|
||||
}
|
||||
addEntities(pasted, true);
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,161 @@
|
||||
/** 조회 명령 — 거리·반지름·각도·면적·좌표·리스트·계산기 (조사표 3절 유틸리티) */
|
||||
import { Circle, type Point } from '@flatten-js/core';
|
||||
import { toast } from 'react-toastify';
|
||||
import { EntityName } from '../../entities/Entity';
|
||||
import { entitiesToLoop } from '../../helpers/geometry/entity-loop';
|
||||
import { polylineLength, sampleEntityPoints } from '../../helpers/geometry/sample-entity';
|
||||
import { getSelectedEntities, setSelectedEntityIds } from '../../state';
|
||||
import { Tool } from '../../tools';
|
||||
import { createSequenceTool } from '../factories/sequence-tool';
|
||||
|
||||
const format = (value: number, digits = 3): string => value.toFixed(digits);
|
||||
const toDegrees = (radians: number): number => (radians * 180) / Math.PI;
|
||||
|
||||
/** 닫힌 점렬의 면적 (신발끈 공식) */
|
||||
export function polygonArea(points: Point[]): number {
|
||||
let total = 0;
|
||||
for (let index = 0; index < points.length; index++) {
|
||||
const current = points[index];
|
||||
const next = points[(index + 1) % points.length];
|
||||
total += current.x * next.y - next.x * current.y;
|
||||
}
|
||||
return Math.abs(total) / 2;
|
||||
}
|
||||
|
||||
export const distanceToolStateMachine = createSequenceTool({
|
||||
tool: Tool.DIST,
|
||||
steps: [
|
||||
{ kind: 'point', instructions: '거리를 잴 첫 점을 지정하십시오.' },
|
||||
{ kind: 'point', instructions: '두 번째 점을 지정하십시오.' },
|
||||
],
|
||||
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)}°`
|
||||
);
|
||||
},
|
||||
});
|
||||
|
||||
export const radiusToolStateMachine = createSequenceTool({
|
||||
tool: Tool.RADIUS_INQUIRY,
|
||||
helpers: false,
|
||||
steps: [{ kind: 'entity', instructions: '반지름을 잴 원 또는 호를 선택하십시오.' }],
|
||||
commit: (input) => {
|
||||
const shape = input.entity(0).getShape();
|
||||
const radius =
|
||||
shape instanceof Circle
|
||||
? Number(shape.r)
|
||||
: Number((shape as unknown as { r?: number })?.r ?? Number.NaN);
|
||||
if (!Number.isFinite(radius)) {
|
||||
toast.warn('원 또는 호를 선택하십시오.');
|
||||
return;
|
||||
}
|
||||
toast.info(`반지름 ${format(radius)} · 지름 ${format(radius * 2)}`);
|
||||
},
|
||||
});
|
||||
|
||||
export const angleToolStateMachine = createSequenceTool({
|
||||
tool: Tool.ANGLE_INQUIRY,
|
||||
steps: [
|
||||
{ kind: 'point', instructions: '각의 꼭짓점을 지정하십시오.' },
|
||||
{ kind: 'point', instructions: '첫 번째 방향의 점을 지정하십시오.' },
|
||||
{ kind: 'point', instructions: '두 번째 방향의 점을 지정하십시오.' },
|
||||
],
|
||||
commit: (input) => {
|
||||
const [vertex, first, second] = input.points();
|
||||
const angle =
|
||||
toDegrees(Math.atan2(second.y - vertex.y, second.x - vertex.x)) -
|
||||
toDegrees(Math.atan2(first.y - vertex.y, first.x - vertex.x));
|
||||
const normalized = ((angle % 360) + 360) % 360;
|
||||
toast.info(`각도 ${format(normalized, 2)}° (보각 ${format(360 - normalized, 2)}°)`);
|
||||
},
|
||||
});
|
||||
|
||||
export const areaToolStateMachine = createSequenceTool({
|
||||
tool: Tool.AREA,
|
||||
helpers: false,
|
||||
steps: [{ kind: 'selection', instructions: '면적을 잴 객체를 선택한 뒤 ENTER.' }],
|
||||
commit: (input) => {
|
||||
const loop = entitiesToLoop(input.entities(0));
|
||||
if (loop.length < 3) {
|
||||
toast.warn('닫힌 경계를 이루는 객체를 선택하십시오.');
|
||||
return;
|
||||
}
|
||||
toast.info(`면적 ${format(polygonArea(loop), 2)} · 둘레 ${format(polylineLength(loop), 2)}`);
|
||||
setSelectedEntityIds([]);
|
||||
},
|
||||
});
|
||||
|
||||
export const idPointToolStateMachine = createSequenceTool({
|
||||
tool: Tool.ID_POINT,
|
||||
steps: [{ kind: 'point', instructions: '좌표를 확인할 점을 지정하십시오.' }],
|
||||
commit: (input) => {
|
||||
const point = input.point(0);
|
||||
toast.info(`X = ${format(point.x)} · Y = ${format(point.y)}`);
|
||||
},
|
||||
});
|
||||
|
||||
/** LIST — 선택 객체의 요약 정보 */
|
||||
export function listSelectedEntities(): string {
|
||||
const selected = getSelectedEntities();
|
||||
if (!selected.length) {
|
||||
toast.info('객체를 먼저 선택하십시오.');
|
||||
return '선택 없음';
|
||||
}
|
||||
const lines = selected.slice(0, 20).map((entity) => {
|
||||
const points = sampleEntityPoints(entity);
|
||||
const box = entity.getBoundingBox();
|
||||
const size = `${format(box.xmax - box.xmin, 2)}×${format(box.ymax - box.ymin, 2)}`;
|
||||
const length = entity.getType() === EntityName.Point ? '-' : format(polylineLength(points), 2);
|
||||
return `${entity.getType()} · 길이 ${length} · 크기 ${size} · 색 ${entity.lineColor}`;
|
||||
});
|
||||
toast.info(lines.join('\n'), { autoClose: 8000 });
|
||||
return `리스트 ${selected.length}개`;
|
||||
}
|
||||
|
||||
const CALCULATOR_PATTERN = /^[0-9+\-*/(). %]+$/;
|
||||
|
||||
export const quickCalcToolStateMachine = createSequenceTool({
|
||||
tool: Tool.QUICKCALC,
|
||||
helpers: false,
|
||||
steps: [{ kind: 'text', instructions: '계산할 수식을 입력하십시오 (예: 12*3.5).' }],
|
||||
commit: (input) => {
|
||||
const expression = input.text(0).replace(/\s/g, '');
|
||||
if (!CALCULATOR_PATTERN.test(expression)) {
|
||||
toast.warn('숫자와 + - * / ( ) 만 사용할 수 있습니다.');
|
||||
return;
|
||||
}
|
||||
try {
|
||||
// 위 정규식으로 숫자·연산자만 남긴 문자열이라 임의 코드가 들어올 수 없다
|
||||
const result = Function(`"use strict";return (${expression})`)() as number;
|
||||
toast.success(`${expression} = ${result}`);
|
||||
} catch {
|
||||
toast.error('수식을 계산할 수 없습니다.');
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
export const measureGeomToolStateMachine = createSequenceTool({
|
||||
tool: Tool.MEASUREGEOM,
|
||||
helpers: false,
|
||||
steps: [
|
||||
{
|
||||
kind: 'text',
|
||||
instructions: '측정 항목을 입력하십시오 (DIST · RADIUS · ANGLE · AREA) <DIST>.',
|
||||
defaultValue: 'DIST',
|
||||
},
|
||||
],
|
||||
commit: (input) => {
|
||||
const mode = input.text(0).trim().toUpperCase();
|
||||
// 각 측정은 이미 개별 명령으로 있으므로 그쪽으로 넘긴다
|
||||
const target = ['DIST', 'RADIUS', 'ANGLE', 'AREA'].includes(mode) ? mode : 'DIST';
|
||||
// 이 명령이 끝나며 선택 도구로 돌아가는 전환보다 뒤에 실행되어야 한다
|
||||
void import('../../commands/run-command').then(({ runCommandInput }) => {
|
||||
setTimeout(() => runCommandInput(target), 0);
|
||||
});
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,265 @@
|
||||
/** 도면층 명령 (조사표 3절 도면층 패널) */
|
||||
import { toast } from 'react-toastify';
|
||||
import type { Layer } from '../../App.types';
|
||||
import { openInspector } from '../../components/ui-state';
|
||||
import type { Entity } from '../../entities/Entity';
|
||||
import {
|
||||
popLayerHistory,
|
||||
pushLayerHistory,
|
||||
restoreLayerState,
|
||||
saveLayerState,
|
||||
} from '../../helpers/layer-history';
|
||||
import {
|
||||
deleteEntities,
|
||||
getActiveLayerId,
|
||||
getEntities,
|
||||
getLayers,
|
||||
setActiveLayerId,
|
||||
setEntities,
|
||||
setLayers,
|
||||
setSelectedEntityIds,
|
||||
} from '../../state';
|
||||
import { Tool } from '../../tools';
|
||||
import { createSequenceTool } from '../factories/sequence-tool';
|
||||
|
||||
/** 도면층을 바꾸기 전에 직전 상태를 기록한다 (LAYERP가 되돌린다) */
|
||||
function updateLayers(mutate: (layers: Layer[]) => Layer[]): void {
|
||||
const current = getLayers();
|
||||
pushLayerHistory(current);
|
||||
setLayers(mutate(current.map((layer) => ({ ...layer }))));
|
||||
}
|
||||
|
||||
const layerNameOf = (layerId: string): string =>
|
||||
getLayers().find((layer) => layer.id === layerId)?.name ?? layerId;
|
||||
|
||||
/** LAYER — 도면층 특성 관리자(좌측 팔레트)를 연다 */
|
||||
export function openLayerManager(): string {
|
||||
openInspector('layers');
|
||||
return '도면층 관리자';
|
||||
}
|
||||
|
||||
export const layerCurrentToolStateMachine = createSequenceTool({
|
||||
tool: Tool.LAYER_CURRENT,
|
||||
helpers: false,
|
||||
steps: [{ kind: 'entity', instructions: '현재 도면층으로 지정할 객체를 선택하십시오.' }],
|
||||
commit: (input) => {
|
||||
const layerId = input.entity(0).layerId;
|
||||
setActiveLayerId(layerId);
|
||||
toast.success(`현재 도면층: ${layerNameOf(layerId)}`);
|
||||
},
|
||||
});
|
||||
|
||||
export const layerOffToolStateMachine = createSequenceTool({
|
||||
tool: Tool.LAYOFF,
|
||||
helpers: false,
|
||||
steps: [{ kind: 'entity', instructions: '끌 도면층의 객체를 선택하십시오.' }],
|
||||
commit: (input) => {
|
||||
const layerId = input.entity(0).layerId;
|
||||
updateLayers((layers) =>
|
||||
layers.map((layer) => (layer.id === layerId ? { ...layer, isVisible: false } : layer))
|
||||
);
|
||||
toast.info(`도면층 끄기: ${layerNameOf(layerId)}`);
|
||||
},
|
||||
});
|
||||
|
||||
/** LAYON — 모든 도면층 켜기 */
|
||||
export function turnAllLayersOn(): string {
|
||||
updateLayers((layers) => layers.map((layer) => ({ ...layer, isVisible: true })));
|
||||
toast.success('모든 도면층을 켰습니다.');
|
||||
return '모든 도면층 켜기';
|
||||
}
|
||||
|
||||
export const layerFreezeToolStateMachine = createSequenceTool({
|
||||
tool: Tool.LAYFRZ,
|
||||
helpers: false,
|
||||
steps: [{ kind: 'entity', instructions: '동결할 도면층의 객체를 선택하십시오.' }],
|
||||
commit: (input) => {
|
||||
const layerId = input.entity(0).layerId;
|
||||
if (layerId === getActiveLayerId()) {
|
||||
toast.warn('현재 도면층은 동결할 수 없습니다.');
|
||||
return;
|
||||
}
|
||||
updateLayers((layers) =>
|
||||
layers.map((layer) => (layer.id === layerId ? { ...layer, isFrozen: true } : layer))
|
||||
);
|
||||
toast.info(`도면층 동결: ${layerNameOf(layerId)}`);
|
||||
},
|
||||
});
|
||||
|
||||
/** LAYTHW — 모든 도면층 동결 해제 */
|
||||
export function thawAllLayers(): string {
|
||||
updateLayers((layers) => layers.map((layer) => ({ ...layer, isFrozen: false })));
|
||||
toast.success('모든 도면층을 동결 해제했습니다.');
|
||||
return '동결 해제';
|
||||
}
|
||||
|
||||
export const layerLockToolStateMachine = createSequenceTool({
|
||||
tool: Tool.LAYLCK,
|
||||
helpers: false,
|
||||
steps: [{ kind: 'entity', instructions: '잠글 도면층의 객체를 선택하십시오.' }],
|
||||
commit: (input) => {
|
||||
const layerId = input.entity(0).layerId;
|
||||
updateLayers((layers) =>
|
||||
layers.map((layer) => (layer.id === layerId ? { ...layer, isLocked: true } : layer))
|
||||
);
|
||||
toast.info(`도면층 잠금: ${layerNameOf(layerId)}`);
|
||||
},
|
||||
});
|
||||
|
||||
export const layerUnlockToolStateMachine = createSequenceTool({
|
||||
tool: Tool.LAYULK,
|
||||
helpers: false,
|
||||
steps: [{ kind: 'entity', instructions: '잠금 해제할 도면층의 객체를 선택하십시오.' }],
|
||||
commit: (input) => {
|
||||
const layerId = input.entity(0).layerId;
|
||||
updateLayers((layers) =>
|
||||
layers.map((layer) => (layer.id === layerId ? { ...layer, isLocked: false } : layer))
|
||||
);
|
||||
toast.success(`도면층 잠금 해제: ${layerNameOf(layerId)}`);
|
||||
},
|
||||
});
|
||||
|
||||
export const layerIsolateToolStateMachine = createSequenceTool({
|
||||
tool: Tool.LAYISO,
|
||||
helpers: false,
|
||||
steps: [{ kind: 'entity', instructions: '남길 도면층의 객체를 선택하십시오.' }],
|
||||
commit: (input) => {
|
||||
const layerId = input.entity(0).layerId;
|
||||
updateLayers((layers) =>
|
||||
layers.map((layer) => ({ ...layer, isVisible: layer.id === layerId }))
|
||||
);
|
||||
toast.info(`도면층 분리: ${layerNameOf(layerId)}`);
|
||||
},
|
||||
});
|
||||
|
||||
/** LAYUNISO — 도면층 분리 해제 */
|
||||
export function unisolateLayers(): string {
|
||||
updateLayers((layers) => layers.map((layer) => ({ ...layer, isVisible: true })));
|
||||
toast.success('도면층 분리를 해제했습니다.');
|
||||
return '도면층 분리 해제';
|
||||
}
|
||||
|
||||
/** LAYERP — 직전 도면층 상태로 되돌리기 */
|
||||
export function restorePreviousLayers(): string {
|
||||
const previous = popLayerHistory();
|
||||
if (!previous) {
|
||||
toast.info('되돌릴 도면층 상태가 없습니다.');
|
||||
return '되돌릴 상태 없음';
|
||||
}
|
||||
setLayers(previous);
|
||||
toast.success('직전 도면층 상태로 되돌렸습니다.');
|
||||
return '직전 도면층 상태';
|
||||
}
|
||||
|
||||
export const layerStateSaveToolStateMachine = createSequenceTool({
|
||||
tool: Tool.LAYERSTATE,
|
||||
helpers: false,
|
||||
steps: [{ kind: 'text', instructions: '저장할 도면층 상태 이름을 입력하십시오.' }],
|
||||
commit: (input) => {
|
||||
saveLayerState(input.text(0), getLayers());
|
||||
toast.success(`도면층 상태 저장: ${input.text(0)}`);
|
||||
},
|
||||
});
|
||||
|
||||
export const layerStateRestoreToolStateMachine = createSequenceTool({
|
||||
tool: Tool.LAYERSTATE_RESTORE,
|
||||
helpers: false,
|
||||
steps: [{ kind: 'text', instructions: '복원할 도면층 상태 이름을 입력하십시오.' }],
|
||||
commit: (input) => {
|
||||
const restored = restoreLayerState(input.text(0));
|
||||
if (!restored) {
|
||||
toast.warn('그 이름으로 저장한 도면층 상태가 없습니다.');
|
||||
return;
|
||||
}
|
||||
pushLayerHistory(getLayers());
|
||||
setLayers(restored);
|
||||
toast.success(`도면층 상태 복원: ${input.text(0)}`);
|
||||
},
|
||||
});
|
||||
|
||||
export const layerMatchToolStateMachine = createSequenceTool({
|
||||
tool: Tool.LAYMCH,
|
||||
helpers: false,
|
||||
steps: [
|
||||
{ kind: 'selection', instructions: '옮길 객체를 선택한 뒤 ENTER.' },
|
||||
{ kind: 'entity', instructions: '대상 도면층의 객체를 선택하십시오.' },
|
||||
],
|
||||
commit: (input) => {
|
||||
const layerId = input.entity(1).layerId;
|
||||
for (const entity of input.entities(0)) {
|
||||
entity.layerId = layerId;
|
||||
}
|
||||
setEntities([...getEntities()], true);
|
||||
setSelectedEntityIds([]);
|
||||
toast.success(`${input.entities(0).length}개 객체를 ${layerNameOf(layerId)}(으)로 옮겼습니다.`);
|
||||
},
|
||||
});
|
||||
|
||||
export const layerToCurrentToolStateMachine = createSequenceTool({
|
||||
tool: Tool.LAYCUR,
|
||||
helpers: false,
|
||||
steps: [{ kind: 'selection', instructions: '현재 도면층으로 옮길 객체를 선택한 뒤 ENTER.' }],
|
||||
commit: (input) => {
|
||||
const layerId = getActiveLayerId();
|
||||
for (const entity of input.entities(0)) {
|
||||
entity.layerId = layerId;
|
||||
}
|
||||
setEntities([...getEntities()], true);
|
||||
setSelectedEntityIds([]);
|
||||
toast.success(`${input.entities(0).length}개 객체를 현재 도면층으로 옮겼습니다.`);
|
||||
},
|
||||
});
|
||||
|
||||
export const layerMergeToolStateMachine = createSequenceTool({
|
||||
tool: Tool.LAYMRG,
|
||||
helpers: false,
|
||||
steps: [
|
||||
{ kind: 'entity', instructions: '병합할(사라질) 도면층의 객체를 선택하십시오.' },
|
||||
{ kind: 'entity', instructions: '대상 도면층의 객체를 선택하십시오.' },
|
||||
],
|
||||
commit: (input) => {
|
||||
const sourceId = input.entity(0).layerId;
|
||||
const targetId = input.entity(1).layerId;
|
||||
if (sourceId === targetId) {
|
||||
toast.warn('같은 도면층입니다.');
|
||||
return;
|
||||
}
|
||||
for (const entity of getEntities()) {
|
||||
if (entity.layerId === sourceId) entity.layerId = targetId;
|
||||
}
|
||||
setEntities([...getEntities()], true);
|
||||
updateLayers((layers) => layers.filter((layer) => layer.id !== sourceId));
|
||||
if (getActiveLayerId() === sourceId) setActiveLayerId(targetId);
|
||||
toast.success(`도면층을 ${layerNameOf(targetId)}(으)로 병합했습니다.`);
|
||||
},
|
||||
});
|
||||
|
||||
export const layerDeleteToolStateMachine = createSequenceTool({
|
||||
tool: Tool.LAYDEL,
|
||||
helpers: false,
|
||||
steps: [{ kind: 'entity', instructions: '삭제할 도면층의 객체를 선택하십시오.' }],
|
||||
commit: (input) => {
|
||||
const layerId = input.entity(0).layerId;
|
||||
if (getLayers().length <= 1) {
|
||||
toast.warn('마지막 도면층은 삭제할 수 없습니다.');
|
||||
return;
|
||||
}
|
||||
const doomed: Entity[] = getEntities().filter((entity) => entity.layerId === layerId);
|
||||
deleteEntities(doomed, true);
|
||||
updateLayers((layers) => layers.filter((layer) => layer.id !== layerId));
|
||||
if (getActiveLayerId() === layerId) setActiveLayerId(getLayers()[0].id);
|
||||
toast.success(`도면층과 객체 ${doomed.length}개를 삭제했습니다.`);
|
||||
},
|
||||
});
|
||||
|
||||
/** LAYWALK — 부를 때마다 다음 도면층 하나만 보여 준다 */
|
||||
let walkIndex = -1;
|
||||
export function walkLayers(): string {
|
||||
const layers = getLayers();
|
||||
if (!layers.length) return '도면층 없음';
|
||||
walkIndex = (walkIndex + 1) % layers.length;
|
||||
const target = layers[walkIndex];
|
||||
updateLayers((all) => all.map((layer) => ({ ...layer, isVisible: layer.id === target.id })));
|
||||
toast.info(`도면층 탐색: ${target.name} (${walkIndex + 1}/${layers.length})`);
|
||||
return `도면층 탐색 ${target.name}`;
|
||||
}
|
||||
@@ -0,0 +1,125 @@
|
||||
/** 특성 명령 — 투명도와 특성 팔레트 열기 (조사표 3절 특성 패널) */
|
||||
import { toast } from 'react-toastify';
|
||||
import { openInspector, setQuickPropertiesVisible, isQuickPropertiesVisible } from '../../components/ui-state';
|
||||
import { getEntities, setEntities, setSelectedEntityIds } from '../../state';
|
||||
import { Tool } from '../../tools';
|
||||
import { createSequenceTool } from '../factories/sequence-tool';
|
||||
|
||||
/** PROPERTIES — 좌측 특성 팔레트를 연다 */
|
||||
export function openPropertiesPalette(): string {
|
||||
openInspector('properties');
|
||||
return '특성 팔레트';
|
||||
}
|
||||
|
||||
/** QUICKPROPERTIES — 선택 객체 옆의 간이 특성 상자를 켜고 끈다 */
|
||||
export function toggleQuickProperties(): string {
|
||||
const next = !isQuickPropertiesVisible();
|
||||
setQuickPropertiesVisible(next);
|
||||
return next ? '빠른 특성 켜기' : '빠른 특성 끄기';
|
||||
}
|
||||
|
||||
/** 이름·16진수 색을 모두 받는다 (AutoCAD의 색 이름 관행) */
|
||||
const NAMED_COLORS: Record<string, string> = {
|
||||
RED: '#ff0000',
|
||||
YELLOW: '#ffff00',
|
||||
GREEN: '#00ff00',
|
||||
CYAN: '#00ffff',
|
||||
BLUE: '#0000ff',
|
||||
MAGENTA: '#ff00ff',
|
||||
WHITE: '#ffffff',
|
||||
BLACK: '#000000',
|
||||
GRAY: '#808080',
|
||||
};
|
||||
|
||||
const LINE_DASHES: Record<string, number[] | undefined> = {
|
||||
실선: undefined,
|
||||
SOLID: undefined,
|
||||
파선: [10, 5],
|
||||
DASHED: [10, 5],
|
||||
'1점쇄선': [12, 4, 2, 4],
|
||||
DASHDOT: [12, 4, 2, 4],
|
||||
점선: [2, 4],
|
||||
DOTTED: [2, 4],
|
||||
};
|
||||
|
||||
export const colorToolStateMachine = createSequenceTool({
|
||||
tool: Tool.COLOR,
|
||||
helpers: false,
|
||||
steps: [
|
||||
{ kind: 'selection', instructions: '색을 바꿀 객체를 선택한 뒤 ENTER.' },
|
||||
{ kind: 'text', instructions: '색을 입력하십시오 (#ff0000 또는 RED).' },
|
||||
],
|
||||
commit: (input) => {
|
||||
const raw = input.text(1).trim();
|
||||
const color = NAMED_COLORS[raw.toUpperCase()] ?? (raw.startsWith('#') ? raw : '');
|
||||
if (!color) {
|
||||
toast.warn('#rrggbb 형식이나 색 이름을 입력하십시오.');
|
||||
return;
|
||||
}
|
||||
for (const entity of input.entities(0)) {
|
||||
entity.lineColor = color;
|
||||
}
|
||||
setEntities([...getEntities()], true);
|
||||
setSelectedEntityIds([]);
|
||||
toast.success(`색상 ${color}`);
|
||||
},
|
||||
});
|
||||
|
||||
export const lineTypeToolStateMachine = createSequenceTool({
|
||||
tool: Tool.LINETYPE,
|
||||
helpers: false,
|
||||
steps: [
|
||||
{ kind: 'selection', instructions: '선종류를 바꿀 객체를 선택한 뒤 ENTER.' },
|
||||
{ kind: 'text', instructions: '선종류를 입력하십시오 (실선·파선·1점쇄선·점선).', defaultValue: '실선' },
|
||||
],
|
||||
commit: (input) => {
|
||||
const key = input.text(1).trim();
|
||||
if (!(key in LINE_DASHES) && !(key.toUpperCase() in LINE_DASHES)) {
|
||||
toast.warn('실선·파선·1점쇄선·점선 중에서 입력하십시오.');
|
||||
return;
|
||||
}
|
||||
const dash = LINE_DASHES[key] ?? LINE_DASHES[key.toUpperCase()];
|
||||
for (const entity of input.entities(0)) {
|
||||
entity.lineDash = dash ? [...dash] : undefined;
|
||||
}
|
||||
setEntities([...getEntities()], true);
|
||||
setSelectedEntityIds([]);
|
||||
toast.success(`선종류 ${key}`);
|
||||
},
|
||||
});
|
||||
|
||||
export const lineWeightToolStateMachine = createSequenceTool({
|
||||
tool: Tool.LWEIGHT,
|
||||
helpers: false,
|
||||
steps: [
|
||||
{ kind: 'selection', instructions: '선가중치를 바꿀 객체를 선택한 뒤 ENTER.' },
|
||||
{ kind: 'number', instructions: '선 굵기를 입력하십시오 <1>.', defaultValue: 1 },
|
||||
],
|
||||
commit: (input) => {
|
||||
const width = Math.max(1, Math.round(input.number(1)));
|
||||
for (const entity of input.entities(0)) {
|
||||
entity.lineWidth = width;
|
||||
}
|
||||
setEntities([...getEntities()], true);
|
||||
setSelectedEntityIds([]);
|
||||
toast.success(`선가중치 ${width}px`);
|
||||
},
|
||||
});
|
||||
|
||||
export const transparencyToolStateMachine = createSequenceTool({
|
||||
tool: Tool.TRANSPARENCY,
|
||||
helpers: false,
|
||||
steps: [
|
||||
{ kind: 'selection', instructions: '투명도를 바꿀 객체를 선택한 뒤 ENTER.' },
|
||||
{ kind: 'number', instructions: '투명도를 입력하십시오 (0~90) <0>.', defaultValue: 0 },
|
||||
],
|
||||
commit: (input) => {
|
||||
const percent = Math.min(90, Math.max(0, input.number(1)));
|
||||
for (const entity of input.entities(0)) {
|
||||
entity.opacity = 1 - percent / 100;
|
||||
}
|
||||
setEntities([...getEntities()], true);
|
||||
setSelectedEntityIds([]);
|
||||
toast.success(`투명도 ${percent}%를 적용했습니다.`);
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,122 @@
|
||||
/** 선택·표시 명령 — 빠른 선택·유사 선택·객체 분리·그룹 (조사표 3절) */
|
||||
import { toast } from 'react-toastify';
|
||||
import type { Entity } from '../../entities/Entity';
|
||||
import { hideEntities, isolateEntities, showAllEntities } from '../../helpers/visibility';
|
||||
import { getEntities, setEntities, setSelectedEntityIds } from '../../state';
|
||||
import { Tool } from '../../tools';
|
||||
import { createSequenceTool } from '../factories/sequence-tool';
|
||||
|
||||
export const qSelectToolStateMachine = createSequenceTool({
|
||||
tool: Tool.QSELECT,
|
||||
helpers: false,
|
||||
steps: [
|
||||
{
|
||||
kind: 'text',
|
||||
instructions: '선택할 객체 유형을 입력하십시오 (Line · Circle · Arc · Text · PolyLine · Hatch).',
|
||||
},
|
||||
],
|
||||
commit: (input) => {
|
||||
const wanted = input.text(0).trim().toLowerCase();
|
||||
const matched = getEntities().filter(
|
||||
(entity) => entity.getType().toLowerCase() === wanted
|
||||
);
|
||||
if (!matched.length) {
|
||||
toast.info('조건에 맞는 객체가 없습니다.');
|
||||
return;
|
||||
}
|
||||
setSelectedEntityIds(matched.map((entity) => entity.id));
|
||||
toast.success(`${matched.length}개 객체를 선택했습니다.`);
|
||||
},
|
||||
});
|
||||
|
||||
export const selectSimilarToolStateMachine = createSequenceTool({
|
||||
tool: Tool.SELECTSIMILAR,
|
||||
helpers: false,
|
||||
steps: [{ kind: 'entity', instructions: '기준이 될 객체를 선택하십시오.' }],
|
||||
commit: (input) => {
|
||||
const reference = input.entity(0);
|
||||
const similar = getEntities().filter(
|
||||
(entity) =>
|
||||
entity.getType() === reference.getType() &&
|
||||
entity.lineColor === reference.lineColor &&
|
||||
entity.layerId === reference.layerId
|
||||
);
|
||||
setSelectedEntityIds(similar.map((entity) => entity.id));
|
||||
toast.success(`유사 객체 ${similar.length}개를 선택했습니다.`);
|
||||
},
|
||||
});
|
||||
|
||||
export const isolateObjectsToolStateMachine = createSequenceTool({
|
||||
tool: Tool.ISOLATEOBJECTS,
|
||||
helpers: false,
|
||||
steps: [{ kind: 'selection', instructions: '남길 객체를 선택한 뒤 ENTER.' }],
|
||||
commit: (input) => {
|
||||
const selected = input.entities(0);
|
||||
if (!selected.length) return;
|
||||
isolateEntities(selected, getEntities());
|
||||
setSelectedEntityIds([]);
|
||||
toast.info(`${selected.length}개 객체만 표시합니다.`);
|
||||
},
|
||||
});
|
||||
|
||||
export const hideObjectsToolStateMachine = createSequenceTool({
|
||||
tool: Tool.HIDEOBJECTS,
|
||||
helpers: false,
|
||||
steps: [{ kind: 'selection', instructions: '숨길 객체를 선택한 뒤 ENTER.' }],
|
||||
commit: (input) => {
|
||||
const selected = input.entities(0);
|
||||
if (!selected.length) return;
|
||||
hideEntities(selected);
|
||||
setSelectedEntityIds([]);
|
||||
toast.info(`${selected.length}개 객체를 숨겼습니다.`);
|
||||
},
|
||||
});
|
||||
|
||||
/** UNISOLATEOBJECTS — 숨긴 객체를 모두 되살린다 */
|
||||
export function unhideAllObjects(): string {
|
||||
showAllEntities();
|
||||
toast.success('숨긴 객체를 모두 표시했습니다.');
|
||||
return '객체 분리 종료';
|
||||
}
|
||||
|
||||
export const groupToolStateMachine = createSequenceTool({
|
||||
tool: Tool.GROUP,
|
||||
helpers: false,
|
||||
steps: [{ kind: 'selection', instructions: '그룹으로 묶을 객체를 선택한 뒤 ENTER.' }],
|
||||
commit: (input) => {
|
||||
const selected = input.entities(0);
|
||||
if (selected.length < 2) {
|
||||
toast.warn('두 개 이상의 객체를 선택하십시오.');
|
||||
return;
|
||||
}
|
||||
const groupId = crypto.randomUUID();
|
||||
for (const entity of selected) {
|
||||
entity.groupId = groupId;
|
||||
}
|
||||
setEntities([...getEntities()], true);
|
||||
setSelectedEntityIds([]);
|
||||
toast.success(`${selected.length}개 객체를 그룹으로 묶었습니다.`);
|
||||
},
|
||||
});
|
||||
|
||||
export const ungroupToolStateMachine = createSequenceTool({
|
||||
tool: Tool.UNGROUP,
|
||||
helpers: false,
|
||||
steps: [{ kind: 'selection', instructions: '그룹을 해제할 객체를 선택한 뒤 ENTER.' }],
|
||||
commit: (input) => {
|
||||
const selected: Entity[] = input.entities(0);
|
||||
const groupIds = new Set(selected.map((entity) => entity.groupId).filter(Boolean));
|
||||
if (!groupIds.size) {
|
||||
toast.info('그룹으로 묶인 객체가 없습니다.');
|
||||
return;
|
||||
}
|
||||
for (const entity of getEntities()) {
|
||||
if (entity.groupId && groupIds.has(entity.groupId)) {
|
||||
entity.groupId = undefined;
|
||||
}
|
||||
}
|
||||
setEntities([...getEntities()], true);
|
||||
setSelectedEntityIds([]);
|
||||
toast.success('그룹을 해제했습니다.');
|
||||
},
|
||||
});
|
||||
Reference in New Issue
Block a user