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:
@@ -4,9 +4,9 @@
|
||||
|
||||
:root {
|
||||
--cad-title-height: 42px;
|
||||
--cad-ribbon-height: 82px;
|
||||
/* 하단 명령어 입력창 제거 → 높이 0으로 캔버스가 공간을 회수 (추후 사용성 개선 예정) */
|
||||
--cad-command-height: 0px;
|
||||
--cad-ribbon-height: 116px;
|
||||
/* 명령행 높이 — 상태막대의 [명령행] 토글이 0px로 바꾼다 */
|
||||
--cad-command-height: 86px;
|
||||
--cad-status-height: 28px;
|
||||
--cad-panel-width: 248px;
|
||||
font-family: var(--font-body);
|
||||
@@ -139,13 +139,44 @@ body > canvas[data-id="canvas"] {
|
||||
left: 0;
|
||||
height: var(--cad-ribbon-height);
|
||||
display: flex;
|
||||
align-items: stretch;
|
||||
padding: 5px 8px 3px;
|
||||
overflow-x: auto;
|
||||
flex-direction: column;
|
||||
background: var(--cad-chrome-raised);
|
||||
border-bottom: 1px solid var(--cad-line);
|
||||
}
|
||||
.cad-ribbon-tabs {
|
||||
display: flex;
|
||||
gap: 2px;
|
||||
height: 26px;
|
||||
padding: 0 8px;
|
||||
border-bottom: 1px solid var(--cad-line);
|
||||
}
|
||||
.cad-ribbon-tabs button {
|
||||
padding: 0 14px;
|
||||
border: 1px solid transparent;
|
||||
border-bottom: 0;
|
||||
border-radius: 4px 4px 0 0;
|
||||
background: transparent;
|
||||
color: var(--cad-text-dim);
|
||||
font-size: 12px;
|
||||
}
|
||||
.cad-ribbon-tabs button:hover {
|
||||
background: var(--cad-hover);
|
||||
}
|
||||
.cad-ribbon-tabs button[data-active="true"] {
|
||||
background: var(--cad-chrome);
|
||||
border-color: var(--cad-line);
|
||||
color: var(--color-text);
|
||||
}
|
||||
.cad-ribbon-panels {
|
||||
display: flex;
|
||||
flex: 1;
|
||||
align-items: stretch;
|
||||
padding: 4px 8px 2px;
|
||||
overflow-x: auto;
|
||||
overflow-y: visible;
|
||||
}
|
||||
.cad-ribbon-group {
|
||||
position: relative;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-width: max-content;
|
||||
@@ -154,54 +185,125 @@ body > canvas[data-id="canvas"] {
|
||||
}
|
||||
.cad-ribbon-tools {
|
||||
display: flex;
|
||||
gap: 2px;
|
||||
height: 57px;
|
||||
gap: 3px;
|
||||
align-items: flex-start;
|
||||
height: 66px;
|
||||
}
|
||||
.cad-ribbon-tools__grid {
|
||||
display: grid;
|
||||
grid-template-rows: 1fr 1fr;
|
||||
grid-auto-flow: column;
|
||||
gap: 1px;
|
||||
}
|
||||
.cad-ribbon-group__label {
|
||||
margin-top: auto;
|
||||
padding: 0;
|
||||
border: 0;
|
||||
background: transparent;
|
||||
color: var(--cad-text-muted);
|
||||
font-size: 10px;
|
||||
text-align: center;
|
||||
}
|
||||
.cad-ribbon-props {
|
||||
.cad-ribbon-group__label[data-expandable="true"] {
|
||||
cursor: pointer;
|
||||
}
|
||||
.cad-ribbon-group__label[data-expandable="true"]:hover {
|
||||
color: var(--cad-accent);
|
||||
}
|
||||
.cad-ribbon-overflow {
|
||||
position: absolute;
|
||||
z-index: 10;
|
||||
top: 100%;
|
||||
left: 4px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-width: 170px;
|
||||
padding: 4px;
|
||||
background: var(--cad-chrome-raised);
|
||||
border: 1px solid var(--cad-line);
|
||||
border-radius: 4px;
|
||||
box-shadow: var(--shadow-lg);
|
||||
}
|
||||
.cad-ribbon-overflow button {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
align-items: center;
|
||||
padding: 5px 8px;
|
||||
border: 0;
|
||||
border-radius: 3px;
|
||||
background: transparent;
|
||||
font-size: 11px;
|
||||
text-align: left;
|
||||
}
|
||||
.cad-prop {
|
||||
.cad-ribbon-overflow button:hover {
|
||||
background: var(--cad-hover);
|
||||
}
|
||||
.cad-quick-access {
|
||||
display: flex;
|
||||
gap: 2px;
|
||||
margin-left: 14px;
|
||||
}
|
||||
.cad-quick-access button {
|
||||
width: 26px;
|
||||
height: 24px;
|
||||
border: 1px solid transparent;
|
||||
border-radius: 3px;
|
||||
background: transparent;
|
||||
font-size: 13px;
|
||||
}
|
||||
.cad-quick-access button:hover {
|
||||
border-color: var(--cad-line);
|
||||
background: var(--cad-hover);
|
||||
}
|
||||
.cad-ribbon-props {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 3px;
|
||||
align-items: stretch;
|
||||
justify-content: flex-start;
|
||||
}
|
||||
/* 위젯 패널은 한 줄에 라벨+입력을 놓고 세로로 쌓는다 (리본 높이 안에 들어가야 한다) */
|
||||
.cad-prop {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
gap: 6px;
|
||||
align-items: center;
|
||||
font-size: 10px;
|
||||
color: var(--cad-text-muted);
|
||||
}
|
||||
.cad-prop > span {
|
||||
text-align: center;
|
||||
width: 52px;
|
||||
text-align: left;
|
||||
}
|
||||
.cad-prop select,
|
||||
.cad-prop input[type="number"] {
|
||||
height: 24px;
|
||||
min-width: 64px;
|
||||
padding: 0 4px;
|
||||
height: 19px;
|
||||
min-width: 78px;
|
||||
padding: 0 3px;
|
||||
color: var(--cad-text);
|
||||
background: var(--cad-chrome-sunken);
|
||||
border: 1px solid var(--cad-line);
|
||||
border-radius: 4px;
|
||||
border-radius: 3px;
|
||||
font-size: 11px;
|
||||
}
|
||||
.cad-prop input[type="number"] {
|
||||
min-width: 48px;
|
||||
width: 48px;
|
||||
min-width: 52px;
|
||||
width: 52px;
|
||||
}
|
||||
.cad-prop input[type="color"] {
|
||||
height: 24px;
|
||||
width: 40px;
|
||||
height: 19px;
|
||||
width: 44px;
|
||||
padding: 1px;
|
||||
background: var(--cad-chrome-sunken);
|
||||
border: 1px solid var(--cad-line);
|
||||
border-radius: 4px;
|
||||
border-radius: 3px;
|
||||
cursor: pointer;
|
||||
}
|
||||
/* 위젯 패널 안의 작은 명령 버튼은 한 줄 높이에 맞춘다 */
|
||||
.cad-ribbon-props .cad-tool[data-size="small"] {
|
||||
height: 22px;
|
||||
min-width: 0;
|
||||
}
|
||||
.cad-tool {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
@@ -214,6 +316,29 @@ body > canvas[data-id="canvas"] {
|
||||
border-radius: 3px;
|
||||
background: transparent;
|
||||
font-size: 10px;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.cad-tool[data-size="big"] {
|
||||
min-width: 58px;
|
||||
height: 66px;
|
||||
}
|
||||
.cad-tool[data-size="big"] .cad-tool__glyph {
|
||||
height: 32px;
|
||||
font-size: 26px;
|
||||
line-height: 32px;
|
||||
}
|
||||
.cad-tool[data-size="small"] {
|
||||
flex-direction: row;
|
||||
justify-content: flex-start;
|
||||
gap: 4px;
|
||||
min-width: 78px;
|
||||
height: 32px;
|
||||
padding: 0 5px;
|
||||
}
|
||||
.cad-tool[data-size="small"] .cad-tool__glyph {
|
||||
height: auto;
|
||||
font-size: 15px;
|
||||
line-height: 1;
|
||||
}
|
||||
.cad-tool:hover:not(:disabled),
|
||||
.cad-tool[data-active="true"] {
|
||||
@@ -376,23 +501,62 @@ body > canvas[data-id="canvas"] {
|
||||
|
||||
/* 하단 명령어 입력창은 숨김 처리 (마우스 커서 단축키 입력으로 대체) */
|
||||
.cad-command-area {
|
||||
display: none;
|
||||
position: fixed;
|
||||
z-index: 3;
|
||||
right: 0;
|
||||
bottom: var(--cad-status-height);
|
||||
left: var(--cad-panel-width);
|
||||
height: var(--cad-command-height);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
padding: 6px 10px;
|
||||
background: var(--cad-chrome);
|
||||
border-top: 1px solid var(--cad-line);
|
||||
}
|
||||
.cad-command-history {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
flex: 1;
|
||||
justify-content: flex-end;
|
||||
overflow: hidden;
|
||||
color: var(--cad-text-muted);
|
||||
font: 11px Consolas, monospace;
|
||||
}
|
||||
.cad-command-suggestions {
|
||||
position: absolute;
|
||||
z-index: 6;
|
||||
bottom: 100%;
|
||||
left: 10px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-width: 220px;
|
||||
padding: 4px;
|
||||
background: var(--cad-chrome-raised);
|
||||
border: 1px solid var(--cad-line);
|
||||
border-radius: 4px;
|
||||
box-shadow: var(--shadow-lg);
|
||||
}
|
||||
.cad-command-suggestions button {
|
||||
padding: 4px 8px;
|
||||
border: 0;
|
||||
border-radius: 3px;
|
||||
background: transparent;
|
||||
font: 11px Consolas, monospace;
|
||||
text-align: left;
|
||||
}
|
||||
.cad-command-suggestions button:hover {
|
||||
background: var(--cad-hover);
|
||||
}
|
||||
.cad-prop--wide select {
|
||||
min-width: 132px;
|
||||
}
|
||||
.cad-command-prompt {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
height: 18px;
|
||||
overflow: hidden;
|
||||
font-size: 10px;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.cad-command-prompt span {
|
||||
color: var(--cad-accent);
|
||||
}
|
||||
.cad-command-prompt strong {
|
||||
max-width: 42%;
|
||||
overflow: hidden;
|
||||
color: var(--cad-text-dim);
|
||||
font-weight: 400;
|
||||
font-size: 11px;
|
||||
white-space: nowrap;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
.cad-command-area form {
|
||||
@@ -605,3 +769,87 @@ body > canvas[data-id="canvas"] {
|
||||
min-width: auto;
|
||||
}
|
||||
}
|
||||
|
||||
/* 특성 팔레트 — 값을 바로 고치는 입력들 */
|
||||
.cad-properties-editor {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 7px;
|
||||
}
|
||||
.cad-properties-editor__title {
|
||||
color: var(--color-text);
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
}
|
||||
.cad-properties-editor label {
|
||||
display: grid;
|
||||
grid-template-columns: 72px 1fr;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
font-size: 11px;
|
||||
color: var(--cad-text-dim);
|
||||
}
|
||||
.cad-properties-editor select,
|
||||
.cad-properties-editor input {
|
||||
height: 24px;
|
||||
min-width: 0;
|
||||
padding: 0 4px;
|
||||
border: 1px solid var(--cad-line);
|
||||
border-radius: 3px;
|
||||
background: var(--cad-chrome-sunken);
|
||||
color: var(--cad-text);
|
||||
font-size: 11px;
|
||||
}
|
||||
.cad-properties-editor__readout {
|
||||
margin: 6px 0 0;
|
||||
border-top: 1px solid var(--cad-line);
|
||||
}
|
||||
.cad-properties-editor__readout div {
|
||||
display: grid;
|
||||
grid-template-columns: 72px 1fr;
|
||||
padding: 5px 0;
|
||||
border-bottom: 1px solid var(--cad-line);
|
||||
font-size: 11px;
|
||||
}
|
||||
.cad-properties-editor__readout dt {
|
||||
color: var(--cad-text-dim);
|
||||
}
|
||||
.cad-properties-editor__readout dd {
|
||||
margin: 0;
|
||||
overflow: hidden;
|
||||
color: var(--cad-text);
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
.cad-properties__empty {
|
||||
margin: 0;
|
||||
color: var(--cad-text-muted);
|
||||
font-size: 11px;
|
||||
}
|
||||
|
||||
/* 빠른 특성 — 화면 위에 뜨는 작은 상자 */
|
||||
.cad-quick-properties {
|
||||
position: fixed;
|
||||
z-index: 5;
|
||||
top: calc(var(--cad-title-height) + var(--cad-ribbon-height) + 12px);
|
||||
right: 14px;
|
||||
width: 240px;
|
||||
padding: 8px 10px 10px;
|
||||
background: var(--cad-chrome-raised);
|
||||
border: 1px solid var(--cad-line);
|
||||
border-radius: 5px;
|
||||
box-shadow: var(--shadow-lg);
|
||||
}
|
||||
.cad-quick-properties__header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 6px;
|
||||
color: var(--cad-text-dim);
|
||||
font-size: 11px;
|
||||
}
|
||||
.cad-quick-properties__header button {
|
||||
border: 0;
|
||||
background: transparent;
|
||||
color: var(--cad-text-muted);
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
@@ -62,6 +62,14 @@ export interface Layer {
|
||||
name: string;
|
||||
isVisible: boolean;
|
||||
isLocked: boolean;
|
||||
/** 동결 — 표시에서 빼고 선택 대상에서도 제외한다 */
|
||||
isFrozen?: boolean;
|
||||
/** 도면층 기본 특성 (SETBYLAYER가 객체에 입힌다) */
|
||||
color?: string;
|
||||
lineWidth?: number;
|
||||
lineDash?: number[];
|
||||
/** 0~1. 1이면 불투명 */
|
||||
opacity?: number;
|
||||
}
|
||||
|
||||
export enum LOCAL_STORAGE_KEY {
|
||||
|
||||
@@ -0,0 +1,29 @@
|
||||
/**
|
||||
* 명령 정의 — 리본 버튼·명령행 입력·단축키가 모두 이 한 곳을 읽는다.
|
||||
* AutoCAD 명령 이름(id)과 기본 별칭(aliases)을 그대로 쓴다.
|
||||
*/
|
||||
import type { Tool } from '../tools';
|
||||
|
||||
// biome-ignore lint/suspicious/noExplicitAny: xstate 머신 타입 인자가 14개라 그대로 받는다
|
||||
export type AnyToolMachine = any;
|
||||
|
||||
export interface CadCommand {
|
||||
/** AutoCAD 명령 이름 (대문자). 명령행에서 그대로 입력한다 */
|
||||
id: string;
|
||||
/** 리본·툴팁에 쓰는 한글 이름 */
|
||||
label: string;
|
||||
/** AutoCAD 기본 별칭 (대문자) */
|
||||
aliases?: string[];
|
||||
/** 리본 버튼 글리프 */
|
||||
glyph: string;
|
||||
/** 툴팁 보조 설명 */
|
||||
hint?: string;
|
||||
/** 도구형 명령이 활성화할 도구 */
|
||||
tool?: Tool;
|
||||
/** 도구형 명령의 xstate 머신 */
|
||||
machine?: AnyToolMachine;
|
||||
/** 즉시 실행 명령. 반환 문자열은 명령행 기록에 남는다 (없으면 라벨을 쓴다) */
|
||||
run?: () => string | undefined;
|
||||
/** 선택 객체가 있어야 동작하는 즉시 실행 명령 */
|
||||
needsSelection?: boolean;
|
||||
}
|
||||
@@ -0,0 +1,299 @@
|
||||
/** 주석 탭 명령 (조사표 5절 전 항목) */
|
||||
import { toast } from 'react-toastify';
|
||||
import type { CadCommand } from './command.types';
|
||||
import { setActiveRibbonTab } from '../components/ui-state';
|
||||
import { Tool } from '../tools';
|
||||
import {
|
||||
centerLineToolStateMachine,
|
||||
centerMarkToolStateMachine,
|
||||
dimAngularToolStateMachine,
|
||||
dimArcToolStateMachine,
|
||||
dimDiameterToolStateMachine,
|
||||
dimOrdinateToolStateMachine,
|
||||
dimRadiusToolStateMachine,
|
||||
dimSpaceToolStateMachine,
|
||||
} from '../tools/annotate/dimension-radial-tools';
|
||||
import {
|
||||
dimAlignedToolStateMachine,
|
||||
dimAutoToolStateMachine,
|
||||
dimBaselineToolStateMachine,
|
||||
dimContinueToolStateMachine,
|
||||
dimLinearToolStateMachine,
|
||||
qDimToolStateMachine,
|
||||
} from '../tools/annotate/dimension-tools';
|
||||
import {
|
||||
annotationScaleToolStateMachine,
|
||||
dimStyleToolStateMachine,
|
||||
mleaderAlignToolStateMachine,
|
||||
mleaderStyleToolStateMachine,
|
||||
mleaderToolStateMachine,
|
||||
revCloudToolStateMachine,
|
||||
tableStyleToolStateMachine,
|
||||
tableToolStateMachine,
|
||||
updateDimensions,
|
||||
} from '../tools/annotate/leader-table-tools';
|
||||
import {
|
||||
findToolStateMachine,
|
||||
mtextToolStateMachine,
|
||||
textEditToolStateMachine,
|
||||
textToolStateMachine,
|
||||
} from '../tools/annotate/text-tools';
|
||||
|
||||
export const TEXT_COMMANDS: CadCommand[] = [
|
||||
{
|
||||
id: 'MTEXT',
|
||||
label: '여러 줄 문자',
|
||||
aliases: ['T', 'MT'],
|
||||
glyph: '¶',
|
||||
hint: '여러 줄 문자를 작성한다 (줄바꿈은 \\P)',
|
||||
tool: Tool.MTEXT,
|
||||
machine: mtextToolStateMachine,
|
||||
},
|
||||
{
|
||||
id: 'TEXT',
|
||||
label: '단일 행 문자',
|
||||
aliases: ['DT'],
|
||||
glyph: 'A',
|
||||
hint: '한 줄짜리 문자를 작성한다',
|
||||
tool: Tool.TEXT,
|
||||
machine: textToolStateMachine,
|
||||
},
|
||||
{
|
||||
id: 'STYLE',
|
||||
label: '문자 스타일',
|
||||
aliases: ['ST'],
|
||||
glyph: '🅰',
|
||||
hint: '리본 문자 패널에서 글꼴·크기·색을 지정한다',
|
||||
run: () => {
|
||||
setActiveRibbonTab('annotate');
|
||||
toast.info('주석 탭 문자 패널에서 글꼴과 크기를 지정하십시오.');
|
||||
return '문자 스타일';
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'TEXTEDIT',
|
||||
label: '문자 편집',
|
||||
aliases: ['TEDIT'],
|
||||
glyph: '✎',
|
||||
hint: '기존 문자의 내용을 고친다',
|
||||
tool: Tool.TEXTEDIT,
|
||||
machine: textEditToolStateMachine,
|
||||
},
|
||||
{
|
||||
id: 'FIND',
|
||||
label: '찾기·대치',
|
||||
glyph: '🔍',
|
||||
hint: '도면 문자를 찾고 바꾼다',
|
||||
tool: Tool.FIND,
|
||||
machine: findToolStateMachine,
|
||||
},
|
||||
];
|
||||
|
||||
export const DIMENSION_COMMANDS: CadCommand[] = [
|
||||
{
|
||||
id: 'DIM',
|
||||
label: '치수',
|
||||
glyph: '⟺',
|
||||
hint: '선택 객체에 맞는 치수를 자동으로 넣는다',
|
||||
tool: Tool.DIM,
|
||||
machine: dimAutoToolStateMachine,
|
||||
},
|
||||
{
|
||||
id: 'DIMLINEAR',
|
||||
label: '선형 치수',
|
||||
aliases: ['DLI'],
|
||||
glyph: '↔',
|
||||
hint: '수평 또는 수직 거리를 기입한다',
|
||||
tool: Tool.DIMLINEAR,
|
||||
machine: dimLinearToolStateMachine,
|
||||
},
|
||||
{
|
||||
id: 'DIMALIGNED',
|
||||
label: '정렬 치수',
|
||||
aliases: ['DAL'],
|
||||
glyph: '⤢',
|
||||
hint: '두 점과 평행한 실제 길이를 기입한다',
|
||||
tool: Tool.DIMALIGNED,
|
||||
machine: dimAlignedToolStateMachine,
|
||||
},
|
||||
{
|
||||
id: 'DIMANGULAR',
|
||||
label: '각도 치수',
|
||||
aliases: ['DAN'],
|
||||
glyph: '∠',
|
||||
hint: '세 점이 이루는 각도를 기입한다',
|
||||
tool: Tool.DIMANGULAR,
|
||||
machine: dimAngularToolStateMachine,
|
||||
},
|
||||
{
|
||||
id: 'DIMARC',
|
||||
label: '호 길이 치수',
|
||||
aliases: ['DAR'],
|
||||
glyph: '⌒',
|
||||
hint: '호의 곡선 길이를 기입한다',
|
||||
tool: Tool.DIMARC,
|
||||
machine: dimArcToolStateMachine,
|
||||
},
|
||||
{
|
||||
id: 'DIMRADIUS',
|
||||
label: '반지름 치수',
|
||||
aliases: ['DRA'],
|
||||
glyph: 'R',
|
||||
hint: '원·호의 반지름을 기입한다',
|
||||
tool: Tool.DIMRADIUS,
|
||||
machine: dimRadiusToolStateMachine,
|
||||
},
|
||||
{
|
||||
id: 'DIMDIAMETER',
|
||||
label: '지름 치수',
|
||||
aliases: ['DDI'],
|
||||
glyph: 'Ø',
|
||||
hint: '원·호의 지름을 기입한다',
|
||||
tool: Tool.DIMDIAMETER,
|
||||
machine: dimDiameterToolStateMachine,
|
||||
},
|
||||
{
|
||||
id: 'DIMORDINATE',
|
||||
label: '세로좌표 치수',
|
||||
aliases: ['DOR'],
|
||||
glyph: '⌐',
|
||||
hint: '기준 원점에 대한 X 또는 Y 좌표를 기입한다',
|
||||
tool: Tool.DIMORDINATE,
|
||||
machine: dimOrdinateToolStateMachine,
|
||||
},
|
||||
{
|
||||
id: 'DIMBASELINE',
|
||||
label: '기준선 치수',
|
||||
aliases: ['DBA'],
|
||||
glyph: '⊞',
|
||||
hint: '직전 치수의 시작점을 기준으로 이어 기입한다',
|
||||
tool: Tool.DIMBASELINE,
|
||||
machine: dimBaselineToolStateMachine,
|
||||
},
|
||||
{
|
||||
id: 'DIMCONTINUE',
|
||||
label: '연속 치수',
|
||||
aliases: ['DCO'],
|
||||
glyph: '⋯',
|
||||
hint: '직전 치수의 끝점에서 이어 기입한다',
|
||||
tool: Tool.DIMCONTINUE,
|
||||
machine: dimContinueToolStateMachine,
|
||||
},
|
||||
{
|
||||
id: 'QDIM',
|
||||
label: '빠른 치수',
|
||||
glyph: '⚡',
|
||||
hint: '선택 객체에 치수를 한 번에 넣는다',
|
||||
tool: Tool.QDIM,
|
||||
machine: qDimToolStateMachine,
|
||||
},
|
||||
{
|
||||
id: 'CENTERMARK',
|
||||
label: '중심 표식',
|
||||
glyph: '✛',
|
||||
hint: '원·호 중심에 표식을 넣는다',
|
||||
tool: Tool.CENTERMARK,
|
||||
machine: centerMarkToolStateMachine,
|
||||
},
|
||||
{
|
||||
id: 'CENTERLINE',
|
||||
label: '중심선',
|
||||
glyph: '┈',
|
||||
hint: '두 선 사이에 중심선을 넣는다',
|
||||
tool: Tool.CENTERLINE,
|
||||
machine: centerLineToolStateMachine,
|
||||
},
|
||||
{
|
||||
id: 'DIMSTYLE',
|
||||
label: '치수 스타일',
|
||||
aliases: ['D'],
|
||||
glyph: '⚙',
|
||||
hint: '치수 문자 높이·화살표 크기·소수 자릿수를 정한다',
|
||||
tool: Tool.DIMSTYLE,
|
||||
machine: dimStyleToolStateMachine,
|
||||
},
|
||||
{
|
||||
id: 'DIMUPDATE',
|
||||
label: '치수 업데이트',
|
||||
glyph: '⟳',
|
||||
hint: '바꾼 치수 스타일을 기존 치수에 반영한다',
|
||||
run: updateDimensions,
|
||||
},
|
||||
{
|
||||
id: 'DIMSPACE',
|
||||
label: '치수 간격',
|
||||
glyph: '⇕',
|
||||
hint: '평행한 치수선의 간격을 고르게 맞춘다',
|
||||
tool: Tool.DIMSPACE,
|
||||
machine: dimSpaceToolStateMachine,
|
||||
},
|
||||
];
|
||||
|
||||
export const LEADER_COMMANDS: CadCommand[] = [
|
||||
{
|
||||
id: 'MLEADER',
|
||||
label: '다중 지시선',
|
||||
aliases: ['MLD'],
|
||||
glyph: '➤',
|
||||
hint: '화살표와 문자를 잇는 지시선을 작성한다',
|
||||
tool: Tool.MLEADER,
|
||||
machine: mleaderToolStateMachine,
|
||||
},
|
||||
{
|
||||
id: 'MLEADERSTYLE',
|
||||
label: '지시선 스타일',
|
||||
aliases: ['MLS'],
|
||||
glyph: '⚙',
|
||||
hint: '지시선 문자 높이와 화살표 크기를 정한다',
|
||||
tool: Tool.MLEADERSTYLE,
|
||||
machine: mleaderStyleToolStateMachine,
|
||||
},
|
||||
{
|
||||
id: 'MLEADERALIGN',
|
||||
label: '지시선 정렬',
|
||||
glyph: '≡',
|
||||
hint: '선택한 지시선 문자의 위치를 맞춘다',
|
||||
tool: Tool.MLEADERALIGN,
|
||||
machine: mleaderAlignToolStateMachine,
|
||||
},
|
||||
];
|
||||
|
||||
export const TABLE_COMMANDS: CadCommand[] = [
|
||||
{
|
||||
id: 'TABLE',
|
||||
label: '테이블',
|
||||
aliases: ['TB'],
|
||||
glyph: '▦',
|
||||
hint: '행·열 격자를 도면에 배치한다',
|
||||
tool: Tool.TABLE,
|
||||
machine: tableToolStateMachine,
|
||||
},
|
||||
{
|
||||
id: 'TABLESTYLE',
|
||||
label: '테이블 스타일',
|
||||
aliases: ['TS'],
|
||||
glyph: '⚙',
|
||||
hint: '표의 열 너비와 행 높이를 정한다',
|
||||
tool: Tool.TABLESTYLE,
|
||||
machine: tableStyleToolStateMachine,
|
||||
},
|
||||
];
|
||||
|
||||
export const MARKUP_COMMANDS: CadCommand[] = [
|
||||
{
|
||||
id: 'REVCLOUD',
|
||||
label: '구름형 리비전',
|
||||
glyph: '☁',
|
||||
hint: '검토 범위를 구름형 선으로 표시한다',
|
||||
tool: Tool.REVCLOUD,
|
||||
machine: revCloudToolStateMachine,
|
||||
},
|
||||
{
|
||||
id: 'ANNOSCALE',
|
||||
label: '주석 축척',
|
||||
glyph: '⚖',
|
||||
hint: '치수·문자·화살표 크기에 곱할 축척을 정한다',
|
||||
tool: Tool.ANNOSCALE,
|
||||
machine: annotationScaleToolStateMachine,
|
||||
},
|
||||
];
|
||||
@@ -0,0 +1,221 @@
|
||||
/** 홈 탭 — 그리기 패널 명령 (조사표 1절 전 항목) */
|
||||
import type { CadCommand } from './command.types';
|
||||
import { Tool } from '../tools';
|
||||
import { circleToolStateMachine } from '../tools/circle-tool';
|
||||
import {
|
||||
arcToolStateMachine,
|
||||
donutToolStateMachine,
|
||||
ellipseToolStateMachine,
|
||||
plineToolStateMachine,
|
||||
pointToolStateMachine,
|
||||
polygonToolStateMachine,
|
||||
splineToolStateMachine,
|
||||
} from '../tools/draw/basic-draw-tools';
|
||||
import {
|
||||
mlineToolStateMachine,
|
||||
mlstyleToolStateMachine,
|
||||
rayToolStateMachine,
|
||||
wipeoutToolStateMachine,
|
||||
xlineToolStateMachine,
|
||||
} from '../tools/draw/construction-tools';
|
||||
import {
|
||||
divideToolStateMachine,
|
||||
measureLengthToolStateMachine,
|
||||
} from '../tools/draw/divide-tools';
|
||||
import {
|
||||
boundaryToolStateMachine,
|
||||
gradientToolStateMachine,
|
||||
hatchToolStateMachine,
|
||||
regionToolStateMachine,
|
||||
} from '../tools/draw/fill-tools';
|
||||
import { lineToolStateMachine } from '../tools/line-tool';
|
||||
import { rectangleToolStateMachine } from '../tools/rectangle-tool';
|
||||
|
||||
export const DRAW_COMMANDS: CadCommand[] = [
|
||||
{
|
||||
id: 'LINE',
|
||||
label: '선',
|
||||
aliases: ['L'],
|
||||
glyph: '╱',
|
||||
hint: '두 점 사이에 직선 세그먼트를 작성한다',
|
||||
tool: Tool.LINE,
|
||||
machine: lineToolStateMachine,
|
||||
},
|
||||
{
|
||||
id: 'PLINE',
|
||||
label: '폴리선',
|
||||
aliases: ['PL'],
|
||||
glyph: '⌁',
|
||||
hint: '연결된 선을 하나의 객체로 작성한다',
|
||||
tool: Tool.PLINE,
|
||||
machine: plineToolStateMachine,
|
||||
},
|
||||
{
|
||||
id: 'CIRCLE',
|
||||
label: '원',
|
||||
aliases: ['C'],
|
||||
glyph: '○',
|
||||
hint: '중심과 반지름으로 원을 작성한다',
|
||||
tool: Tool.CIRCLE,
|
||||
machine: circleToolStateMachine,
|
||||
},
|
||||
{
|
||||
id: 'ARC',
|
||||
label: '호',
|
||||
aliases: ['A'],
|
||||
glyph: '◜',
|
||||
hint: '시작점·통과점·끝점 세 점으로 호를 작성한다',
|
||||
tool: Tool.ARC,
|
||||
machine: arcToolStateMachine,
|
||||
},
|
||||
{
|
||||
id: 'RECTANG',
|
||||
label: '직사각형',
|
||||
aliases: ['REC'],
|
||||
glyph: '▭',
|
||||
hint: '두 대각점으로 직사각형을 작성한다',
|
||||
tool: Tool.RECTANGLE,
|
||||
machine: rectangleToolStateMachine,
|
||||
},
|
||||
{
|
||||
id: 'POLYGON',
|
||||
label: '다각형',
|
||||
aliases: ['POL'],
|
||||
glyph: '⬠',
|
||||
hint: '지정한 변 수의 정다각형을 작성한다',
|
||||
tool: Tool.POLYGON,
|
||||
machine: polygonToolStateMachine,
|
||||
},
|
||||
{
|
||||
id: 'ELLIPSE',
|
||||
label: '타원',
|
||||
aliases: ['EL'],
|
||||
glyph: '⬭',
|
||||
hint: '중심과 두 축으로 타원을 작성한다',
|
||||
tool: Tool.ELLIPSE,
|
||||
machine: ellipseToolStateMachine,
|
||||
},
|
||||
{
|
||||
id: 'SPLINE',
|
||||
label: '스플라인',
|
||||
aliases: ['SPL'],
|
||||
glyph: '∿',
|
||||
hint: '지정한 점을 지나는 부드러운 곡선을 작성한다',
|
||||
tool: Tool.SPLINE,
|
||||
machine: splineToolStateMachine,
|
||||
},
|
||||
{
|
||||
id: 'MLINE',
|
||||
label: '다중선',
|
||||
aliases: ['ML'],
|
||||
glyph: '⋕',
|
||||
hint: '평행한 여러 선을 한 번에 작성한다',
|
||||
tool: Tool.MLINE,
|
||||
machine: mlineToolStateMachine,
|
||||
},
|
||||
{
|
||||
id: 'MLSTYLE',
|
||||
label: '다중선 스타일',
|
||||
glyph: '⚙',
|
||||
hint: '다중선의 요소 수와 간격을 정한다',
|
||||
tool: Tool.MLSTYLE,
|
||||
machine: mlstyleToolStateMachine,
|
||||
},
|
||||
{
|
||||
id: 'XLINE',
|
||||
label: '구성선',
|
||||
aliases: ['XL'],
|
||||
glyph: '⟷',
|
||||
hint: '양방향으로 길게 뻗는 기준선을 작성한다',
|
||||
tool: Tool.XLINE,
|
||||
machine: xlineToolStateMachine,
|
||||
},
|
||||
{
|
||||
id: 'RAY',
|
||||
label: '광선',
|
||||
glyph: '⟶',
|
||||
hint: '한 방향으로 길게 뻗는 기준선을 작성한다',
|
||||
tool: Tool.RAY,
|
||||
machine: rayToolStateMachine,
|
||||
},
|
||||
{
|
||||
id: 'POINT',
|
||||
label: '점',
|
||||
aliases: ['PO'],
|
||||
glyph: '·',
|
||||
hint: '점 객체를 작성한다',
|
||||
tool: Tool.POINT,
|
||||
machine: pointToolStateMachine,
|
||||
},
|
||||
{
|
||||
id: 'DONUT',
|
||||
label: '도넛',
|
||||
aliases: ['DO'],
|
||||
glyph: '◎',
|
||||
hint: '내부·외부 지름으로 링을 작성한다',
|
||||
tool: Tool.DONUT,
|
||||
machine: donutToolStateMachine,
|
||||
},
|
||||
{
|
||||
id: 'DIVIDE',
|
||||
label: '등분',
|
||||
aliases: ['DIV'],
|
||||
glyph: '⋯',
|
||||
hint: '객체를 자르지 않고 같은 간격의 점을 배치한다',
|
||||
tool: Tool.DIVIDE,
|
||||
machine: divideToolStateMachine,
|
||||
},
|
||||
{
|
||||
id: 'MEASURE',
|
||||
label: '길이분할',
|
||||
aliases: ['ME'],
|
||||
glyph: '⋮',
|
||||
hint: '지정 거리마다 점을 배치한다',
|
||||
tool: Tool.MEASURE_LENGTH,
|
||||
machine: measureLengthToolStateMachine,
|
||||
},
|
||||
{
|
||||
id: 'HATCH',
|
||||
label: '해치',
|
||||
aliases: ['H', 'BH'],
|
||||
glyph: '▨',
|
||||
hint: '닫힌 경계를 패턴으로 채운다',
|
||||
tool: Tool.HATCH,
|
||||
machine: hatchToolStateMachine,
|
||||
},
|
||||
{
|
||||
id: 'GRADIENT',
|
||||
label: '그라데이션',
|
||||
aliases: ['GD'],
|
||||
glyph: '◪',
|
||||
hint: '닫힌 경계를 색 변화로 채운다',
|
||||
tool: Tool.GRADIENT,
|
||||
machine: gradientToolStateMachine,
|
||||
},
|
||||
{
|
||||
id: 'BOUNDARY',
|
||||
label: '경계',
|
||||
aliases: ['BO'],
|
||||
glyph: '⬡',
|
||||
hint: '선택 객체에서 닫힌 폴리선 경계를 만든다',
|
||||
tool: Tool.BOUNDARY,
|
||||
machine: boundaryToolStateMachine,
|
||||
},
|
||||
{
|
||||
id: 'REGION',
|
||||
label: '영역',
|
||||
aliases: ['REG'],
|
||||
glyph: '⬢',
|
||||
hint: '닫힌 객체를 영역(닫힌 폴리선)으로 만든다',
|
||||
tool: Tool.REGION,
|
||||
machine: regionToolStateMachine,
|
||||
},
|
||||
{
|
||||
id: 'WIPEOUT',
|
||||
label: '와이프아웃',
|
||||
glyph: '▩',
|
||||
hint: '뒤쪽 객체를 가리는 마스크를 작성한다',
|
||||
tool: Tool.WIPEOUT,
|
||||
machine: wipeoutToolStateMachine,
|
||||
},
|
||||
];
|
||||
@@ -0,0 +1,70 @@
|
||||
/** 빠른 실행 도구막대 · 출력 탭 명령 (파일 수명주기) */
|
||||
import { toast } from 'react-toastify';
|
||||
import type { CadCommand } from './command.types';
|
||||
import { exportEntitiesToJsonFile } from '../helpers/import-export-handlers/export-entities-to-json';
|
||||
import { exportEntitiesToLocalStorage } from '../helpers/import-export-handlers/export-entities-to-local-storage';
|
||||
import { exportEntitiesToPngFile } from '../helpers/import-export-handlers/export-entities-to-png';
|
||||
import { exportEntitiesToSvgFile } from '../helpers/import-export-handlers/export-entities-to-svg';
|
||||
import { redo, undo } from '../state';
|
||||
|
||||
export const FILE_COMMANDS: CadCommand[] = [
|
||||
{
|
||||
id: 'QSAVE',
|
||||
label: '저장',
|
||||
aliases: ['SAVE'],
|
||||
glyph: '💾',
|
||||
hint: '현재 도면을 브라우저에 저장한다',
|
||||
run: () => {
|
||||
void exportEntitiesToLocalStorage().then(() => toast.success('도면을 저장했습니다.'));
|
||||
return '도면 저장';
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'EXPORT',
|
||||
label: 'JSON 내보내기',
|
||||
aliases: ['EXP'],
|
||||
glyph: '⭳',
|
||||
hint: '도면을 JSON 파일로 내보낸다',
|
||||
run: () => {
|
||||
void exportEntitiesToJsonFile();
|
||||
return 'JSON 내보내기';
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'EXPORTSVG',
|
||||
label: 'SVG 내보내기',
|
||||
glyph: '🖼',
|
||||
run: () => {
|
||||
exportEntitiesToSvgFile();
|
||||
return 'SVG 내보내기';
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'EXPORTPNG',
|
||||
label: 'PNG 내보내기',
|
||||
glyph: '🏞',
|
||||
run: () => {
|
||||
void exportEntitiesToPngFile();
|
||||
return 'PNG 내보내기';
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'UNDO',
|
||||
label: '실행 취소',
|
||||
aliases: ['U'],
|
||||
glyph: '↶',
|
||||
run: () => {
|
||||
undo();
|
||||
return '실행 취소';
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'REDO',
|
||||
label: '다시 실행',
|
||||
glyph: '↷',
|
||||
run: () => {
|
||||
redo();
|
||||
return '다시 실행';
|
||||
},
|
||||
},
|
||||
];
|
||||
@@ -0,0 +1,16 @@
|
||||
/** 삽입 탭 명령 (조사표 4절 중 구현분) */
|
||||
import type { CadCommand } from './command.types';
|
||||
import { Tool } from '../tools';
|
||||
import { imageImportToolStateMachine } from '../tools/image-import-tool';
|
||||
|
||||
export const INSERT_COMMANDS: CadCommand[] = [
|
||||
{
|
||||
id: 'IMAGEATTACH',
|
||||
label: '이미지 부착',
|
||||
aliases: ['IAT'],
|
||||
glyph: '🖼',
|
||||
hint: '래스터 이미지를 도면에 배치한다',
|
||||
tool: Tool.IMAGE_IMPORT,
|
||||
machine: imageImportToolStateMachine,
|
||||
},
|
||||
];
|
||||
@@ -0,0 +1,166 @@
|
||||
/** 홈 탭 — 도면층 패널 명령 (조사표 3절) */
|
||||
import type { CadCommand } from './command.types';
|
||||
import { Tool } from '../tools';
|
||||
import {
|
||||
layerCurrentToolStateMachine,
|
||||
layerDeleteToolStateMachine,
|
||||
layerFreezeToolStateMachine,
|
||||
layerIsolateToolStateMachine,
|
||||
layerLockToolStateMachine,
|
||||
layerMatchToolStateMachine,
|
||||
layerMergeToolStateMachine,
|
||||
layerOffToolStateMachine,
|
||||
layerStateRestoreToolStateMachine,
|
||||
layerStateSaveToolStateMachine,
|
||||
layerToCurrentToolStateMachine,
|
||||
layerUnlockToolStateMachine,
|
||||
openLayerManager,
|
||||
restorePreviousLayers,
|
||||
thawAllLayers,
|
||||
turnAllLayersOn,
|
||||
unisolateLayers,
|
||||
walkLayers,
|
||||
} from '../tools/utility/layer-tools';
|
||||
|
||||
export const LAYER_COMMANDS: CadCommand[] = [
|
||||
{
|
||||
id: 'LAYER',
|
||||
label: '도면층 특성',
|
||||
aliases: ['LA'],
|
||||
glyph: '▤',
|
||||
hint: '도면층을 만들고 이름·표시·잠금을 관리한다',
|
||||
run: openLayerManager,
|
||||
},
|
||||
{
|
||||
id: 'LAYCURSET',
|
||||
label: '현재 도면층 설정',
|
||||
glyph: '◉',
|
||||
hint: '선택한 객체의 도면층을 현재 도면층으로 지정한다',
|
||||
tool: Tool.LAYER_CURRENT,
|
||||
machine: layerCurrentToolStateMachine,
|
||||
},
|
||||
{
|
||||
id: 'LAYOFF',
|
||||
label: '도면층 끄기',
|
||||
glyph: '◐',
|
||||
hint: '선택 객체가 속한 도면층을 숨긴다',
|
||||
tool: Tool.LAYOFF,
|
||||
machine: layerOffToolStateMachine,
|
||||
},
|
||||
{
|
||||
id: 'LAYON',
|
||||
label: '모두 켜기',
|
||||
glyph: '◉',
|
||||
hint: '모든 도면층을 표시한다',
|
||||
run: turnAllLayersOn,
|
||||
},
|
||||
{
|
||||
id: 'LAYFRZ',
|
||||
label: '동결',
|
||||
glyph: '❄',
|
||||
hint: '도면층을 표시·재생성 대상에서 뺀다',
|
||||
tool: Tool.LAYFRZ,
|
||||
machine: layerFreezeToolStateMachine,
|
||||
},
|
||||
{
|
||||
id: 'LAYTHW',
|
||||
label: '동결 해제',
|
||||
glyph: '☀',
|
||||
hint: '모든 도면층의 동결을 푼다',
|
||||
run: thawAllLayers,
|
||||
},
|
||||
{
|
||||
id: 'LAYLCK',
|
||||
label: '잠금',
|
||||
glyph: '🔒',
|
||||
hint: '도면층 객체를 편집할 수 없게 한다',
|
||||
tool: Tool.LAYLCK,
|
||||
machine: layerLockToolStateMachine,
|
||||
},
|
||||
{
|
||||
id: 'LAYULK',
|
||||
label: '잠금 해제',
|
||||
glyph: '🔓',
|
||||
hint: '도면층 잠금을 푼다',
|
||||
tool: Tool.LAYULK,
|
||||
machine: layerUnlockToolStateMachine,
|
||||
},
|
||||
{
|
||||
id: 'LAYISO',
|
||||
label: '분리',
|
||||
glyph: '◫',
|
||||
hint: '선택 객체의 도면층만 남기고 나머지를 숨긴다',
|
||||
tool: Tool.LAYISO,
|
||||
machine: layerIsolateToolStateMachine,
|
||||
},
|
||||
{
|
||||
id: 'LAYUNISO',
|
||||
label: '분리 해제',
|
||||
glyph: '◻',
|
||||
hint: '도면층 분리 이전 상태로 되돌린다',
|
||||
run: unisolateLayers,
|
||||
},
|
||||
{
|
||||
id: 'LAYERP',
|
||||
label: '이전 상태',
|
||||
glyph: '↺',
|
||||
hint: '직전 도면층 설정으로 되돌린다',
|
||||
run: restorePreviousLayers,
|
||||
},
|
||||
{
|
||||
id: 'LAYERSTATE',
|
||||
label: '도면층 상태 저장',
|
||||
aliases: ['LAS'],
|
||||
glyph: '💾',
|
||||
hint: '현재 도면층 설정을 이름으로 저장한다',
|
||||
tool: Tool.LAYERSTATE,
|
||||
machine: layerStateSaveToolStateMachine,
|
||||
},
|
||||
{
|
||||
id: 'LAYERSTATERESTORE',
|
||||
label: '도면층 상태 복원',
|
||||
glyph: '⭯',
|
||||
hint: '저장한 도면층 설정을 되돌린다',
|
||||
tool: Tool.LAYERSTATE_RESTORE,
|
||||
machine: layerStateRestoreToolStateMachine,
|
||||
},
|
||||
{
|
||||
id: 'LAYMCH',
|
||||
label: '도면층 일치',
|
||||
glyph: '⇄',
|
||||
hint: '선택 객체를 대상 객체의 도면층으로 옮긴다',
|
||||
tool: Tool.LAYMCH,
|
||||
machine: layerMatchToolStateMachine,
|
||||
},
|
||||
{
|
||||
id: 'LAYCUR',
|
||||
label: '현재 도면층으로',
|
||||
glyph: '⇥',
|
||||
hint: '선택 객체를 현재 도면층으로 옮긴다',
|
||||
tool: Tool.LAYCUR,
|
||||
machine: layerToCurrentToolStateMachine,
|
||||
},
|
||||
{
|
||||
id: 'LAYMRG',
|
||||
label: '도면층 병합',
|
||||
glyph: '⊎',
|
||||
hint: '한 도면층의 객체를 다른 도면층으로 옮기고 원래 도면층을 지운다',
|
||||
tool: Tool.LAYMRG,
|
||||
machine: layerMergeToolStateMachine,
|
||||
},
|
||||
{
|
||||
id: 'LAYDEL',
|
||||
label: '도면층 삭제',
|
||||
glyph: '🗑',
|
||||
hint: '선택한 도면층과 그 객체를 삭제한다',
|
||||
tool: Tool.LAYDEL,
|
||||
machine: layerDeleteToolStateMachine,
|
||||
},
|
||||
{
|
||||
id: 'LAYWALK',
|
||||
label: '도면층 탐색',
|
||||
glyph: '👣',
|
||||
hint: '부를 때마다 다음 도면층만 표시한다',
|
||||
run: walkLayers,
|
||||
},
|
||||
];
|
||||
@@ -0,0 +1,340 @@
|
||||
/** 홈 탭 — 수정 패널 명령 (조사표 2절 전 항목) */
|
||||
import type { CadCommand } from './command.types';
|
||||
import { Tool } from '../tools';
|
||||
import { alignBottomToolStateMachine } from '../tools/align-bottom-tool';
|
||||
import { alignCenterHorizontalToolStateMachine } from '../tools/align-center-horizontal-tool';
|
||||
import { alignLeftToolStateMachine } from '../tools/align-left-tool';
|
||||
import { alignCenterVerticalToolStateMachine } from '../tools/align-middle-vertical-tool';
|
||||
import { alignRightToolStateMachine } from '../tools/align-right-tool';
|
||||
import { alignTopToolStateMachine } from '../tools/align-top-tool';
|
||||
import { arrayToolStateMachine } from '../tools/array-tool';
|
||||
import { copyToolStateMachine } from '../tools/copy-tool';
|
||||
import { eraserToolStateMachine } from '../tools/eraser-tool';
|
||||
import {
|
||||
blendToolStateMachine,
|
||||
breakAtPointToolStateMachine,
|
||||
breakToolStateMachine,
|
||||
chamferToolStateMachine,
|
||||
filletToolStateMachine,
|
||||
} from '../tools/modify/corner-tools';
|
||||
import {
|
||||
drawOrderBackToolStateMachine,
|
||||
drawOrderFrontToolStateMachine,
|
||||
hatchEditToolStateMachine,
|
||||
matchPropToolStateMachine,
|
||||
overkillToolStateMachine,
|
||||
reverseToolStateMachine,
|
||||
setByLayerToolStateMachine,
|
||||
} from '../tools/modify/property-tools';
|
||||
import {
|
||||
eraseToolStateMachine,
|
||||
explodeToolStateMachine,
|
||||
extendToolStateMachine,
|
||||
joinToolStateMachine,
|
||||
} from '../tools/modify/structure-tools';
|
||||
import {
|
||||
alignToolStateMachine,
|
||||
lengthenToolStateMachine,
|
||||
mirrorToolStateMachine,
|
||||
offsetToolStateMachine,
|
||||
stretchToolStateMachine,
|
||||
} from '../tools/modify/transform-tools';
|
||||
import { moveToolStateMachine } from '../tools/move-tool';
|
||||
import { peditToolStateMachine } from '../tools/pedit-tool';
|
||||
import { rotateToolStateMachine } from '../tools/rotate-tool';
|
||||
import { scaleToolStateMachine } from '../tools/scale-tool';
|
||||
|
||||
export const MODIFY_COMMANDS: CadCommand[] = [
|
||||
{
|
||||
id: 'MOVE',
|
||||
label: '이동',
|
||||
aliases: ['M'],
|
||||
glyph: '✥',
|
||||
hint: '선택 객체를 기준점에서 새 위치로 이동한다',
|
||||
tool: Tool.MOVE,
|
||||
machine: moveToolStateMachine,
|
||||
},
|
||||
{
|
||||
id: 'COPY',
|
||||
label: '복사',
|
||||
aliases: ['CO', 'CP'],
|
||||
glyph: '⧉',
|
||||
hint: '선택 객체를 하나 이상의 위치에 복제한다',
|
||||
tool: Tool.COPY,
|
||||
machine: copyToolStateMachine,
|
||||
},
|
||||
{
|
||||
id: 'ROTATE',
|
||||
label: '회전',
|
||||
aliases: ['RO'],
|
||||
glyph: '↻',
|
||||
hint: '기준점을 중심으로 객체를 회전한다',
|
||||
tool: Tool.ROTATE,
|
||||
machine: rotateToolStateMachine,
|
||||
},
|
||||
{
|
||||
id: 'SCALE',
|
||||
label: '축척',
|
||||
aliases: ['SC'],
|
||||
glyph: '⤢',
|
||||
hint: '기준점과 비율로 크기를 변경한다',
|
||||
tool: Tool.SCALE,
|
||||
machine: scaleToolStateMachine,
|
||||
},
|
||||
{
|
||||
id: 'ALIGN',
|
||||
label: '정렬',
|
||||
aliases: ['AL'],
|
||||
glyph: '⇲',
|
||||
hint: '원본점·대상점 두 쌍으로 객체를 이동·회전한다',
|
||||
tool: Tool.ALIGN,
|
||||
machine: alignToolStateMachine,
|
||||
},
|
||||
{
|
||||
id: 'MIRROR',
|
||||
label: '대칭',
|
||||
aliases: ['MI'],
|
||||
glyph: '⇋',
|
||||
hint: '대칭축을 기준으로 객체를 반사 복사한다',
|
||||
tool: Tool.MIRROR,
|
||||
machine: mirrorToolStateMachine,
|
||||
},
|
||||
{
|
||||
id: 'OFFSET',
|
||||
label: '간격띄우기',
|
||||
aliases: ['O'],
|
||||
glyph: '⇶',
|
||||
hint: '평행선·동심원을 지정 거리만큼 띄워 만든다',
|
||||
tool: Tool.OFFSET,
|
||||
machine: offsetToolStateMachine,
|
||||
},
|
||||
{
|
||||
id: 'ARRAY',
|
||||
label: '배열',
|
||||
aliases: ['AR'],
|
||||
glyph: '⋮⋮',
|
||||
hint: '직사각형 패턴으로 객체를 반복 배치한다',
|
||||
tool: Tool.ARRAY,
|
||||
machine: arrayToolStateMachine,
|
||||
},
|
||||
{
|
||||
id: 'TRIM',
|
||||
label: '자르기',
|
||||
aliases: ['TR'],
|
||||
glyph: '⌦',
|
||||
hint: '교차점 사이의 필요 없는 부분을 잘라낸다',
|
||||
tool: Tool.ERASER,
|
||||
machine: eraserToolStateMachine,
|
||||
},
|
||||
{
|
||||
id: 'EXTEND',
|
||||
label: '연장',
|
||||
aliases: ['EX'],
|
||||
glyph: '⇥',
|
||||
hint: '선의 끝을 지정한 경계까지 늘린다',
|
||||
tool: Tool.EXTEND,
|
||||
machine: extendToolStateMachine,
|
||||
},
|
||||
{
|
||||
id: 'STRETCH',
|
||||
label: '신축',
|
||||
aliases: ['S'],
|
||||
glyph: '↔',
|
||||
hint: '기준점에 가까운 끝점을 끌어 늘린다',
|
||||
tool: Tool.STRETCH,
|
||||
machine: stretchToolStateMachine,
|
||||
},
|
||||
{
|
||||
id: 'FILLET',
|
||||
label: '모깎기',
|
||||
aliases: ['F'],
|
||||
glyph: '◟',
|
||||
hint: '두 선을 지정 반지름의 호로 연결한다',
|
||||
tool: Tool.FILLET,
|
||||
machine: filletToolStateMachine,
|
||||
},
|
||||
{
|
||||
id: 'CHAMFER',
|
||||
label: '모따기',
|
||||
aliases: ['CHA'],
|
||||
glyph: '◺',
|
||||
hint: '두 선을 직선 모따기로 연결한다',
|
||||
tool: Tool.CHAMFER,
|
||||
machine: chamferToolStateMachine,
|
||||
},
|
||||
{
|
||||
id: 'BLEND',
|
||||
label: '곡선 혼합',
|
||||
glyph: '∿',
|
||||
hint: '두 곡선의 끝을 부드러운 곡선으로 잇는다',
|
||||
tool: Tool.BLEND,
|
||||
machine: blendToolStateMachine,
|
||||
},
|
||||
{
|
||||
id: 'BREAK',
|
||||
label: '끊기',
|
||||
aliases: ['BR'],
|
||||
glyph: '⊣⊢',
|
||||
hint: '두 점 사이를 제거해 객체를 끊는다',
|
||||
tool: Tool.BREAK,
|
||||
machine: breakToolStateMachine,
|
||||
},
|
||||
{
|
||||
id: 'BREAKATPOINT',
|
||||
label: '점에서 끊기',
|
||||
glyph: '⊥',
|
||||
hint: '지정한 점에서 객체를 둘로 나눈다',
|
||||
tool: Tool.BREAK_AT_POINT,
|
||||
machine: breakAtPointToolStateMachine,
|
||||
},
|
||||
{
|
||||
id: 'JOIN',
|
||||
label: '결합',
|
||||
aliases: ['J'],
|
||||
glyph: '⋈',
|
||||
hint: '끝이 맞는 객체를 하나의 폴리선으로 결합한다',
|
||||
tool: Tool.JOIN,
|
||||
machine: joinToolStateMachine,
|
||||
},
|
||||
{
|
||||
id: 'EXPLODE',
|
||||
label: '분해',
|
||||
aliases: ['X'],
|
||||
glyph: '✳',
|
||||
hint: '폴리선·사각형·해치를 구성요소로 분해한다',
|
||||
tool: Tool.EXPLODE,
|
||||
machine: explodeToolStateMachine,
|
||||
},
|
||||
{
|
||||
id: 'ERASE',
|
||||
label: '지우기',
|
||||
aliases: ['E'],
|
||||
glyph: '⌫',
|
||||
hint: '선택한 객체를 삭제한다',
|
||||
tool: Tool.ERASE,
|
||||
machine: eraseToolStateMachine,
|
||||
},
|
||||
{
|
||||
id: 'LENGTHEN',
|
||||
label: '길이조정',
|
||||
aliases: ['LEN'],
|
||||
glyph: '↦',
|
||||
hint: '선의 길이를 증분만큼 늘리거나 줄인다',
|
||||
tool: Tool.LENGTHEN,
|
||||
machine: lengthenToolStateMachine,
|
||||
},
|
||||
{
|
||||
id: 'PEDIT',
|
||||
label: '폴리선 편집',
|
||||
aliases: ['PE'],
|
||||
glyph: '⌁',
|
||||
hint: '연결된 선을 폴리선으로 묶거나 편집한다',
|
||||
tool: Tool.PEDIT,
|
||||
machine: peditToolStateMachine,
|
||||
},
|
||||
{
|
||||
id: 'HATCHEDIT',
|
||||
label: '해치 편집',
|
||||
aliases: ['HE'],
|
||||
glyph: '▨',
|
||||
hint: '기존 해치의 패턴을 바꾼다',
|
||||
tool: Tool.HATCHEDIT,
|
||||
machine: hatchEditToolStateMachine,
|
||||
},
|
||||
{
|
||||
id: 'DRAWORDER',
|
||||
label: '맨 앞으로',
|
||||
aliases: ['DR'],
|
||||
glyph: '⤒',
|
||||
hint: '선택 객체를 가장 위에 그린다',
|
||||
tool: Tool.DRAWORDER_FRONT,
|
||||
machine: drawOrderFrontToolStateMachine,
|
||||
},
|
||||
{
|
||||
id: 'DRAWORDERBACK',
|
||||
label: '맨 뒤로',
|
||||
glyph: '⤓',
|
||||
hint: '선택 객체를 가장 아래에 그린다',
|
||||
tool: Tool.DRAWORDER_BACK,
|
||||
machine: drawOrderBackToolStateMachine,
|
||||
},
|
||||
{
|
||||
id: 'MATCHPROP',
|
||||
label: '특성 일치',
|
||||
aliases: ['MA'],
|
||||
glyph: '🖌',
|
||||
hint: '원본 객체의 색·굵기·선종류를 대상에 복사한다',
|
||||
tool: Tool.MATCHPROP,
|
||||
machine: matchPropToolStateMachine,
|
||||
},
|
||||
{
|
||||
id: 'OVERKILL',
|
||||
label: '중복 객체 삭제',
|
||||
glyph: '⧉',
|
||||
hint: '같은 자리에 겹친 중복 객체를 지운다',
|
||||
tool: Tool.OVERKILL,
|
||||
machine: overkillToolStateMachine,
|
||||
},
|
||||
{
|
||||
id: 'REVERSE',
|
||||
label: '방향 반전',
|
||||
glyph: '⇄',
|
||||
hint: '선·폴리선의 시작점과 끝점을 뒤집는다',
|
||||
tool: Tool.REVERSE,
|
||||
machine: reverseToolStateMachine,
|
||||
},
|
||||
{
|
||||
id: 'SETBYLAYER',
|
||||
label: 'ByLayer로',
|
||||
glyph: '▤',
|
||||
hint: '선택 객체의 특성을 도면층 값으로 되돌린다',
|
||||
tool: Tool.SETBYLAYER,
|
||||
machine: setByLayerToolStateMachine,
|
||||
},
|
||||
];
|
||||
|
||||
/** 원본 openwebcad의 정렬 도구 — AutoCAD 리본에는 없지만 기존 기능이라 유지한다 */
|
||||
export const ALIGN_COMMANDS: CadCommand[] = [
|
||||
{
|
||||
id: 'ALIGNLEFT',
|
||||
label: '왼쪽',
|
||||
glyph: '⇤',
|
||||
tool: Tool.ALIGN_LEFT,
|
||||
machine: alignLeftToolStateMachine,
|
||||
},
|
||||
{
|
||||
id: 'ALIGNCENTERH',
|
||||
label: '가로 중앙',
|
||||
glyph: '↔',
|
||||
tool: Tool.ALIGN_CENTER_HORIZONTAL,
|
||||
machine: alignCenterHorizontalToolStateMachine,
|
||||
},
|
||||
{
|
||||
id: 'ALIGNRIGHT',
|
||||
label: '오른쪽',
|
||||
glyph: '⇥',
|
||||
tool: Tool.ALIGN_RIGHT,
|
||||
machine: alignRightToolStateMachine,
|
||||
},
|
||||
{
|
||||
id: 'ALIGNTOP',
|
||||
label: '위',
|
||||
glyph: '⤒',
|
||||
tool: Tool.ALIGN_TOP,
|
||||
machine: alignTopToolStateMachine,
|
||||
},
|
||||
{
|
||||
id: 'ALIGNCENTERV',
|
||||
label: '세로 중앙',
|
||||
glyph: '↕',
|
||||
tool: Tool.ALIGN_CENTER_VERTICAL,
|
||||
machine: alignCenterVerticalToolStateMachine,
|
||||
},
|
||||
{
|
||||
id: 'ALIGNBOTTOM',
|
||||
label: '아래',
|
||||
glyph: '⤓',
|
||||
tool: Tool.ALIGN_BOTTOM,
|
||||
machine: alignBottomToolStateMachine,
|
||||
},
|
||||
];
|
||||
@@ -0,0 +1,270 @@
|
||||
/** 홈 탭 — 특성·그룹·유틸리티·클립보드 명령 (조사표 3절) */
|
||||
import type { CadCommand } from './command.types';
|
||||
import { Tool } from '../tools';
|
||||
import {
|
||||
copyBaseToolStateMachine,
|
||||
copySelectionToClipboard,
|
||||
cutSelectionToClipboard,
|
||||
pasteAsGroup,
|
||||
pasteAtOriginalCoordinates,
|
||||
pasteToolStateMachine,
|
||||
} from '../tools/utility/clipboard-tools';
|
||||
import {
|
||||
angleToolStateMachine,
|
||||
areaToolStateMachine,
|
||||
distanceToolStateMachine,
|
||||
idPointToolStateMachine,
|
||||
listSelectedEntities,
|
||||
measureGeomToolStateMachine,
|
||||
quickCalcToolStateMachine,
|
||||
radiusToolStateMachine,
|
||||
} from '../tools/utility/inquiry-tools';
|
||||
import {
|
||||
colorToolStateMachine,
|
||||
lineTypeToolStateMachine,
|
||||
lineWeightToolStateMachine,
|
||||
openPropertiesPalette,
|
||||
toggleQuickProperties,
|
||||
transparencyToolStateMachine,
|
||||
} from '../tools/utility/property-tools';
|
||||
import {
|
||||
groupToolStateMachine,
|
||||
hideObjectsToolStateMachine,
|
||||
isolateObjectsToolStateMachine,
|
||||
qSelectToolStateMachine,
|
||||
selectSimilarToolStateMachine,
|
||||
ungroupToolStateMachine,
|
||||
unhideAllObjects,
|
||||
} from '../tools/utility/selection-tools';
|
||||
|
||||
export const PROPERTY_COMMANDS: CadCommand[] = [
|
||||
{
|
||||
id: 'COLOR',
|
||||
label: '색상',
|
||||
aliases: ['COL'],
|
||||
glyph: '🎨',
|
||||
hint: '선택 객체의 색을 지정한다',
|
||||
tool: Tool.COLOR,
|
||||
machine: colorToolStateMachine,
|
||||
},
|
||||
{
|
||||
id: 'LINETYPE',
|
||||
label: '선종류',
|
||||
aliases: ['LT'],
|
||||
glyph: '┄',
|
||||
hint: '실선·파선·1점쇄선·점선을 지정한다',
|
||||
tool: Tool.LINETYPE,
|
||||
machine: lineTypeToolStateMachine,
|
||||
},
|
||||
{
|
||||
id: 'LWEIGHT',
|
||||
label: '선가중치',
|
||||
aliases: ['LW'],
|
||||
glyph: '▬',
|
||||
hint: '선 굵기를 지정한다',
|
||||
tool: Tool.LWEIGHT,
|
||||
machine: lineWeightToolStateMachine,
|
||||
},
|
||||
{
|
||||
id: 'PROPERTIES',
|
||||
label: '특성',
|
||||
aliases: ['CH', 'MO', 'PR'],
|
||||
glyph: '☰',
|
||||
hint: '선택 객체의 특성을 보고 고친다',
|
||||
run: openPropertiesPalette,
|
||||
},
|
||||
{
|
||||
id: 'QUICKPROPERTIES',
|
||||
label: '빠른 특성',
|
||||
aliases: ['QP'],
|
||||
glyph: '⌗',
|
||||
hint: '선택 객체의 주요 특성만 화면에 띄운다',
|
||||
run: toggleQuickProperties,
|
||||
},
|
||||
{
|
||||
id: 'TRANSPARENCY',
|
||||
label: '투명도',
|
||||
glyph: '◍',
|
||||
hint: '선택 객체의 투명도를 지정한다',
|
||||
tool: Tool.TRANSPARENCY,
|
||||
machine: transparencyToolStateMachine,
|
||||
},
|
||||
];
|
||||
|
||||
export const GROUP_COMMANDS: CadCommand[] = [
|
||||
{
|
||||
id: 'GROUP',
|
||||
label: '그룹',
|
||||
aliases: ['G'],
|
||||
glyph: '⧉',
|
||||
hint: '여러 객체를 함께 선택되는 그룹으로 묶는다',
|
||||
tool: Tool.GROUP,
|
||||
machine: groupToolStateMachine,
|
||||
},
|
||||
{
|
||||
id: 'UNGROUP',
|
||||
label: '그룹 해제',
|
||||
glyph: '⧅',
|
||||
hint: '그룹 묶음을 푼다',
|
||||
tool: Tool.UNGROUP,
|
||||
machine: ungroupToolStateMachine,
|
||||
},
|
||||
];
|
||||
|
||||
export const INQUIRY_COMMANDS: CadCommand[] = [
|
||||
{
|
||||
id: 'DIST',
|
||||
label: '거리',
|
||||
aliases: ['DI'],
|
||||
glyph: '↔',
|
||||
hint: '두 점 사이의 거리와 각도를 잰다',
|
||||
tool: Tool.DIST,
|
||||
machine: distanceToolStateMachine,
|
||||
},
|
||||
{
|
||||
id: 'RADIUS',
|
||||
label: '반지름',
|
||||
glyph: '◜',
|
||||
hint: '원 또는 호의 반지름을 잰다',
|
||||
tool: Tool.RADIUS_INQUIRY,
|
||||
machine: radiusToolStateMachine,
|
||||
},
|
||||
{
|
||||
id: 'ANGLE',
|
||||
label: '각도',
|
||||
glyph: '∠',
|
||||
hint: '세 점이 이루는 각도를 잰다',
|
||||
tool: Tool.ANGLE_INQUIRY,
|
||||
machine: angleToolStateMachine,
|
||||
},
|
||||
{
|
||||
id: 'AREA',
|
||||
label: '면적',
|
||||
aliases: ['AA'],
|
||||
glyph: '▦',
|
||||
hint: '닫힌 경계의 면적과 둘레를 계산한다',
|
||||
tool: Tool.AREA,
|
||||
machine: areaToolStateMachine,
|
||||
},
|
||||
{
|
||||
id: 'MEASUREGEOM',
|
||||
label: '빠른 측정',
|
||||
aliases: ['MEA'],
|
||||
glyph: '📏',
|
||||
hint: '거리·반지름·각도·면적 중 하나를 골라 잰다',
|
||||
tool: Tool.MEASUREGEOM,
|
||||
machine: measureGeomToolStateMachine,
|
||||
},
|
||||
{
|
||||
id: 'ID',
|
||||
label: '점 좌표',
|
||||
glyph: '⌖',
|
||||
hint: '지정한 점의 좌표를 표시한다',
|
||||
tool: Tool.ID_POINT,
|
||||
machine: idPointToolStateMachine,
|
||||
},
|
||||
{
|
||||
id: 'LIST',
|
||||
label: '리스트',
|
||||
aliases: ['LI'],
|
||||
glyph: '≣',
|
||||
hint: '선택 객체의 종류·길이·크기를 표시한다',
|
||||
run: listSelectedEntities,
|
||||
},
|
||||
{
|
||||
id: 'QSELECT',
|
||||
label: '빠른 선택',
|
||||
glyph: '⋔',
|
||||
hint: '객체 유형으로 선택 집합을 만든다',
|
||||
tool: Tool.QSELECT,
|
||||
machine: qSelectToolStateMachine,
|
||||
},
|
||||
{
|
||||
id: 'QUICKCALC',
|
||||
label: '계산기',
|
||||
aliases: ['QC'],
|
||||
glyph: '🖩',
|
||||
hint: '수식을 계산한다',
|
||||
tool: Tool.QUICKCALC,
|
||||
machine: quickCalcToolStateMachine,
|
||||
},
|
||||
{
|
||||
id: 'SELECTSIMILAR',
|
||||
label: '유사 선택',
|
||||
glyph: '⁝⁝',
|
||||
hint: '같은 종류·색·도면층 객체를 모두 선택한다',
|
||||
tool: Tool.SELECTSIMILAR,
|
||||
machine: selectSimilarToolStateMachine,
|
||||
},
|
||||
{
|
||||
id: 'ISOLATEOBJECTS',
|
||||
label: '객체 분리',
|
||||
glyph: '◧',
|
||||
hint: '선택 객체만 남기고 나머지를 숨긴다',
|
||||
tool: Tool.ISOLATEOBJECTS,
|
||||
machine: isolateObjectsToolStateMachine,
|
||||
},
|
||||
{
|
||||
id: 'HIDEOBJECTS',
|
||||
label: '객체 숨기기',
|
||||
glyph: '◌',
|
||||
hint: '선택 객체를 일시적으로 숨긴다',
|
||||
tool: Tool.HIDEOBJECTS,
|
||||
machine: hideObjectsToolStateMachine,
|
||||
},
|
||||
{
|
||||
id: 'UNISOLATEOBJECTS',
|
||||
label: '객체 분리 종료',
|
||||
aliases: ['UNHIDE', 'UNISOLATE'],
|
||||
glyph: '◉',
|
||||
hint: '숨긴 객체를 다시 표시한다',
|
||||
run: unhideAllObjects,
|
||||
},
|
||||
];
|
||||
|
||||
export const CLIPBOARD_COMMANDS: CadCommand[] = [
|
||||
{
|
||||
id: 'COPYCLIP',
|
||||
label: '복사',
|
||||
glyph: '⎘',
|
||||
hint: '선택 객체를 클립보드로 복사한다',
|
||||
run: copySelectionToClipboard,
|
||||
},
|
||||
{
|
||||
id: 'CUTCLIP',
|
||||
label: '잘라내기',
|
||||
glyph: '✂',
|
||||
hint: '선택 객체를 클립보드로 옮긴다',
|
||||
run: cutSelectionToClipboard,
|
||||
},
|
||||
{
|
||||
id: 'PASTECLIP',
|
||||
label: '붙여넣기',
|
||||
glyph: '📋',
|
||||
hint: '클립보드 객체를 지정 위치에 붙여넣는다',
|
||||
tool: Tool.PASTECLIP,
|
||||
machine: pasteToolStateMachine,
|
||||
},
|
||||
{
|
||||
id: 'COPYBASE',
|
||||
label: '기준점 복사',
|
||||
glyph: '⌖',
|
||||
hint: '기준점을 지정해 복사한다',
|
||||
tool: Tool.COPYBASE,
|
||||
machine: copyBaseToolStateMachine,
|
||||
},
|
||||
{
|
||||
id: 'PASTEORIG',
|
||||
label: '원래 좌표로 붙여넣기',
|
||||
glyph: '⇱',
|
||||
hint: '복사한 좌표 그대로 붙여넣는다',
|
||||
run: pasteAtOriginalCoordinates,
|
||||
},
|
||||
{
|
||||
id: 'PASTEBLOCK',
|
||||
label: '그룹으로 붙여넣기',
|
||||
glyph: '❐',
|
||||
hint: '붙여넣으면서 하나의 그룹으로 묶는다',
|
||||
run: pasteAsGroup,
|
||||
},
|
||||
];
|
||||
@@ -0,0 +1,71 @@
|
||||
/** 뷰 탭 — 탐색·재생성 명령 (조사표 7절 중 구현분) */
|
||||
import { toast } from 'react-toastify';
|
||||
import type { CadCommand } from './command.types';
|
||||
import { bumpSceneVersion } from '../helpers/scene-version';
|
||||
import { getScreenCanvasDrawController, setEntities, getEntities } from '../state';
|
||||
import { Tool } from '../tools';
|
||||
import { selectToolStateMachine } from '../tools/select-tool';
|
||||
|
||||
const zoomBy = (factor: number): string => {
|
||||
const controller = getScreenCanvasDrawController();
|
||||
controller.setScreenScale(Math.max(0.01, controller.getScreenScale() * factor));
|
||||
return `줌 ${Math.round(controller.getScreenScale() * 100)}%`;
|
||||
};
|
||||
|
||||
export const VIEW_COMMANDS: CadCommand[] = [
|
||||
{
|
||||
id: 'SELECT',
|
||||
label: '선택',
|
||||
aliases: ['SE'],
|
||||
glyph: '↖',
|
||||
hint: '객체를 클릭하거나 선택 사각형으로 고른다',
|
||||
tool: Tool.SELECT,
|
||||
machine: selectToolStateMachine,
|
||||
},
|
||||
{
|
||||
id: 'ZOOM',
|
||||
label: '범위 줌',
|
||||
aliases: ['Z'],
|
||||
glyph: '⛶',
|
||||
hint: '도면 전체가 보이도록 화면을 맞춘다',
|
||||
run: () => {
|
||||
getScreenCanvasDrawController().zoomToFitScreen();
|
||||
return '도면 전체 보기';
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'ZOOMIN',
|
||||
label: '확대',
|
||||
glyph: '+',
|
||||
run: () => zoomBy(1.25),
|
||||
},
|
||||
{
|
||||
id: 'ZOOMOUT',
|
||||
label: '축소',
|
||||
glyph: '-',
|
||||
run: () => zoomBy(0.8),
|
||||
},
|
||||
{
|
||||
id: 'REGEN',
|
||||
label: '재생성',
|
||||
aliases: ['RE'],
|
||||
glyph: '⟳',
|
||||
hint: '화면 캐시를 버리고 객체를 다시 그린다',
|
||||
run: () => {
|
||||
bumpSceneVersion();
|
||||
setEntities([...getEntities()], false);
|
||||
return '화면 재생성';
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'PAN',
|
||||
label: '초점이동',
|
||||
aliases: ['P'],
|
||||
glyph: '✋',
|
||||
hint: '휠 버튼을 누른 채 끌면 화면이 움직인다',
|
||||
run: () => {
|
||||
toast.info('휠 버튼을 누른 채 끌면 화면이 이동합니다.');
|
||||
return '초점이동 안내';
|
||||
},
|
||||
},
|
||||
];
|
||||
@@ -0,0 +1,40 @@
|
||||
/**
|
||||
* 치수·지시선·표 스타일 설정 (DIMSTYLE·MLEADERSTYLE·TABLESTYLE·주석 축척).
|
||||
* 기본값은 App.consts의 상수를 그대로 쓰고, 명령이 바꾸면 그 뒤로 반영된다.
|
||||
*/
|
||||
import {
|
||||
ARROW_HEAD_LENGTH,
|
||||
MEASUREMENT_DECIMAL_PLACES,
|
||||
MEASUREMENT_FONT_SIZE,
|
||||
} from '../App.consts';
|
||||
|
||||
let dimTextHeight = MEASUREMENT_FONT_SIZE;
|
||||
let dimArrowSize = ARROW_HEAD_LENGTH;
|
||||
let dimDecimals = MEASUREMENT_DECIMAL_PLACES;
|
||||
/** 주석 축척 — 치수·문자·화살표 크기에 곱한다 */
|
||||
let annotationScale = 1;
|
||||
|
||||
let tableColumnWidth = 40;
|
||||
let tableRowHeight = 10;
|
||||
|
||||
export const getDimTextHeight = () => dimTextHeight * annotationScale;
|
||||
export const getDimArrowSize = () => dimArrowSize * annotationScale;
|
||||
export const getDimDecimals = () => dimDecimals;
|
||||
export const getAnnotationScale = () => annotationScale;
|
||||
export const getTableColumnWidth = () => tableColumnWidth;
|
||||
export const getTableRowHeight = () => tableRowHeight;
|
||||
|
||||
export function setDimStyle(textHeight: number, arrowSize: number, decimals: number): void {
|
||||
if (textHeight > 0) dimTextHeight = textHeight;
|
||||
if (arrowSize > 0) dimArrowSize = arrowSize;
|
||||
if (decimals >= 0) dimDecimals = Math.min(6, Math.round(decimals));
|
||||
}
|
||||
|
||||
export function setAnnotationScale(scale: number): void {
|
||||
if (scale > 0) annotationScale = scale;
|
||||
}
|
||||
|
||||
export function setTableStyle(columnWidth: number, rowHeight: number): void {
|
||||
if (columnWidth > 0) tableColumnWidth = columnWidth;
|
||||
if (rowHeight > 0) tableRowHeight = rowHeight;
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
/**
|
||||
* 그리기 명령의 설정값 — AutoCAD의 MLSTYLE·HATCH 대화상자에 해당하는 상태.
|
||||
* 명령이 물어보는 값 중 "매번 묻지 않는 것"만 여기에 둔다.
|
||||
*/
|
||||
import type { HatchStyle } from '../entities/HatchEntity';
|
||||
|
||||
let mlineElements = 2;
|
||||
let mlineSpacing = 1;
|
||||
let hatchStyle: HatchStyle = 'pattern';
|
||||
/** null이면 경계 크기에 맞춰 자동 계산한다 */
|
||||
let hatchSpacing: number | null = null;
|
||||
let hatchAngle = Math.PI / 4;
|
||||
|
||||
export const getMlineElements = () => mlineElements;
|
||||
export const getMlineSpacing = () => mlineSpacing;
|
||||
export const setMlineStyle = (elements: number, spacing: number) => {
|
||||
mlineElements = Math.max(2, Math.round(elements));
|
||||
mlineSpacing = Math.abs(spacing) || 1;
|
||||
};
|
||||
|
||||
export const getHatchStyle = () => hatchStyle;
|
||||
export const getHatchSpacing = () => hatchSpacing;
|
||||
export const getHatchAngle = () => hatchAngle;
|
||||
export const setHatchStyle = (style: HatchStyle) => {
|
||||
hatchStyle = style;
|
||||
};
|
||||
export const setHatchSpacing = (spacing: number | null) => {
|
||||
hatchSpacing = spacing;
|
||||
};
|
||||
export const setHatchAngle = (angleRad: number) => {
|
||||
hatchAngle = angleRad;
|
||||
};
|
||||
|
||||
/** 경계 크기에 맞춘 기본 해치 간격 — 도면 축척이 달라도 눈에 보이게 한다 */
|
||||
export const autoHatchSpacing = (width: number, height: number): number =>
|
||||
Math.max(Math.hypot(width, height) / 30, 1e-6);
|
||||
@@ -0,0 +1,95 @@
|
||||
/**
|
||||
* 명령 레지스트리 — 리본·명령행·단축키가 읽는 단일 소스.
|
||||
* 새 명령은 commands.*.ts에 한 줄 추가하면 세 곳에 동시에 나타난다.
|
||||
*/
|
||||
import type { AnyToolMachine, CadCommand } from './command.types';
|
||||
import type { Tool } from '../tools';
|
||||
import {
|
||||
DIMENSION_COMMANDS,
|
||||
LEADER_COMMANDS,
|
||||
MARKUP_COMMANDS,
|
||||
TABLE_COMMANDS,
|
||||
TEXT_COMMANDS,
|
||||
} from './commands.annotate';
|
||||
import { DRAW_COMMANDS } from './commands.draw';
|
||||
import { FILE_COMMANDS } from './commands.file';
|
||||
import { INSERT_COMMANDS } from './commands.insert';
|
||||
import { LAYER_COMMANDS } from './commands.layer';
|
||||
import {
|
||||
CLIPBOARD_COMMANDS,
|
||||
GROUP_COMMANDS,
|
||||
INQUIRY_COMMANDS,
|
||||
PROPERTY_COMMANDS,
|
||||
} from './commands.utility';
|
||||
import { ALIGN_COMMANDS, MODIFY_COMMANDS } from './commands.modify';
|
||||
import { VIEW_COMMANDS } from './commands.view';
|
||||
|
||||
const ALL_COMMANDS: CadCommand[] = [
|
||||
...DRAW_COMMANDS,
|
||||
...MODIFY_COMMANDS,
|
||||
...ALIGN_COMMANDS,
|
||||
...TEXT_COMMANDS,
|
||||
...DIMENSION_COMMANDS,
|
||||
...LEADER_COMMANDS,
|
||||
...TABLE_COMMANDS,
|
||||
...MARKUP_COMMANDS,
|
||||
...LAYER_COMMANDS,
|
||||
...PROPERTY_COMMANDS,
|
||||
...GROUP_COMMANDS,
|
||||
...INQUIRY_COMMANDS,
|
||||
...CLIPBOARD_COMMANDS,
|
||||
...INSERT_COMMANDS,
|
||||
...VIEW_COMMANDS,
|
||||
...FILE_COMMANDS,
|
||||
];
|
||||
|
||||
const COMMANDS_BY_ID = new Map<string, CadCommand>(
|
||||
ALL_COMMANDS.map((command) => [command.id, command])
|
||||
);
|
||||
|
||||
const COMMANDS_BY_ALIAS = new Map<string, CadCommand>();
|
||||
for (const command of ALL_COMMANDS) {
|
||||
for (const alias of command.aliases ?? []) {
|
||||
if (!COMMANDS_BY_ALIAS.has(alias)) {
|
||||
COMMANDS_BY_ALIAS.set(alias, command);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export const getAllCommands = (): CadCommand[] => ALL_COMMANDS;
|
||||
|
||||
export const getCommandById = (id: string): CadCommand | undefined => COMMANDS_BY_ID.get(id);
|
||||
|
||||
/** 활성 도구로 어떤 명령이 실행 중인지 되짚는다 (리본 강조·상태 표시용) */
|
||||
export const getCommandByTool = (tool: Tool | null): CadCommand | undefined =>
|
||||
tool ? ALL_COMMANDS.find((command) => command.tool === tool) : undefined;
|
||||
|
||||
/** 도구형 명령의 xstate 머신 표 — 도구 활성화 경로가 이 표만 본다 */
|
||||
export const TOOL_STATE_MACHINES = Object.fromEntries(
|
||||
ALL_COMMANDS.filter((command) => command.tool && command.machine).map((command) => [
|
||||
command.tool,
|
||||
command.machine,
|
||||
])
|
||||
) as Record<Tool, AnyToolMachine>;
|
||||
|
||||
/** 명령행 입력 해석 — 정확한 이름·별칭이 먼저, 없으면 접두사 후보의 첫 항목 */
|
||||
export function resolveCommandInput(input: string): CadCommand | undefined {
|
||||
const text = input.trim().toUpperCase();
|
||||
if (!text) return undefined;
|
||||
return COMMANDS_BY_ID.get(text) ?? COMMANDS_BY_ALIAS.get(text) ?? matchCommandPrefixes(text)[0];
|
||||
}
|
||||
|
||||
/** 접두사가 같은 명령 후보 (커서 옆 자동완성 목록에 쓴다) */
|
||||
export function matchCommandPrefixes(input: string): CadCommand[] {
|
||||
const text = input.trim().toUpperCase();
|
||||
if (!text) return [];
|
||||
const byAlias = ALL_COMMANDS.filter((command) =>
|
||||
(command.aliases ?? []).some((alias) => alias.startsWith(text))
|
||||
);
|
||||
const byId = ALL_COMMANDS.filter((command) => command.id.startsWith(text));
|
||||
return [...new Set([...byAlias, ...byId])];
|
||||
}
|
||||
|
||||
/** 명령행 자동완성에 보여줄 문자열 (예: "LINE (L) 선") */
|
||||
export const describeCommand = (command: CadCommand): string =>
|
||||
`${command.id}${command.aliases?.length ? ` (${command.aliases[0]})` : ''} ${command.label}`;
|
||||
@@ -0,0 +1,61 @@
|
||||
/** 명령 실행기 — 리본 버튼·명령행·단축키가 모두 이 경로로 들어온다. */
|
||||
import { toast } from 'react-toastify';
|
||||
import { Actor } from 'xstate';
|
||||
import type { CadCommand } from './command.types';
|
||||
import { getCommandById, resolveCommandInput } from './registry';
|
||||
import { HtmlEvent } from '../App.types';
|
||||
import { getSelectedEntities, setActiveToolActor } from '../state';
|
||||
|
||||
const COMMAND_HISTORY_LIMIT = 200;
|
||||
|
||||
let lastCommandId: string | null = null;
|
||||
const commandHistory: string[] = [];
|
||||
|
||||
export const getLastCommandId = (): string | null => lastCommandId;
|
||||
export const getCommandHistory = (): string[] => commandHistory;
|
||||
|
||||
function log(line: string) {
|
||||
commandHistory.push(line);
|
||||
if (commandHistory.length > COMMAND_HISTORY_LIMIT) {
|
||||
commandHistory.splice(0, commandHistory.length - COMMAND_HISTORY_LIMIT);
|
||||
}
|
||||
window.dispatchEvent(new CustomEvent(HtmlEvent.UPDATE_STATE));
|
||||
}
|
||||
|
||||
/** 명령 한 건 실행. 도구형이면 도구를 활성화하고, 즉시형이면 run()을 부른다. */
|
||||
export function runCommand(command: CadCommand): string {
|
||||
if (command.needsSelection && getSelectedEntities().length === 0) {
|
||||
toast.info(`${command.label}: 객체를 먼저 선택하십시오.`);
|
||||
log(`${command.id}: 선택 없음`);
|
||||
return '';
|
||||
}
|
||||
|
||||
lastCommandId = command.id;
|
||||
|
||||
if (command.machine) {
|
||||
setActiveToolActor(new Actor(command.machine));
|
||||
log(`${command.id} ${command.label}`);
|
||||
return command.id;
|
||||
}
|
||||
|
||||
const result = command.run?.();
|
||||
log(`${command.id} ${typeof result === 'string' ? result : command.label}`);
|
||||
return typeof result === 'string' ? result : command.id;
|
||||
}
|
||||
|
||||
/** 명령 이름·별칭 문자열로 실행 (명령행 입력) */
|
||||
export function runCommandInput(input: string): boolean {
|
||||
const command = resolveCommandInput(input);
|
||||
if (!command) return false;
|
||||
runCommand(command);
|
||||
return true;
|
||||
}
|
||||
|
||||
/** 직전 명령 반복 (AutoCAD의 빈 ENTER 동작) */
|
||||
export function repeatLastCommand(): boolean {
|
||||
if (!lastCommandId) return false;
|
||||
const command = getCommandById(lastCommandId);
|
||||
if (!command) return false;
|
||||
runCommand(command);
|
||||
return true;
|
||||
}
|
||||
@@ -0,0 +1,98 @@
|
||||
/** 명령행 — AutoCAD 명령행처럼 기록·입력·자동완성을 담당한다. */
|
||||
import { type FC, type FormEvent, useMemo, useRef, useState } from 'react';
|
||||
import { describeCommand, matchCommandPrefixes } from '../commands/registry';
|
||||
import { getCommandHistory, runCommandInput } from '../commands/run-command';
|
||||
import { getInputController, getLastStateInstructions } from '../state';
|
||||
|
||||
export const CommandLine: FC = () => {
|
||||
const [text, setText] = useState('');
|
||||
const [historyIndex, setHistoryIndex] = useState<number | null>(null);
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
const history = getCommandHistory();
|
||||
const instruction = getLastStateInstructions() || '명령을 입력하거나 도구를 선택하십시오.';
|
||||
|
||||
const suggestions = useMemo(() => matchCommandPrefixes(text).slice(0, 8), [text]);
|
||||
|
||||
const submit = (event: FormEvent) => {
|
||||
event.preventDefault();
|
||||
const value = text.trim();
|
||||
if (!value) {
|
||||
// 빈 ENTER는 활성 명령의 확정 신호다 (선택 끝내기·직전 명령 반복)
|
||||
getInputController().handleEnterKey();
|
||||
return;
|
||||
}
|
||||
// 진행 중인 명령이 문자·숫자를 기다리면 그쪽으로, 아니면 명령으로 해석된다
|
||||
getInputController().submitText(value);
|
||||
setText('');
|
||||
setHistoryIndex(null);
|
||||
};
|
||||
|
||||
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]);
|
||||
};
|
||||
|
||||
return (
|
||||
<section className="cad-command-area controls">
|
||||
<div className="cad-command-history">
|
||||
{history.slice(-3).map((line, index) => (
|
||||
// biome-ignore lint/suspicious/noArrayIndexKey: 기록은 순서 자체가 식별자다
|
||||
<span key={`${line}-${index}`}>{line}</span>
|
||||
))}
|
||||
</div>
|
||||
<form onSubmit={submit}>
|
||||
<label htmlFor="cad-command">명령:</label>
|
||||
<input
|
||||
id="cad-command"
|
||||
ref={inputRef}
|
||||
value={text}
|
||||
onChange={(event) => setText(event.target.value)}
|
||||
onKeyDown={(event) => {
|
||||
if (event.key === 'Escape') {
|
||||
event.preventDefault();
|
||||
getInputController().handleEscapeKey();
|
||||
}
|
||||
if (event.key === 'ArrowUp') {
|
||||
event.preventDefault();
|
||||
recallHistory(-1);
|
||||
}
|
||||
if (event.key === 'ArrowDown') {
|
||||
event.preventDefault();
|
||||
recallHistory(1);
|
||||
}
|
||||
if (event.key === 'Tab' && suggestions.length) {
|
||||
event.preventDefault();
|
||||
setText(suggestions[0].id);
|
||||
}
|
||||
}}
|
||||
placeholder="명령 입력 (예: L, LINE, REC, MOVE)"
|
||||
autoComplete="off"
|
||||
spellCheck={false}
|
||||
/>
|
||||
<span className="cad-command-prompt">{instruction}</span>
|
||||
</form>
|
||||
{text.trim() && suggestions.length > 0 && (
|
||||
<div className="cad-command-suggestions">
|
||||
{suggestions.map((command) => (
|
||||
<button
|
||||
type="button"
|
||||
key={command.id}
|
||||
onClick={() => {
|
||||
runCommandInput(command.id);
|
||||
setText('');
|
||||
inputRef.current?.focus();
|
||||
}}
|
||||
>
|
||||
{describeCommand(command)}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,68 @@
|
||||
/** 좌측 팔레트 — 특성(PROPERTIES)과 도면층 관리자 (AutoCAD 팔레트 자리) */
|
||||
import type { FC } from 'react';
|
||||
import { LayerManager } from './LayerManager';
|
||||
import { PropertiesEditor } from './PropertiesEditor';
|
||||
import { getInspectorTab, openInspector } from './ui-state';
|
||||
import {
|
||||
getActiveLayerId,
|
||||
getLayers,
|
||||
setActiveLayerId,
|
||||
setLayers,
|
||||
} from '../state';
|
||||
|
||||
interface InspectorPanelProps {
|
||||
collapsed: boolean;
|
||||
onToggleCollapsed: () => void;
|
||||
}
|
||||
|
||||
export const InspectorPanel: FC<InspectorPanelProps> = ({ collapsed, onToggleCollapsed }) => {
|
||||
const tab = getInspectorTab();
|
||||
const layers = getLayers();
|
||||
const activeLayerId = getActiveLayerId();
|
||||
|
||||
return (
|
||||
<aside className="cad-inspector controls" data-collapsed={collapsed}>
|
||||
<button
|
||||
className="cad-inspector__collapse"
|
||||
type="button"
|
||||
onClick={onToggleCollapsed}
|
||||
title={collapsed ? '패널 펼치기' : '패널 접기'}
|
||||
>
|
||||
{collapsed ? '›' : '‹'}
|
||||
</button>
|
||||
{!collapsed && (
|
||||
<>
|
||||
<div className="cad-inspector-tabs">
|
||||
<button
|
||||
type="button"
|
||||
data-active={tab === 'properties'}
|
||||
onClick={() => openInspector('properties')}
|
||||
>
|
||||
특성
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
data-active={tab === 'layers'}
|
||||
onClick={() => openInspector('layers')}
|
||||
>
|
||||
도면층
|
||||
</button>
|
||||
</div>
|
||||
{tab === 'properties' ? (
|
||||
<div className="cad-properties">
|
||||
<PropertiesEditor />
|
||||
</div>
|
||||
) : (
|
||||
<LayerManager
|
||||
className="cad-layer-manager"
|
||||
layers={layers}
|
||||
activeLayerId={activeLayerId}
|
||||
setLayers={(next) => setLayers(next)}
|
||||
setActiveLayerId={(id) => setActiveLayerId(id)}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</aside>
|
||||
);
|
||||
};
|
||||
@@ -93,7 +93,7 @@ export const LayerManager: FC<LayerManagerProps> = ({
|
||||
title="Set this layer as active"
|
||||
active={activeLayerId === layer.id}
|
||||
onClick={(evt) => handleLayerClick(evt, layer.id)}
|
||||
className="flex-grow"
|
||||
className="flex-grow min-w-0 overflow-hidden"
|
||||
left={
|
||||
<>
|
||||
<Button
|
||||
@@ -101,7 +101,7 @@ export const LayerManager: FC<LayerManagerProps> = ({
|
||||
title="Show/hide layer content"
|
||||
onClick={(evt) => handleShowHideLayer(evt, layer.id)}
|
||||
size="small"
|
||||
className="w-10 -ml-1"
|
||||
className="w-7 -ml-1"
|
||||
type="transparent"
|
||||
active={activeLayerId === layer.id}
|
||||
/>
|
||||
@@ -110,7 +110,7 @@ export const LayerManager: FC<LayerManagerProps> = ({
|
||||
title="Lock/Unlock layer content"
|
||||
onClick={(evt) => handleLockUnlockLayer(evt, layer.id)}
|
||||
size="small"
|
||||
className="w-10"
|
||||
className="w-7"
|
||||
type="transparent"
|
||||
active={activeLayerId === layer.id}
|
||||
/>
|
||||
@@ -123,7 +123,7 @@ export const LayerManager: FC<LayerManagerProps> = ({
|
||||
title="Select entities on this layer"
|
||||
onClick={(evt) => handleSelectEntitiesOnLayer(evt, layer.id)}
|
||||
size="small"
|
||||
className="w-10"
|
||||
className="w-7"
|
||||
type="transparent"
|
||||
active={activeLayerId === layer.id}
|
||||
/>
|
||||
@@ -132,7 +132,7 @@ export const LayerManager: FC<LayerManagerProps> = ({
|
||||
title="Assign current selection to layer"
|
||||
onClick={(evt) => handleAssignSelectionToLayer(evt, layer.id)}
|
||||
size="small"
|
||||
className="w-10"
|
||||
className="w-7"
|
||||
type="transparent"
|
||||
active={activeLayerId === layer.id}
|
||||
/>
|
||||
@@ -141,7 +141,7 @@ export const LayerManager: FC<LayerManagerProps> = ({
|
||||
title="delete layer and content"
|
||||
onClick={(evt) => handleDeleteLayer(evt, layer.id)}
|
||||
size="small"
|
||||
className="w-10"
|
||||
className="w-7"
|
||||
type="transparent"
|
||||
active={activeLayerId === layer.id}
|
||||
/>
|
||||
|
||||
@@ -0,0 +1,154 @@
|
||||
/** 특성 팔레트 본문 — 선택 객체의 값을 읽고 바로 고친다 (PROPERTIES) */
|
||||
import type { FC } from 'react';
|
||||
import type { Entity } from '../entities/Entity';
|
||||
import { polylineLength, sampleEntityPoints } from '../helpers/geometry/sample-entity';
|
||||
import {
|
||||
getEntities,
|
||||
getLayers,
|
||||
getSelectedEntities,
|
||||
setEntities,
|
||||
} from '../state';
|
||||
import { dashToLineType, LINE_TYPES, LINE_WIDTHS } from './RibbonWidgets';
|
||||
|
||||
interface PropertiesEditorProps {
|
||||
compact?: boolean;
|
||||
}
|
||||
|
||||
function applyToSelection(mutate: (entity: Entity) => void): void {
|
||||
const selected = getSelectedEntities();
|
||||
if (!selected.length) return;
|
||||
for (const entity of selected) mutate(entity);
|
||||
setEntities([...getEntities()], true);
|
||||
}
|
||||
|
||||
/** 여러 객체가 값이 다르면 '*가지각색' 대신 첫 객체 값을 보여 준다 (AutoCAD와 같은 관행) */
|
||||
export const PropertiesEditor: FC<PropertiesEditorProps> = ({ compact = false }) => {
|
||||
const selected = getSelectedEntities();
|
||||
const layers = getLayers();
|
||||
const first = selected[0];
|
||||
|
||||
if (!first) {
|
||||
return <p className="cad-properties__empty">선택된 객체가 없습니다.</p>;
|
||||
}
|
||||
|
||||
const points = sampleEntityPoints(first);
|
||||
const box = first.getBoundingBox();
|
||||
|
||||
return (
|
||||
<div className="cad-properties-editor" data-compact={compact}>
|
||||
<div className="cad-properties-editor__title">
|
||||
{selected.length === 1 ? first.getType() : `여러 객체 (${selected.length})`}
|
||||
</div>
|
||||
|
||||
<label>
|
||||
<span>색상</span>
|
||||
<input
|
||||
type="color"
|
||||
value={first.lineColor}
|
||||
onChange={(event) =>
|
||||
applyToSelection((entity) => {
|
||||
entity.lineColor = event.target.value;
|
||||
})
|
||||
}
|
||||
/>
|
||||
</label>
|
||||
|
||||
<label>
|
||||
<span>선가중치</span>
|
||||
<select
|
||||
value={first.lineWidth}
|
||||
onChange={(event) =>
|
||||
applyToSelection((entity) => {
|
||||
entity.lineWidth = Number(event.target.value);
|
||||
})
|
||||
}
|
||||
>
|
||||
{LINE_WIDTHS.map((width) => (
|
||||
<option key={width} value={width}>
|
||||
{width}px
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
|
||||
<label>
|
||||
<span>선종류</span>
|
||||
<select
|
||||
value={dashToLineType(first.lineDash)}
|
||||
onChange={(event) => {
|
||||
const dash = LINE_TYPES.find((type) => type.value === event.target.value)?.dash;
|
||||
applyToSelection((entity) => {
|
||||
entity.lineDash = dash ? [...dash] : undefined;
|
||||
});
|
||||
}}
|
||||
>
|
||||
{LINE_TYPES.map((type) => (
|
||||
<option key={type.value} value={type.value}>
|
||||
{type.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
|
||||
<label>
|
||||
<span>도면층</span>
|
||||
<select
|
||||
value={first.layerId}
|
||||
onChange={(event) =>
|
||||
applyToSelection((entity) => {
|
||||
entity.layerId = event.target.value;
|
||||
})
|
||||
}
|
||||
>
|
||||
{layers.map((layer) => (
|
||||
<option key={layer.id} value={layer.id}>
|
||||
{layer.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
|
||||
<label>
|
||||
<span>투명도</span>
|
||||
<input
|
||||
type="number"
|
||||
min={0}
|
||||
max={90}
|
||||
step={5}
|
||||
value={Math.round((1 - (first.opacity ?? 1)) * 100)}
|
||||
onChange={(event) => {
|
||||
const percent = Math.min(90, Math.max(0, Number(event.target.value)));
|
||||
applyToSelection((entity) => {
|
||||
entity.opacity = 1 - percent / 100;
|
||||
});
|
||||
}}
|
||||
/>
|
||||
</label>
|
||||
|
||||
{!compact && (
|
||||
<dl className="cad-properties-editor__readout">
|
||||
<div>
|
||||
<dt>길이</dt>
|
||||
<dd>{polylineLength(points).toFixed(3)}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>크기</dt>
|
||||
<dd>
|
||||
{(box.xmax - box.xmin).toFixed(3)} × {(box.ymax - box.ymin).toFixed(3)}
|
||||
</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>시작점</dt>
|
||||
<dd>
|
||||
{points[0] ? `${points[0].x.toFixed(2)}, ${points[0].y.toFixed(2)}` : '-'}
|
||||
</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>그룹</dt>
|
||||
<dd>{first.groupId ? '있음' : '없음'}</dd>
|
||||
</div>
|
||||
</dl>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,34 @@
|
||||
/** 제목표시줄 + 빠른 실행 도구막대 (AutoCAD 상단 막대) */
|
||||
import type { FC } from 'react';
|
||||
import { getCommandById } from '../commands/registry';
|
||||
import { runCommand } from '../commands/run-command';
|
||||
import { QUICK_ACCESS_COMMANDS } from '../ribbon/ribbon.config';
|
||||
|
||||
export const QuickAccessBar: FC = () => (
|
||||
<header className="cad-titlebar controls">
|
||||
<div className="cad-brand">
|
||||
<strong>Aislo CAD</strong>
|
||||
<span>B07 상세 설계</span>
|
||||
</div>
|
||||
<div className="cad-quick-access">
|
||||
{QUICK_ACCESS_COMMANDS.map((id) => {
|
||||
const command = getCommandById(id);
|
||||
if (!command) return null;
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
key={command.id}
|
||||
title={`${command.label} (${command.id})`}
|
||||
onClick={() => runCommand(command)}
|
||||
>
|
||||
<span aria-hidden>{command.glyph}</span>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
<div className="cad-file-state">
|
||||
<span className="cad-file-state__dot" />
|
||||
현재 도면
|
||||
</div>
|
||||
</header>
|
||||
);
|
||||
@@ -0,0 +1,21 @@
|
||||
/** 빠른 특성 — 선택이 있을 때만 화면 오른쪽 위에 뜨는 간이 특성 상자 (QUICKPROPERTIES) */
|
||||
import type { FC } from 'react';
|
||||
import { PropertiesEditor } from './PropertiesEditor';
|
||||
import { getSelectedEntities } from '../state';
|
||||
import { isQuickPropertiesVisible, setQuickPropertiesVisible } from './ui-state';
|
||||
|
||||
export const QuickProperties: FC = () => {
|
||||
if (!isQuickPropertiesVisible() || getSelectedEntities().length === 0) return null;
|
||||
|
||||
return (
|
||||
<div className="cad-quick-properties controls">
|
||||
<div className="cad-quick-properties__header">
|
||||
<span>빠른 특성</span>
|
||||
<button type="button" onClick={() => setQuickPropertiesVisible(false)} title="닫기">
|
||||
✕
|
||||
</button>
|
||||
</div>
|
||||
<PropertiesEditor compact />
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,191 @@
|
||||
/** 리본 안에 들어가는 위젯 패널 — 특성(색·굵기·선종류), 도면층, 문자 스타일 */
|
||||
import type { FC } from 'react';
|
||||
import type { Entity } from '../entities/Entity';
|
||||
import { EntityName } from '../entities/Entity';
|
||||
import type { TextEntity } from '../entities/TextEntity';
|
||||
import {
|
||||
getActiveLayerId,
|
||||
getActiveLineColor,
|
||||
getActiveLineDash,
|
||||
getActiveLineWidth,
|
||||
getActiveTextStyle,
|
||||
getEntities,
|
||||
getLayers,
|
||||
getSelectedEntities,
|
||||
setActiveLayerId,
|
||||
setActiveLineColor,
|
||||
setActiveLineDash,
|
||||
setActiveLineWidth,
|
||||
setActiveTextStyle,
|
||||
setEntities,
|
||||
} from '../state';
|
||||
import { runCommandInput } from '../commands/run-command';
|
||||
|
||||
export const LINE_TYPES: { value: string; label: string; dash: number[] | undefined }[] = [
|
||||
{ value: 'solid', label: '실선', dash: undefined },
|
||||
{ value: 'dashed', label: '파선', dash: [10, 5] },
|
||||
{ value: 'dashdot', label: '1점쇄선', dash: [12, 4, 2, 4] },
|
||||
{ value: 'dotted', label: '점선', dash: [2, 4] },
|
||||
];
|
||||
|
||||
export const LINE_WIDTHS = [1, 2, 3, 4, 5];
|
||||
|
||||
const FONT_FAMILIES = ['Noto Sans KR', 'Malgun Gothic', 'Pretendard', 'Arial', 'monospace'];
|
||||
|
||||
export const dashToLineType = (dash: number[] | undefined): string =>
|
||||
LINE_TYPES.find((type) => JSON.stringify(type.dash) === JSON.stringify(dash))?.value ?? 'solid';
|
||||
|
||||
/** 선택 객체가 있으면 즉시 적용하고, 없으면 이후 그리기 기본값만 바꾼다. */
|
||||
function applyToSelection(mutate: (entity: Entity) => void): boolean {
|
||||
const selected = getSelectedEntities();
|
||||
if (!selected.length) return false;
|
||||
for (const entity of selected) {
|
||||
mutate(entity);
|
||||
}
|
||||
setEntities([...getEntities()], true);
|
||||
return true;
|
||||
}
|
||||
|
||||
export const PropertiesWidget: FC = () => {
|
||||
const lineColor = getActiveLineColor();
|
||||
const lineWidth = getActiveLineWidth();
|
||||
const lineType = dashToLineType(getActiveLineDash());
|
||||
|
||||
return (
|
||||
<div className="cad-ribbon-props">
|
||||
<label className="cad-prop" title="객체 색상 (COLOR)">
|
||||
<span>색상</span>
|
||||
<input
|
||||
type="color"
|
||||
value={lineColor}
|
||||
onChange={(event) => {
|
||||
setActiveLineColor(event.target.value);
|
||||
applyToSelection((entity) => {
|
||||
entity.lineColor = event.target.value;
|
||||
});
|
||||
}}
|
||||
/>
|
||||
</label>
|
||||
<label className="cad-prop" title="선가중치 (LWEIGHT)">
|
||||
<span>선가중치</span>
|
||||
<select
|
||||
value={lineWidth}
|
||||
onChange={(event) => {
|
||||
const width = Number(event.target.value);
|
||||
setActiveLineWidth(width);
|
||||
applyToSelection((entity) => {
|
||||
entity.lineWidth = width;
|
||||
});
|
||||
}}
|
||||
>
|
||||
{LINE_WIDTHS.map((width) => (
|
||||
<option key={width} value={width}>
|
||||
{width}px
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
<label className="cad-prop" title="선종류 (LINETYPE)">
|
||||
<span>선종류</span>
|
||||
<select
|
||||
value={lineType}
|
||||
onChange={(event) => {
|
||||
const dash = LINE_TYPES.find((type) => type.value === event.target.value)?.dash;
|
||||
setActiveLineDash(dash ? [...dash] : undefined);
|
||||
applyToSelection((entity) => {
|
||||
entity.lineDash = dash ? [...dash] : undefined;
|
||||
});
|
||||
}}
|
||||
>
|
||||
{LINE_TYPES.map((type) => (
|
||||
<option key={type.value} value={type.value}>
|
||||
{type.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export const LayersWidget: FC = () => {
|
||||
const layers = getLayers();
|
||||
const activeLayerId = getActiveLayerId();
|
||||
|
||||
return (
|
||||
<div className="cad-ribbon-props">
|
||||
<label className="cad-prop cad-prop--wide" title="현재 도면층">
|
||||
<span>현재 도면층</span>
|
||||
<select value={activeLayerId} onChange={(event) => setActiveLayerId(event.target.value)}>
|
||||
{layers.map((layer) => (
|
||||
<option key={layer.id} value={layer.id}>
|
||||
{layer.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
<button
|
||||
type="button"
|
||||
className="cad-tool"
|
||||
data-size="small"
|
||||
title="도면층 특성 관리자 (LAYER)"
|
||||
onClick={() => runCommandInput('LAYER')}
|
||||
>
|
||||
<span className="cad-tool__glyph">▤</span>
|
||||
<span className="cad-tool__label">도면층</span>
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export const TextStyleWidget: FC = () => {
|
||||
const textStyle = getActiveTextStyle();
|
||||
|
||||
const handle = (patch: Partial<typeof textStyle>) => {
|
||||
setActiveTextStyle(patch);
|
||||
applyToSelection((entity) => {
|
||||
if (entity.getType() === EntityName.Text) {
|
||||
(entity as TextEntity).setTextOptions(patch);
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="cad-ribbon-props">
|
||||
<label className="cad-prop cad-prop--wide" title="문자 글꼴 (STYLE)">
|
||||
<span>글꼴</span>
|
||||
<select
|
||||
value={textStyle.fontFamily}
|
||||
onChange={(event) => handle({ fontFamily: event.target.value })}
|
||||
>
|
||||
{FONT_FAMILIES.map((family) => (
|
||||
<option key={family} value={family}>
|
||||
{family}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
<label className="cad-prop" title="문자 높이">
|
||||
<span>크기</span>
|
||||
<input
|
||||
type="number"
|
||||
min={4}
|
||||
max={120}
|
||||
value={textStyle.fontSize}
|
||||
onChange={(event) => {
|
||||
const size = Number(event.target.value);
|
||||
if (Number.isFinite(size) && size > 0) handle({ fontSize: size });
|
||||
}}
|
||||
/>
|
||||
</label>
|
||||
<label className="cad-prop" title="문자 색상">
|
||||
<span>색상</span>
|
||||
<input
|
||||
type="color"
|
||||
value={textStyle.textColor}
|
||||
onChange={(event) => handle({ textColor: event.target.value })}
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,67 @@
|
||||
/** 상태막대 — AutoCAD 하단 제도 보조 토글 */
|
||||
import type { FC } from 'react';
|
||||
import {
|
||||
getAngleStep,
|
||||
getGridEnabled,
|
||||
getSnapEnabled,
|
||||
setAngleStep,
|
||||
setGridEnabled,
|
||||
setSnapEnabled,
|
||||
} from '../state';
|
||||
|
||||
interface StatusBarProps {
|
||||
commandLineVisible: boolean;
|
||||
onToggleCommandLine: () => void;
|
||||
}
|
||||
|
||||
export const StatusBar: FC<StatusBarProps> = ({ commandLineVisible, onToggleCommandLine }) => {
|
||||
const snap = getSnapEnabled();
|
||||
const grid = getGridEnabled();
|
||||
const ortho = getAngleStep() === 90;
|
||||
|
||||
return (
|
||||
<footer className="cad-statusbar controls">
|
||||
<button
|
||||
type="button"
|
||||
data-active={snap}
|
||||
title="객체 스냅 (OSNAP)"
|
||||
onClick={() => setSnapEnabled(!snap)}
|
||||
>
|
||||
객체 스냅
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
data-active={ortho}
|
||||
title="직교 모드 — 켜면 90°, 끄면 45° 간격 각도 가이드"
|
||||
onClick={() => setAngleStep(ortho ? 45 : 90)}
|
||||
>
|
||||
직교
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
data-active={!ortho}
|
||||
title="극좌표 추적 — 45° 간격 각도 가이드"
|
||||
onClick={() => setAngleStep(ortho ? 45 : 90)}
|
||||
>
|
||||
극좌표 추적
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
data-active={grid}
|
||||
title="그리드 표시"
|
||||
onClick={() => setGridEnabled(!grid)}
|
||||
>
|
||||
그리드
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
data-active={commandLineVisible}
|
||||
title="명령행 표시/숨기기"
|
||||
onClick={onToggleCommandLine}
|
||||
>
|
||||
명령행
|
||||
</button>
|
||||
<span className="cad-statusbar__hint">휠: 줌 · 휠 드래그: 초점이동 · Esc: 취소</span>
|
||||
</footer>
|
||||
);
|
||||
};
|
||||
@@ -1,483 +1,61 @@
|
||||
import { type FC, type FormEvent, useCallback, useEffect, useState } from 'react';
|
||||
import { toast } from 'react-toastify';
|
||||
import { Actor } from 'xstate';
|
||||
import { HtmlEvent, type Layer } from '../App.types';
|
||||
import { exportEntitiesToJsonFile } from '../helpers/import-export-handlers/export-entities-to-json';
|
||||
import { exportEntitiesToLocalStorage } from '../helpers/import-export-handlers/export-entities-to-local-storage';
|
||||
import type { Entity } from '../entities/Entity';
|
||||
import { EntityName } from '../entities/Entity';
|
||||
import { TextEntity } from '../entities/TextEntity';
|
||||
import {
|
||||
getActiveLayerId,
|
||||
getActiveLineColor,
|
||||
getActiveLineDash,
|
||||
getActiveLineWidth,
|
||||
getActiveTextStyle,
|
||||
getActiveToolActor,
|
||||
getAngleStep,
|
||||
getEntities,
|
||||
getGridEnabled,
|
||||
getInputController,
|
||||
getLastStateInstructions,
|
||||
getLayers,
|
||||
getScreenCanvasDrawController,
|
||||
getSelectedEntities,
|
||||
getSnapEnabled,
|
||||
redo,
|
||||
setActiveLayerId,
|
||||
setActiveLineColor,
|
||||
setActiveLineDash,
|
||||
setActiveLineWidth,
|
||||
setActiveTextStyle,
|
||||
setActiveToolActor,
|
||||
setAngleStep,
|
||||
setEntities,
|
||||
setGridEnabled,
|
||||
setLayers,
|
||||
setSnapEnabled,
|
||||
undo,
|
||||
} from '../state';
|
||||
import { Tool } from '../tools';
|
||||
import { TOOL_STATE_MACHINES } from '../tools/tool.consts';
|
||||
import { LayerManager } from './LayerManager';
|
||||
|
||||
interface RibbonTool {
|
||||
label: string;
|
||||
shortcut?: string;
|
||||
tool?: Tool;
|
||||
glyph: string;
|
||||
disabled?: boolean;
|
||||
}
|
||||
|
||||
const RIBBON_GROUPS: { label: string; tools: RibbonTool[] }[] = [
|
||||
{
|
||||
label: '그리기',
|
||||
tools: [
|
||||
{ label: '선', shortcut: 'L', tool: Tool.LINE, glyph: '╱' },
|
||||
{ label: '폴리선', shortcut: 'PE', tool: Tool.PEDIT, glyph: '⌁' },
|
||||
{ label: '원', shortcut: 'C', tool: Tool.CIRCLE, glyph: '○' },
|
||||
{ label: '사각형', shortcut: 'R', tool: Tool.RECTANGLE, glyph: '□' },
|
||||
{ label: '호', glyph: '◜', disabled: true },
|
||||
],
|
||||
},
|
||||
{
|
||||
label: '수정',
|
||||
tools: [
|
||||
{ label: '선택', shortcut: 'S', tool: Tool.SELECT, glyph: '↖' },
|
||||
{ label: '이동', tool: Tool.MOVE, glyph: '✥' },
|
||||
{ label: '복사', tool: Tool.COPY, glyph: '▣' },
|
||||
{ label: '회전', tool: Tool.ROTATE, glyph: '↻' },
|
||||
{ label: '자르기', tool: Tool.ERASER, glyph: '⌫' },
|
||||
{ label: '간격띄우기', glyph: '⇶', disabled: true },
|
||||
],
|
||||
},
|
||||
{
|
||||
label: '주석',
|
||||
tools: [
|
||||
{ label: '치수', tool: Tool.MEASUREMENT, glyph: '↔' },
|
||||
{ label: '문자', glyph: 'A', disabled: true },
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
const COMMANDS = Object.values(Tool);
|
||||
|
||||
const LINE_TYPES: { value: string; label: string; dash: number[] | undefined }[] = [
|
||||
{ value: 'solid', label: '실선', dash: undefined },
|
||||
{ value: 'dashed', label: '파선', dash: [10, 5] },
|
||||
{ value: 'dashdot', label: '1점쇄선', dash: [12, 4, 2, 4] },
|
||||
{ value: 'dotted', label: '점선', dash: [2, 4] },
|
||||
];
|
||||
|
||||
const LINE_WIDTHS = [1, 2, 3, 4, 5];
|
||||
|
||||
const FONT_FAMILIES = ['Noto Sans KR', 'Malgun Gothic', 'Pretendard', 'Arial', 'monospace'];
|
||||
|
||||
const dashToLineType = (dash: number[] | undefined): string =>
|
||||
LINE_TYPES.find((type) => JSON.stringify(type.dash) === JSON.stringify(dash))?.value ?? 'solid';
|
||||
/**
|
||||
* CAD 화면 골격 조립 — 제목표시줄 · 리본 · 좌측 팔레트 · 탐색막대 · 명령행 · 상태막대.
|
||||
* 각 조각은 자기 파일에 있고, 여기서는 배치와 표시 상태만 다룬다.
|
||||
*/
|
||||
import { type FC, useEffect, useState } from 'react';
|
||||
import { CommandLine } from './CommandLine';
|
||||
import { InspectorPanel } from './InspectorPanel';
|
||||
import { QuickProperties } from './QuickProperties';
|
||||
import { LayersWidget, PropertiesWidget, TextStyleWidget } from './RibbonWidgets';
|
||||
import { QuickAccessBar } from './QuickAccessBar';
|
||||
import { Ribbon } from '../ribbon/Ribbon';
|
||||
import { StatusBar } from './StatusBar';
|
||||
import { useCadRefresh } from './use-cad-refresh';
|
||||
import { getActiveToolActor } from '../state';
|
||||
import type { Tool } from '../tools';
|
||||
import { ViewControls } from './ViewControls';
|
||||
|
||||
export const Toolbar: FC = () => {
|
||||
const [activeTool, setActiveTool] = useState<Tool>(Tool.LINE);
|
||||
const [zoom, setZoom] = useState(1);
|
||||
const [layers, setLayersLocal] = useState<Layer[]>(getLayers());
|
||||
const [activeLayerId, setActiveLayerIdLocal] = useState(getActiveLayerId());
|
||||
const [selectedCount, setSelectedCount] = useState(0);
|
||||
const [selectedType, setSelectedType] = useState('선택 없음');
|
||||
const [instruction, setInstruction] = useState('명령을 입력하거나 도구를 선택하십시오.');
|
||||
const [panelTab, setPanelTab] = useState<'properties' | 'layers'>('layers');
|
||||
useCadRefresh();
|
||||
const [panelCollapsed, setPanelCollapsed] = useState(false);
|
||||
const [snap, setSnap] = useState(getSnapEnabled());
|
||||
const [grid, setGrid] = useState(getGridEnabled());
|
||||
const [ortho, setOrtho] = useState(getAngleStep() === 90);
|
||||
const [command, setCommand] = useState('');
|
||||
const [commandLog, setCommandLog] = useState('준비');
|
||||
const [lineColor, setLineColorLocal] = useState(getActiveLineColor());
|
||||
const [lineWidth, setLineWidthLocal] = useState(getActiveLineWidth());
|
||||
const [lineType, setLineTypeLocal] = useState(dashToLineType(getActiveLineDash()));
|
||||
const [textStyle, setTextStyleLocal] = useState(getActiveTextStyle());
|
||||
const [commandLineVisible, setCommandLineVisible] = useState(true);
|
||||
|
||||
const refresh = useCallback(() => {
|
||||
setActiveTool(getActiveToolActor()?.getSnapshot()?.context.type ?? Tool.LINE);
|
||||
setZoom(getScreenCanvasDrawController().getScreenScale());
|
||||
setLayersLocal([...getLayers()]);
|
||||
setActiveLayerIdLocal(getActiveLayerId());
|
||||
const selected = getSelectedEntities();
|
||||
setSelectedCount(selected.length);
|
||||
setSelectedType(
|
||||
selected.length === 1 ? selected[0].getType() : selected.length ? '여러 객체' : '선택 없음'
|
||||
);
|
||||
setInstruction(getLastStateInstructions() || '명령을 입력하거나 도구를 선택하십시오.');
|
||||
setSnap(getSnapEnabled());
|
||||
setGrid(getGridEnabled());
|
||||
setOrtho(getAngleStep() === 90);
|
||||
setLineColorLocal(getActiveLineColor());
|
||||
setLineWidthLocal(getActiveLineWidth());
|
||||
setLineTypeLocal(dashToLineType(getActiveLineDash()));
|
||||
setTextStyleLocal({ ...getActiveTextStyle() });
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
window.addEventListener(HtmlEvent.UPDATE_STATE, refresh);
|
||||
return () => window.removeEventListener(HtmlEvent.UPDATE_STATE, refresh);
|
||||
}, [refresh]);
|
||||
const activeTool = (getActiveToolActor()?.getSnapshot()?.context?.type ?? null) as Tool | null;
|
||||
|
||||
useEffect(() => {
|
||||
document.documentElement.style.setProperty(
|
||||
'--cad-panel-width',
|
||||
panelCollapsed ? '0px' : '248px'
|
||||
);
|
||||
document.documentElement.style.setProperty(
|
||||
'--cad-command-height',
|
||||
commandLineVisible ? '86px' : '0px'
|
||||
);
|
||||
window.dispatchEvent(new Event('resize'));
|
||||
}, [panelCollapsed]);
|
||||
|
||||
const activateTool = useCallback((tool: Tool) => {
|
||||
const actor = new Actor(TOOL_STATE_MACHINES[tool]);
|
||||
setActiveToolActor(actor);
|
||||
setActiveTool(tool);
|
||||
setCommandLog(`${tool} 명령 실행`);
|
||||
}, []);
|
||||
|
||||
const handleCommand = (event: FormEvent) => {
|
||||
event.preventDefault();
|
||||
const value = command.trim();
|
||||
if (!value) return;
|
||||
getInputController().submitText(value);
|
||||
setCommandLog(`명령: ${value.toUpperCase()}`);
|
||||
setCommand('');
|
||||
};
|
||||
|
||||
const changeZoom = (factor: number) => {
|
||||
const controller = getScreenCanvasDrawController();
|
||||
controller.setScreenScale(Math.max(0.05, controller.getScreenScale() * factor));
|
||||
setZoom(controller.getScreenScale());
|
||||
};
|
||||
|
||||
/** 선택 객체가 있으면 스타일을 즉시 적용하고, 없으면 이후 그리기 기본값만 바꾼다. */
|
||||
const applyToSelection = useCallback((mutate: (entity: Entity) => void): boolean => {
|
||||
const selected = getSelectedEntities();
|
||||
if (!selected.length) return false;
|
||||
for (const entity of selected) {
|
||||
mutate(entity);
|
||||
}
|
||||
setEntities([...getEntities()], true);
|
||||
return true;
|
||||
}, []);
|
||||
|
||||
const handleLineColor = (color: string) => {
|
||||
setActiveLineColor(color);
|
||||
setLineColorLocal(color);
|
||||
if (applyToSelection((entity) => (entity.lineColor = color))) {
|
||||
setCommandLog('선택 객체 색상 변경');
|
||||
}
|
||||
};
|
||||
|
||||
const handleLineWidth = (width: number) => {
|
||||
setActiveLineWidth(width);
|
||||
setLineWidthLocal(width);
|
||||
if (applyToSelection((entity) => (entity.lineWidth = width))) {
|
||||
setCommandLog('선택 객체 선굵기 변경');
|
||||
}
|
||||
};
|
||||
|
||||
const handleLineType = (value: string) => {
|
||||
const dash = LINE_TYPES.find((type) => type.value === value)?.dash;
|
||||
setActiveLineDash(dash ? [...dash] : undefined);
|
||||
setLineTypeLocal(value);
|
||||
if (applyToSelection((entity) => (entity.lineDash = dash ? [...dash] : undefined))) {
|
||||
setCommandLog('선택 객체 선종류 변경');
|
||||
}
|
||||
};
|
||||
|
||||
const handleTextStyle = (patch: Partial<typeof textStyle>) => {
|
||||
setActiveTextStyle(patch);
|
||||
setTextStyleLocal((previous) => ({ ...previous, ...patch }));
|
||||
const applied = applyToSelection((entity) => {
|
||||
if (entity.getType() === EntityName.Text) {
|
||||
(entity as TextEntity).setTextOptions(patch);
|
||||
}
|
||||
});
|
||||
if (applied) {
|
||||
setCommandLog('선택 문자 스타일 변경');
|
||||
}
|
||||
};
|
||||
}, [panelCollapsed, commandLineVisible]);
|
||||
|
||||
return (
|
||||
<>
|
||||
<header className="cad-titlebar controls">
|
||||
<div className="cad-brand">
|
||||
<strong>Aislo CAD</strong>
|
||||
<span>B07 상세 설계</span>
|
||||
</div>
|
||||
<div className="cad-file-state">
|
||||
<span className="cad-file-state__dot" />
|
||||
현재 도면 · 저장됨
|
||||
</div>
|
||||
<div className="cad-title-actions">
|
||||
<button type="button" onClick={() => undo()} title="실행 취소 (Ctrl+Z)">
|
||||
↶
|
||||
</button>
|
||||
<button type="button" onClick={() => redo()} title="다시 실행 (Ctrl+Y)">
|
||||
↷
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={async () => {
|
||||
await exportEntitiesToLocalStorage();
|
||||
toast.success('도면을 저장했습니다.');
|
||||
}}
|
||||
>
|
||||
저장
|
||||
</button>
|
||||
<button type="button" onClick={() => exportEntitiesToJsonFile()}>
|
||||
내보내기
|
||||
</button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<nav className="cad-ribbon controls" aria-label="CAD 도구 리본">
|
||||
{RIBBON_GROUPS.map((group) => (
|
||||
<section className="cad-ribbon-group" key={group.label}>
|
||||
<div className="cad-ribbon-tools">
|
||||
{group.tools.map((item) => (
|
||||
<button
|
||||
type="button"
|
||||
key={item.label}
|
||||
className="cad-tool"
|
||||
data-active={item.tool === activeTool}
|
||||
disabled={item.disabled}
|
||||
title={
|
||||
item.disabled
|
||||
? `${item.label} 도구는 후속 구현 예정입니다.`
|
||||
: `${item.label}${item.shortcut ? ` (${item.shortcut})` : ''}`
|
||||
}
|
||||
onClick={() => item.tool && activateTool(item.tool)}
|
||||
>
|
||||
<span className="cad-tool__glyph">{item.glyph}</span>
|
||||
<span>{item.label}</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<span className="cad-ribbon-group__label">{group.label}</span>
|
||||
</section>
|
||||
))}
|
||||
<section className="cad-ribbon-group">
|
||||
<div className="cad-ribbon-tools cad-ribbon-props">
|
||||
<label className="cad-prop" title="선 색상">
|
||||
<span>색상</span>
|
||||
<input
|
||||
type="color"
|
||||
value={lineColor}
|
||||
onChange={(event) => handleLineColor(event.target.value)}
|
||||
/>
|
||||
</label>
|
||||
<label className="cad-prop" title="선 굵기">
|
||||
<span>굵기</span>
|
||||
<select
|
||||
value={lineWidth}
|
||||
onChange={(event) => handleLineWidth(Number(event.target.value))}
|
||||
>
|
||||
{LINE_WIDTHS.map((width) => (
|
||||
<option key={width} value={width}>
|
||||
{width}px
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
<label className="cad-prop" title="선 종류">
|
||||
<span>선종류</span>
|
||||
<select value={lineType} onChange={(event) => handleLineType(event.target.value)}>
|
||||
{LINE_TYPES.map((type) => (
|
||||
<option key={type.value} value={type.value}>
|
||||
{type.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
</div>
|
||||
<span className="cad-ribbon-group__label">특성</span>
|
||||
</section>
|
||||
<section className="cad-ribbon-group">
|
||||
<div className="cad-ribbon-tools cad-ribbon-props">
|
||||
<label className="cad-prop" title="폰트">
|
||||
<span>폰트</span>
|
||||
<select
|
||||
value={textStyle.fontFamily}
|
||||
onChange={(event) => handleTextStyle({ fontFamily: event.target.value })}
|
||||
>
|
||||
{FONT_FAMILIES.map((family) => (
|
||||
<option key={family} value={family}>
|
||||
{family}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
<label className="cad-prop" title="문자 크기">
|
||||
<span>크기</span>
|
||||
<input
|
||||
type="number"
|
||||
min={4}
|
||||
max={120}
|
||||
value={textStyle.fontSize}
|
||||
onChange={(event) => {
|
||||
const size = Number(event.target.value);
|
||||
if (Number.isFinite(size) && size > 0) {
|
||||
handleTextStyle({ fontSize: size });
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</label>
|
||||
<label className="cad-prop" title="문자 색상">
|
||||
<span>색상</span>
|
||||
<input
|
||||
type="color"
|
||||
value={textStyle.textColor}
|
||||
onChange={(event) => handleTextStyle({ textColor: event.target.value })}
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
<span className="cad-ribbon-group__label">문자</span>
|
||||
</section>
|
||||
</nav>
|
||||
|
||||
<aside className="cad-inspector controls" data-collapsed={panelCollapsed}>
|
||||
<button
|
||||
className="cad-inspector__collapse"
|
||||
type="button"
|
||||
onClick={() => setPanelCollapsed((value) => !value)}
|
||||
title={panelCollapsed ? '패널 펼치기' : '패널 접기'}
|
||||
>
|
||||
{panelCollapsed ? '›' : '‹'}
|
||||
</button>
|
||||
{!panelCollapsed && (
|
||||
<>
|
||||
<div className="cad-inspector-tabs">
|
||||
<button
|
||||
type="button"
|
||||
data-active={panelTab === 'properties'}
|
||||
onClick={() => setPanelTab('properties')}
|
||||
>
|
||||
특성
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
data-active={panelTab === 'layers'}
|
||||
onClick={() => setPanelTab('layers')}
|
||||
>
|
||||
도면층
|
||||
</button>
|
||||
</div>
|
||||
{panelTab === 'properties' ? (
|
||||
<div className="cad-properties">
|
||||
<h2>{selectedType}</h2>
|
||||
<dl>
|
||||
<div>
|
||||
<dt>선택 객체</dt>
|
||||
<dd>{selectedCount}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>현재 도구</dt>
|
||||
<dd>{activeTool}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>현재 도면층</dt>
|
||||
<dd>{layers.find((layer) => layer.id === activeLayerId)?.name ?? '-'}</dd>
|
||||
</div>
|
||||
</dl>
|
||||
</div>
|
||||
) : (
|
||||
<LayerManager
|
||||
className="cad-layer-manager"
|
||||
layers={layers}
|
||||
activeLayerId={activeLayerId}
|
||||
setLayers={(next) => {
|
||||
setLayersLocal(next);
|
||||
setLayers(next);
|
||||
}}
|
||||
setActiveLayerId={(id) => {
|
||||
setActiveLayerIdLocal(id);
|
||||
setActiveLayerId(id);
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</aside>
|
||||
|
||||
<div className="cad-view-controls controls">
|
||||
<button
|
||||
type="button"
|
||||
className="cad-view-controls__fit"
|
||||
onClick={() => {
|
||||
getScreenCanvasDrawController().zoomToFitScreen();
|
||||
refresh();
|
||||
}}
|
||||
title="전체 보기 (도면을 화면 중심에 맞춤)"
|
||||
>
|
||||
⛶
|
||||
</button>
|
||||
<button type="button" onClick={() => changeZoom(1.2)} title="확대">
|
||||
+
|
||||
</button>
|
||||
<span>{Math.round(zoom * 100)}%</span>
|
||||
<button type="button" onClick={() => changeZoom(0.8)} title="축소">
|
||||
-
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<section className="cad-command-area controls">
|
||||
<div className="cad-command-prompt">
|
||||
<span>{commandLog}</span>
|
||||
<strong>{instruction}</strong>
|
||||
</div>
|
||||
<form onSubmit={handleCommand}>
|
||||
<label htmlFor="cad-command">명령:</label>
|
||||
<input
|
||||
id="cad-command"
|
||||
list="cad-command-list"
|
||||
value={command}
|
||||
onChange={(event) => setCommand(event.target.value)}
|
||||
placeholder="명령 입력 (예: LINE, MOVE, CIRCLE)"
|
||||
autoComplete="off"
|
||||
/>
|
||||
<datalist id="cad-command-list">
|
||||
{COMMANDS.map((item) => (
|
||||
<option value={item} key={item} />
|
||||
))}
|
||||
</datalist>
|
||||
</form>
|
||||
</section>
|
||||
|
||||
<footer className="cad-statusbar controls">
|
||||
<button type="button" data-active={snap} onClick={() => setSnapEnabled(!snap)}>
|
||||
OSNAP
|
||||
</button>
|
||||
<button type="button" data-active={ortho} onClick={() => setAngleStep(ortho ? 45 : 90)}>
|
||||
직교
|
||||
</button>
|
||||
<button type="button" data-active={grid} onClick={() => setGridEnabled(!grid)}>
|
||||
그리드
|
||||
</button>
|
||||
<span className="cad-statusbar__hint">휠: 줌 · 휠 드래그: 팬 · Esc: 취소</span>
|
||||
</footer>
|
||||
<QuickAccessBar />
|
||||
<Ribbon
|
||||
activeTool={activeTool}
|
||||
widgets={{
|
||||
properties: <PropertiesWidget />,
|
||||
layers: <LayersWidget />,
|
||||
textStyle: <TextStyleWidget />,
|
||||
}}
|
||||
/>
|
||||
<InspectorPanel
|
||||
collapsed={panelCollapsed}
|
||||
onToggleCollapsed={() => setPanelCollapsed((value) => !value)}
|
||||
/>
|
||||
<ViewControls />
|
||||
<QuickProperties />
|
||||
{commandLineVisible && <CommandLine />}
|
||||
<StatusBar
|
||||
commandLineVisible={commandLineVisible}
|
||||
onToggleCommandLine={() => setCommandLineVisible((value) => !value)}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -0,0 +1,28 @@
|
||||
/** 화면 우하단 탐색 막대 (줌 표시·전체 보기) */
|
||||
import type { FC } from 'react';
|
||||
import { runCommandInput } from '../commands/run-command';
|
||||
import { getScreenCanvasDrawController } from '../state';
|
||||
|
||||
export const ViewControls: FC = () => {
|
||||
const zoom = getScreenCanvasDrawController()?.getScreenScale() ?? 1;
|
||||
|
||||
return (
|
||||
<div className="cad-view-controls controls">
|
||||
<button
|
||||
type="button"
|
||||
className="cad-view-controls__fit"
|
||||
onClick={() => runCommandInput('ZOOM')}
|
||||
title="범위 줌 (Z) — 도면 전체를 화면에 맞춘다"
|
||||
>
|
||||
⛶
|
||||
</button>
|
||||
<button type="button" onClick={() => runCommandInput('ZOOMIN')} title="확대">
|
||||
+
|
||||
</button>
|
||||
<span>{Math.round(zoom * 100)}%</span>
|
||||
<button type="button" onClick={() => runCommandInput('ZOOMOUT')} title="축소">
|
||||
-
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,40 @@
|
||||
/**
|
||||
* 화면 표시 상태 — 명령(레지스트리)에서도 팔레트를 열 수 있어야 해서
|
||||
* 리액트 컴포넌트 바깥에 둔다. 값이 바뀌면 UPDATE_STATE로 다시 그린다.
|
||||
*/
|
||||
import { HtmlEvent } from '../App.types';
|
||||
|
||||
export type InspectorTab = 'properties' | 'layers';
|
||||
|
||||
let inspectorTab: InspectorTab = 'layers';
|
||||
let inspectorCollapsed = false;
|
||||
let quickPropertiesVisible = false;
|
||||
let activeRibbonTab = 'home';
|
||||
|
||||
const notify = () => window.dispatchEvent(new CustomEvent(HtmlEvent.UPDATE_STATE));
|
||||
|
||||
export const getInspectorTab = () => inspectorTab;
|
||||
export const isInspectorCollapsed = () => inspectorCollapsed;
|
||||
export const isQuickPropertiesVisible = () => quickPropertiesVisible;
|
||||
export const getActiveRibbonTab = () => activeRibbonTab;
|
||||
|
||||
export function setActiveRibbonTab(tabId: string): void {
|
||||
activeRibbonTab = tabId;
|
||||
notify();
|
||||
}
|
||||
|
||||
export function openInspector(tab: InspectorTab): void {
|
||||
inspectorTab = tab;
|
||||
inspectorCollapsed = false;
|
||||
notify();
|
||||
}
|
||||
|
||||
export function setInspectorCollapsed(collapsed: boolean): void {
|
||||
inspectorCollapsed = collapsed;
|
||||
notify();
|
||||
}
|
||||
|
||||
export function setQuickPropertiesVisible(visible: boolean): void {
|
||||
quickPropertiesVisible = visible;
|
||||
notify();
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { HtmlEvent } from '../App.types';
|
||||
|
||||
/**
|
||||
* CAD 상태(state.ts)는 리액트 밖에 있다. UPDATE_STATE 이벤트가 올 때마다
|
||||
* 카운터를 올려 컴포넌트가 getter를 다시 읽게 한다.
|
||||
*/
|
||||
export function useCadRefresh(): number {
|
||||
const [tick, setTick] = useState(0);
|
||||
|
||||
useEffect(() => {
|
||||
const handler = () => setTick((value) => value + 1);
|
||||
window.addEventListener(HtmlEvent.UPDATE_STATE, handler);
|
||||
return () => window.removeEventListener(HtmlEvent.UPDATE_STATE, handler);
|
||||
}, []);
|
||||
|
||||
return tick;
|
||||
}
|
||||
@@ -19,6 +19,8 @@ export interface DrawController {
|
||||
dash?: number[],
|
||||
): void;
|
||||
setFillStyles(fillColor: string): void;
|
||||
/** 0~1. 도면층·객체 투명도를 그릴 때 반영한다 */
|
||||
setOpacity(opacity: number): void;
|
||||
clear(): void;
|
||||
drawLine(startPoint: Point, endPoint: Point): void;
|
||||
drawArc(
|
||||
|
||||
@@ -304,6 +304,11 @@ export class ScreenCanvasDrawController implements DrawController {
|
||||
this.batchLastY = Number.NaN;
|
||||
}
|
||||
|
||||
/** 도면층·객체 투명도 (1이면 불투명) */
|
||||
public setOpacity(opacity: number) {
|
||||
this.context.globalAlpha = Math.min(1, Math.max(0, opacity));
|
||||
}
|
||||
|
||||
public setFillStyles(fillColor: string) {
|
||||
this.context.fillStyle = paintColor(fillColor);
|
||||
}
|
||||
|
||||
@@ -117,6 +117,9 @@ export class SvgDrawController implements DrawController {
|
||||
this.lineDash = lineDash;
|
||||
}
|
||||
|
||||
/** SVG 내보내기는 투명도를 쓰지 않는다 (출력은 항상 불투명) */
|
||||
public setOpacity(_opacity: number) {}
|
||||
|
||||
public setFillStyles(fillColor: string) {
|
||||
if (
|
||||
fillColor.toLowerCase() === '#fff' ||
|
||||
|
||||
@@ -22,6 +22,10 @@ export class ArcEntity implements Entity, StartAndEndpointEntity {
|
||||
public lineWidth = 1;
|
||||
public lineDash: number[] | undefined = undefined;
|
||||
public layerId: string;
|
||||
/** 0~1 객체 투명도 (Entity 인터페이스의 선택 항목) */
|
||||
public opacity?: number;
|
||||
/** GROUP으로 묶인 객체가 공유하는 식별자 */
|
||||
public groupId?: string;
|
||||
|
||||
private arc: Arc;
|
||||
|
||||
|
||||
@@ -15,6 +15,10 @@ export class ArrowHeadEntity implements Entity {
|
||||
public lineWidth = 1;
|
||||
public lineDash: number[] = [];
|
||||
public layerId: string;
|
||||
/** 0~1 객체 투명도 (Entity 인터페이스의 선택 항목) */
|
||||
public opacity?: number;
|
||||
/** GROUP으로 묶인 객체가 공유하는 식별자 */
|
||||
public groupId?: string;
|
||||
|
||||
// 3 corners of the arrow head
|
||||
constructor(
|
||||
|
||||
@@ -14,6 +14,10 @@ export class CircleEntity implements Entity {
|
||||
public lineWidth = 1;
|
||||
public lineDash: number[] | undefined = undefined;
|
||||
public layerId: string;
|
||||
/** 0~1 객체 투명도 (Entity 인터페이스의 선택 항목) */
|
||||
public opacity?: number;
|
||||
/** GROUP으로 묶인 객체가 공유하는 식별자 */
|
||||
public groupId?: string;
|
||||
|
||||
private circle: Circle;
|
||||
|
||||
|
||||
@@ -8,6 +8,7 @@ import type { ImageJsonData } from './ImageEntity';
|
||||
import type { LineEntity, LineJsonData } from './LineEntity';
|
||||
import type { PointJsonData } from './PointEntity';
|
||||
import type { RectangleJsonData } from './RectangleEntity';
|
||||
import type { HatchJsonData } from './HatchEntity';
|
||||
import type { TextJsonData } from './TextEntity.ts';
|
||||
|
||||
export interface Entity {
|
||||
@@ -18,6 +19,10 @@ export interface Entity {
|
||||
lineWidth: number;
|
||||
lineDash: number[] | undefined;
|
||||
layerId: string;
|
||||
/** 0~1 객체 투명도. 없으면 도면층 값을 따른다 */
|
||||
opacity?: number;
|
||||
/** GROUP으로 묶인 객체가 공유하는 식별자 */
|
||||
groupId?: string;
|
||||
draw(drawController: DrawController, highlighted?: boolean, selected?: boolean): void;
|
||||
|
||||
/**
|
||||
@@ -57,6 +62,7 @@ export enum EntityName {
|
||||
ArrowHead = 'ArrowHead',
|
||||
Text = 'Text',
|
||||
PolyLine = 'PolyLine',
|
||||
Hatch = 'Hatch',
|
||||
}
|
||||
|
||||
export type ShapeJsonData =
|
||||
@@ -67,6 +73,7 @@ export type ShapeJsonData =
|
||||
| PointJsonData
|
||||
| ImageJsonData
|
||||
| ArrowHeadJsonData
|
||||
| HatchJsonData
|
||||
| TextJsonData;
|
||||
|
||||
export interface JsonEntity<TShapeJsonData = ShapeJsonData> {
|
||||
|
||||
@@ -0,0 +1,261 @@
|
||||
/**
|
||||
* 채움 객체 — 해치(HATCH)·그라데이션(GRADIENT)·와이프아웃(WIPEOUT)이 공유한다.
|
||||
* 경계는 닫힌 점렬 하나로 갖는다 (섬 경계는 아직 다루지 않는다).
|
||||
*/
|
||||
import { Box, Point, Polygon, Segment } from '@flatten-js/core';
|
||||
import type { Shape, SnapPoint } from '../App.types';
|
||||
import { SnapPointType } from '../App.types';
|
||||
import type { DrawController } from '../drawControllers/DrawController';
|
||||
import { hatchSpans } from '../helpers/geometry/hatch-lines';
|
||||
import { getExportColor } from '../helpers/get-export-color';
|
||||
import { mirrorPointOverAxis } from '../helpers/mirror-point-over-axis';
|
||||
import { scalePoint } from '../helpers/scale-point';
|
||||
import { getActiveLayerId, isEntityHighlighted, isEntitySelected } from '../state';
|
||||
import { type Entity, EntityName, type JsonEntity } from './Entity';
|
||||
import type { LineEntity } from './LineEntity';
|
||||
|
||||
export type HatchStyle = 'solid' | 'pattern' | 'cross' | 'gradient';
|
||||
|
||||
export interface HatchOptions {
|
||||
style: HatchStyle;
|
||||
/** 채움 색 (solid·gradient 시작색) */
|
||||
color: string;
|
||||
/** gradient 끝색 */
|
||||
color2?: string;
|
||||
/** 패턴 선 간격 (도면 단위) */
|
||||
spacing: number;
|
||||
/** 패턴 선 각도 (라디안) */
|
||||
angle: number;
|
||||
}
|
||||
|
||||
const DEFAULT_OPTIONS: HatchOptions = {
|
||||
style: 'pattern',
|
||||
color: '#ffffff',
|
||||
spacing: 1,
|
||||
angle: Math.PI / 4,
|
||||
};
|
||||
|
||||
const GRADIENT_STEPS = 48;
|
||||
|
||||
export class HatchEntity implements Entity {
|
||||
public id: string = crypto.randomUUID();
|
||||
public lineColor = '#fff';
|
||||
public lineWidth = 1;
|
||||
public lineDash: number[] | undefined = undefined;
|
||||
public layerId: string;
|
||||
/** 0~1 객체 투명도 (Entity 인터페이스의 선택 항목) */
|
||||
public opacity?: number;
|
||||
/** GROUP으로 묶인 객체가 공유하는 식별자 */
|
||||
public groupId?: string;
|
||||
|
||||
private points: Point[];
|
||||
public options: HatchOptions;
|
||||
|
||||
constructor(layerId: string, points: Point[], options?: Partial<HatchOptions>) {
|
||||
this.layerId = layerId;
|
||||
this.points = points.map((point) => point.clone());
|
||||
this.options = { ...DEFAULT_OPTIONS, ...options };
|
||||
}
|
||||
|
||||
public getPoints(): Point[] {
|
||||
return this.points;
|
||||
}
|
||||
|
||||
public draw(
|
||||
drawController: DrawController,
|
||||
parentHighlighted?: boolean,
|
||||
parentSelected?: boolean
|
||||
): void {
|
||||
if (this.points.length < 3) return;
|
||||
const highlighted = parentHighlighted ?? isEntityHighlighted(this);
|
||||
const selected = parentSelected ?? isEntitySelected(this);
|
||||
|
||||
if (this.options.style === 'solid') {
|
||||
drawController.setFillStyles(this.options.color);
|
||||
drawController.fillPolygon(...this.points);
|
||||
} else if (this.options.style === 'gradient') {
|
||||
this.drawGradient(drawController);
|
||||
} else {
|
||||
drawController.setLineStyles(highlighted, selected, this.options.color, this.lineWidth);
|
||||
const angles =
|
||||
this.options.style === 'cross'
|
||||
? [this.options.angle, this.options.angle + Math.PI / 2]
|
||||
: [this.options.angle];
|
||||
for (const angle of angles) {
|
||||
for (const [start, end] of hatchSpans(this.points, angle, this.options.spacing)) {
|
||||
drawController.drawLine(start, end);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 경계선 — 선택·강조 상태를 볼 수 있어야 하므로 항상 그린다
|
||||
drawController.setLineStyles(highlighted, selected, this.lineColor, this.lineWidth, this.lineDash);
|
||||
for (let index = 1; index < this.points.length; index++) {
|
||||
drawController.drawLine(this.points[index - 1], this.points[index]);
|
||||
}
|
||||
}
|
||||
|
||||
/** 그라데이션 — 촘촘한 스캔선의 색을 조금씩 바꿔 표현한다 */
|
||||
private drawGradient(drawController: DrawController): void {
|
||||
const box = this.getBoundingBox();
|
||||
const spacing = Math.max((box.ymax - box.ymin) / GRADIENT_STEPS, 1e-6);
|
||||
const spans = hatchSpans(this.points, 0, spacing);
|
||||
if (!spans.length) return;
|
||||
const minY = Math.min(...spans.map(([start]) => start.y));
|
||||
const maxY = Math.max(...spans.map(([start]) => start.y));
|
||||
const range = maxY - minY || 1;
|
||||
for (const [start, end] of spans) {
|
||||
const ratio = (start.y - minY) / range;
|
||||
drawController.setLineStyles(
|
||||
false,
|
||||
false,
|
||||
mixColors(this.options.color, this.options.color2 ?? this.options.color, ratio),
|
||||
2
|
||||
);
|
||||
drawController.drawLine(start, end);
|
||||
}
|
||||
}
|
||||
|
||||
public move(x: number, y: number) {
|
||||
this.points = this.points.map((point) => point.translate(x, y));
|
||||
}
|
||||
|
||||
public scale(scaleOrigin: Point, scaleFactor: number) {
|
||||
this.points = this.points.map((point) => scalePoint(point, scaleOrigin, scaleFactor));
|
||||
this.options.spacing *= scaleFactor;
|
||||
}
|
||||
|
||||
public rotate(rotateOrigin: Point, angle: number) {
|
||||
this.points = this.points.map((point) => point.rotate(angle, rotateOrigin));
|
||||
}
|
||||
|
||||
public mirror(mirrorAxis: LineEntity) {
|
||||
this.points = this.points.map((point) => mirrorPointOverAxis(point, mirrorAxis));
|
||||
}
|
||||
|
||||
public clone(): HatchEntity {
|
||||
return new HatchEntity(getActiveLayerId(), this.points, { ...this.options });
|
||||
}
|
||||
|
||||
private toPolygon(): Polygon {
|
||||
return new Polygon(this.points.map((point) => [point.x, point.y] as [number, number]));
|
||||
}
|
||||
|
||||
public getBoundingBox(): Box {
|
||||
const xs = this.points.map((point) => point.x);
|
||||
const ys = this.points.map((point) => point.y);
|
||||
return new Box(Math.min(...xs), Math.min(...ys), Math.max(...xs), Math.max(...ys));
|
||||
}
|
||||
|
||||
public intersectsWithBox(box: Box): boolean {
|
||||
return this.getBoundingBox().intersect(box);
|
||||
}
|
||||
|
||||
public isContainedInBox(box: Box): boolean {
|
||||
const own = this.getBoundingBox();
|
||||
return (
|
||||
box.xmin <= own.xmin && box.ymin <= own.ymin && box.xmax >= own.xmax && box.ymax >= own.ymax
|
||||
);
|
||||
}
|
||||
|
||||
public getFirstPoint(): Point | null {
|
||||
return this.points[0] ?? null;
|
||||
}
|
||||
|
||||
public getShape(): Shape | null {
|
||||
return this.points.length >= 3 ? this.toPolygon() : null;
|
||||
}
|
||||
|
||||
public getSnapPoints(): SnapPoint[] {
|
||||
return this.points.map((point) => ({ point, type: SnapPointType.LineEndPoint }));
|
||||
}
|
||||
|
||||
public getIntersections(entity: Entity): Point[] {
|
||||
const otherShape = entity.getShape();
|
||||
if (!otherShape || this.points.length < 3) return [];
|
||||
return this.toPolygon().intersect(otherShape);
|
||||
}
|
||||
|
||||
public distanceTo(shape: Shape): [number, Segment] | null {
|
||||
if (this.points.length < 3) return null;
|
||||
const polygon = this.toPolygon();
|
||||
// 채운 면 안쪽을 찍어도 잡히도록 내부는 거리 0으로 본다 (AutoCAD의 해치 선택)
|
||||
if (shape instanceof Point && polygon.contains(shape)) {
|
||||
return [0, new Segment(shape, shape)];
|
||||
}
|
||||
return polygon.distanceTo(shape) as [number, Segment];
|
||||
}
|
||||
|
||||
public getSvgString(): string | null {
|
||||
const path = this.points
|
||||
.map((point, index) => `${index === 0 ? 'M' : 'L'} ${point.x} ${point.y}`)
|
||||
.join(' ');
|
||||
const fill = this.options.style === 'solid' ? getExportColor(this.options.color) : 'none';
|
||||
return `<path d="${path} Z" fill="${fill}" stroke="${getExportColor(this.lineColor)}" stroke-width="${this.lineWidth}" />`;
|
||||
}
|
||||
|
||||
public getType(): EntityName {
|
||||
return EntityName.Hatch;
|
||||
}
|
||||
|
||||
public containsPointOnShape(point: Point): boolean {
|
||||
if (this.points.length < 3) return false;
|
||||
return this.toPolygon().contains(point);
|
||||
}
|
||||
|
||||
public async toJson(): Promise<JsonEntity<HatchJsonData> | null> {
|
||||
return {
|
||||
id: this.id,
|
||||
type: EntityName.Hatch,
|
||||
lineColor: this.lineColor,
|
||||
lineWidth: this.lineWidth,
|
||||
lineDash: this.lineDash,
|
||||
layerId: this.layerId,
|
||||
shapeData: {
|
||||
points: this.points.map((point) => ({ x: point.x, y: point.y })),
|
||||
options: this.options,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
public static async fromJson(jsonEntity: JsonEntity<HatchJsonData>): Promise<HatchEntity> {
|
||||
if (!jsonEntity.shapeData) {
|
||||
throw new Error('Invalid JSON entity of type Hatch: missing shapeData');
|
||||
}
|
||||
const hatch = new HatchEntity(
|
||||
jsonEntity.layerId || getActiveLayerId(),
|
||||
jsonEntity.shapeData.points.map((point) => new Point(point.x, point.y)),
|
||||
jsonEntity.shapeData.options
|
||||
);
|
||||
hatch.id = jsonEntity.id;
|
||||
hatch.lineColor = jsonEntity.lineColor;
|
||||
hatch.lineWidth = jsonEntity.lineWidth;
|
||||
hatch.lineDash = jsonEntity.lineDash;
|
||||
return hatch;
|
||||
}
|
||||
}
|
||||
|
||||
/** 두 hex 색을 ratio(0~1)로 섞는다 */
|
||||
function mixColors(from: string, to: string, ratio: number): string {
|
||||
const parse = (color: string) => {
|
||||
const hex = color.replace('#', '');
|
||||
const full = hex.length === 3 ? [...hex].map((char) => char + char).join('') : hex;
|
||||
return [
|
||||
Number.parseInt(full.slice(0, 2), 16),
|
||||
Number.parseInt(full.slice(2, 4), 16),
|
||||
Number.parseInt(full.slice(4, 6), 16),
|
||||
];
|
||||
};
|
||||
const [r1, g1, b1] = parse(from);
|
||||
const [r2, g2, b2] = parse(to);
|
||||
const channel = (a: number, b: number) =>
|
||||
Math.round(a + (b - a) * Math.min(1, Math.max(0, ratio)))
|
||||
.toString(16)
|
||||
.padStart(2, '0');
|
||||
return `#${channel(r1, r2)}${channel(g1, g2)}${channel(b1, b2)}`;
|
||||
}
|
||||
|
||||
export interface HatchJsonData {
|
||||
points: { x: number; y: number }[];
|
||||
options: HatchOptions;
|
||||
}
|
||||
@@ -18,6 +18,10 @@ export class ImageEntity implements Entity {
|
||||
public lineWidth = 1;
|
||||
public lineDash: number[] | undefined = undefined;
|
||||
public layerId: string;
|
||||
/** 0~1 객체 투명도 (Entity 인터페이스의 선택 항목) */
|
||||
public opacity?: number;
|
||||
/** GROUP으로 묶인 객체가 공유하는 식별자 */
|
||||
public groupId?: string;
|
||||
|
||||
private imageElement: HTMLImageElement;
|
||||
private polygon: Polygon;
|
||||
|
||||
@@ -22,6 +22,10 @@ export class LineEntity implements Entity, StartAndEndpointEntity {
|
||||
public lineWidth = 1;
|
||||
public lineDash: number[] | undefined = undefined;
|
||||
public layerId: string;
|
||||
/** 0~1 객체 투명도 (Entity 인터페이스의 선택 항목) */
|
||||
public opacity?: number;
|
||||
/** GROUP으로 묶인 객체가 공유하는 식별자 */
|
||||
public groupId?: string;
|
||||
|
||||
private segment: Segment;
|
||||
|
||||
|
||||
@@ -2,16 +2,14 @@ import { Box, Line, Point, Segment, Vector } from '@flatten-js/core';
|
||||
import { minBy, round } from 'es-toolkit';
|
||||
import { max, min } from 'es-toolkit/compat';
|
||||
import {
|
||||
ARROW_HEAD_LENGTH,
|
||||
ARROW_HEAD_WIDTH,
|
||||
EPSILON,
|
||||
MEASUREMENT_DECIMAL_PLACES,
|
||||
MEASUREMENT_EXTENSION_LENGTH,
|
||||
MEASUREMENT_FONT_SIZE,
|
||||
MEASUREMENT_LABEL_OFFSET,
|
||||
MEASUREMENT_ORIGIN_MARGIN,
|
||||
TO_RADIANS,
|
||||
} from '../App.consts';
|
||||
import { getDimArrowSize, getDimDecimals, getDimTextHeight } from '../commands/dim-settings';
|
||||
import type { Shape, SnapPoint } from '../App.types';
|
||||
import type { DrawController } from '../drawControllers/DrawController';
|
||||
import { pointDistance } from '../helpers/distance-between-points';
|
||||
@@ -45,6 +43,10 @@ export class MeasurementEntity implements Entity {
|
||||
public lineWidth = 1;
|
||||
public lineDash: number[] | undefined = undefined;
|
||||
public layerId: string;
|
||||
/** 0~1 객체 투명도 (Entity 인터페이스의 선택 항목) */
|
||||
public opacity?: number;
|
||||
/** GROUP으로 묶인 객체가 공유하는 식별자 */
|
||||
public groupId?: string;
|
||||
|
||||
private startPoint: Point;
|
||||
private endPoint: Point;
|
||||
@@ -57,6 +59,22 @@ export class MeasurementEntity implements Entity {
|
||||
this.offsetPoint = offsetPoint;
|
||||
}
|
||||
|
||||
public getStartPoint(): Point {
|
||||
return this.startPoint;
|
||||
}
|
||||
|
||||
public getEndPoint(): Point {
|
||||
return this.endPoint;
|
||||
}
|
||||
|
||||
public getOffsetPoint(): Point {
|
||||
return this.offsetPoint;
|
||||
}
|
||||
|
||||
public setOffsetPoint(point: Point): void {
|
||||
this.offsetPoint = point;
|
||||
}
|
||||
|
||||
public getDrawPoints() {
|
||||
// Return if measurement is zero length
|
||||
if (isPointEqual(this.startPoint, this.endPoint)) {
|
||||
@@ -136,7 +154,7 @@ export class MeasurementEntity implements Entity {
|
||||
(offsetStartPoint.x + offsetEndPoint.x) / 2,
|
||||
(offsetStartPoint.y + offsetEndPoint.y) / 2
|
||||
);
|
||||
const textHeight = MEASUREMENT_FONT_SIZE / worldFactor;
|
||||
const textHeight = getDimTextHeight() / worldFactor;
|
||||
const totalOffset = MEASUREMENT_LABEL_OFFSET / worldFactor + textHeight / 2;
|
||||
const midpointMeasurementLineOffset = midpointMeasurementLine
|
||||
.clone()
|
||||
@@ -197,7 +215,7 @@ export class MeasurementEntity implements Entity {
|
||||
const vectorFromEndToStartUnit = vectorFromEndToStart.normalize();
|
||||
const baseOfArrow = endPoint
|
||||
.clone()
|
||||
.translate(vectorFromEndToStartUnit.multiply(ARROW_HEAD_LENGTH / worldFactor));
|
||||
.translate(vectorFromEndToStartUnit.multiply(getDimArrowSize() / worldFactor));
|
||||
const perpendicularVector1 = vectorFromEndToStartUnit.rotate(90 * TO_RADIANS);
|
||||
const perpendicularVector2 = vectorFromEndToStartUnit.rotate(-90 * TO_RADIANS);
|
||||
const leftCornerOfArrow = baseOfArrow
|
||||
@@ -284,7 +302,7 @@ export class MeasurementEntity implements Entity {
|
||||
drawController.drawLine(offsetEndPointMargin, offsetEndPointExtend);
|
||||
|
||||
const distance = String(
|
||||
round(pointDistance(this.startPoint, this.endPoint), MEASUREMENT_DECIMAL_PLACES)
|
||||
round(pointDistance(this.startPoint, this.endPoint), getDimDecimals())
|
||||
);
|
||||
const originalTextDirection = normalUnit.rotate90CW();
|
||||
let finalTextDirection = originalTextDirection;
|
||||
@@ -297,7 +315,7 @@ export class MeasurementEntity implements Entity {
|
||||
drawController.drawText(distance, midpointMeasurementLineOffset, {
|
||||
textAlign: 'center',
|
||||
textDirection: finalTextDirection,
|
||||
fontSize: MEASUREMENT_FONT_SIZE / (drawController.getScreenScale() || 1),
|
||||
fontSize: getDimTextHeight() / (drawController.getScreenScale() || 1),
|
||||
textColor: this.lineColor,
|
||||
});
|
||||
}
|
||||
@@ -401,12 +419,12 @@ export class MeasurementEntity implements Entity {
|
||||
|
||||
// Calculate text properties
|
||||
const distance = String(
|
||||
round(pointDistance(this.startPoint, this.endPoint), MEASUREMENT_DECIMAL_PLACES)
|
||||
round(pointDistance(this.startPoint, this.endPoint), getDimDecimals())
|
||||
);
|
||||
const worldFactor = annotationWorldFactor();
|
||||
const textHeight = MEASUREMENT_FONT_SIZE / worldFactor;
|
||||
const textHeight = getDimTextHeight() / worldFactor;
|
||||
// Estimate width: textString.length * fontSize * aspectRatioFactor
|
||||
const textWidth = (distance.length * MEASUREMENT_FONT_SIZE * 0.6) / worldFactor;
|
||||
const textWidth = (distance.length * getDimTextHeight() * 0.6) / worldFactor;
|
||||
|
||||
const { midpointMeasurementLineOffset, normalUnit } = drawPoints;
|
||||
|
||||
|
||||
@@ -15,6 +15,10 @@ export class PointEntity implements Entity {
|
||||
public lineWidth = 1;
|
||||
public lineDash: number[] | undefined = undefined;
|
||||
public layerId: string;
|
||||
/** 0~1 객체 투명도 (Entity 인터페이스의 선택 항목) */
|
||||
public opacity?: number;
|
||||
/** GROUP으로 묶인 객체가 공유하는 식별자 */
|
||||
public groupId?: string;
|
||||
|
||||
public point: Point;
|
||||
|
||||
|
||||
@@ -16,6 +16,10 @@ export class PolyLineEntity implements Entity {
|
||||
public lineWidth = 1;
|
||||
public lineDash: number[] | undefined = undefined;
|
||||
public layerId: string;
|
||||
/** 0~1 객체 투명도 (Entity 인터페이스의 선택 항목) */
|
||||
public opacity?: number;
|
||||
/** GROUP으로 묶인 객체가 공유하는 식별자 */
|
||||
public groupId?: string;
|
||||
|
||||
private readonly entities: Entity[];
|
||||
|
||||
@@ -26,6 +30,11 @@ export class PolyLineEntity implements Entity {
|
||||
);
|
||||
}
|
||||
|
||||
/** 폴리선을 이루는 선·호 목록 (EXPLODE·샘플링에서 읽는다) */
|
||||
public getEntities(): Entity[] {
|
||||
return this.entities;
|
||||
}
|
||||
|
||||
public numberOfSegments(): number {
|
||||
return this.entities.length;
|
||||
}
|
||||
|
||||
@@ -17,6 +17,10 @@ export class RectangleEntity implements Entity {
|
||||
public lineWidth = 1;
|
||||
public lineDash: number[] | undefined = undefined;
|
||||
public layerId: string;
|
||||
/** 0~1 객체 투명도 (Entity 인터페이스의 선택 항목) */
|
||||
public opacity?: number;
|
||||
/** GROUP으로 묶인 객체가 공유하는 식별자 */
|
||||
public groupId?: string;
|
||||
|
||||
private polygon: Polygon;
|
||||
|
||||
|
||||
@@ -22,6 +22,10 @@ export class TextEntity implements Entity {
|
||||
public lineWidth = 1;
|
||||
public lineDash: number[] = [];
|
||||
public layerId: string;
|
||||
/** 0~1 객체 투명도 (Entity 인터페이스의 선택 항목) */
|
||||
public opacity?: number;
|
||||
/** GROUP으로 묶인 객체가 공유하는 식별자 */
|
||||
public groupId?: string;
|
||||
private readonly options: TextOptions;
|
||||
|
||||
constructor(
|
||||
|
||||
@@ -0,0 +1,53 @@
|
||||
/**
|
||||
* 내부 클립보드 — 잘라내기·복사·붙여넣기(기준점 포함).
|
||||
* 시스템 클립보드는 도면 객체를 담을 수 없어 앱 안에서만 오간다.
|
||||
*/
|
||||
import { Point } from '@flatten-js/core';
|
||||
import type { Entity } from '../entities/Entity';
|
||||
import { getBoundingBoxOfMultipleEntities } from './get-bounding-box-of-multiple-entities';
|
||||
|
||||
interface ClipboardContent {
|
||||
entities: Entity[];
|
||||
/** 붙여넣기 기준점 — 지정하지 않으면 선택 영역의 좌하단 */
|
||||
basePoint: Point;
|
||||
}
|
||||
|
||||
let clipboard: ClipboardContent | null = null;
|
||||
|
||||
export const hasClipboardContent = (): boolean => !!clipboard?.entities.length;
|
||||
|
||||
export function copyToClipboard(entities: Entity[], basePoint?: Point): number {
|
||||
if (!entities.length) return 0;
|
||||
const box = getBoundingBoxOfMultipleEntities(entities);
|
||||
clipboard = {
|
||||
entities: entities.map((entity) => {
|
||||
const copy = entity.clone();
|
||||
copy.lineColor = entity.lineColor;
|
||||
copy.lineWidth = entity.lineWidth;
|
||||
copy.lineDash = entity.lineDash;
|
||||
copy.layerId = entity.layerId;
|
||||
return copy;
|
||||
}),
|
||||
basePoint: basePoint ?? new Point(box.minX, box.minY),
|
||||
};
|
||||
return clipboard.entities.length;
|
||||
}
|
||||
|
||||
/**
|
||||
* 클립보드 내용을 새 객체로 꺼낸다.
|
||||
* target을 주면 기준점이 그 위치에 오도록 옮기고, 없으면 원래 좌표 그대로 둔다.
|
||||
*/
|
||||
export function pasteFromClipboard(target?: Point): Entity[] {
|
||||
if (!clipboard) return [];
|
||||
return clipboard.entities.map((entity) => {
|
||||
const copy = entity.clone();
|
||||
copy.lineColor = entity.lineColor;
|
||||
copy.lineWidth = entity.lineWidth;
|
||||
copy.lineDash = entity.lineDash;
|
||||
copy.layerId = entity.layerId;
|
||||
if (target) {
|
||||
copy.move(target.x - (clipboard as ClipboardContent).basePoint.x, target.y - (clipboard as ClipboardContent).basePoint.y);
|
||||
}
|
||||
return copy;
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
/**
|
||||
* 화면 검증용 상태 조회 창구 — 브라우저 콘솔·자동화 스크립트가
|
||||
* window.__aisloCad로 현재 객체·도면층·선택을 읽는다. 앱 동작에는 관여하지 않는다.
|
||||
*/
|
||||
import { getEntities, getLayers, getSelectedEntityIds } from '../state';
|
||||
import { isEntityHidden } from './visibility';
|
||||
|
||||
export function registerCadDebugHook(): void {
|
||||
(window as unknown as Record<string, unknown>).__aisloCad = {
|
||||
entities: () =>
|
||||
getEntities().map((entity) => {
|
||||
const box = entity.getBoundingBox();
|
||||
return {
|
||||
id: entity.id,
|
||||
type: entity.getType(),
|
||||
layerId: entity.layerId,
|
||||
groupId: entity.groupId ?? null,
|
||||
opacity: entity.opacity ?? null,
|
||||
hidden: isEntityHidden(entity),
|
||||
color: entity.lineColor,
|
||||
box: [
|
||||
Math.round(box.xmin),
|
||||
Math.round(box.ymin),
|
||||
Math.round(box.xmax),
|
||||
Math.round(box.ymax),
|
||||
],
|
||||
};
|
||||
}),
|
||||
layers: () => getLayers(),
|
||||
selection: () => getSelectedEntityIds(),
|
||||
};
|
||||
}
|
||||
@@ -5,6 +5,7 @@ import type {DrawController} from '../drawControllers/DrawController';
|
||||
import type {ScreenCanvasDrawController} from '../drawControllers/screenCanvas.drawController';
|
||||
import type {Entity} from '../entities/Entity';
|
||||
import {getLayerById, isEntityHighlighted, isEntitySelected} from '../state';
|
||||
import {isEntityHidden} from './visibility';
|
||||
import {toast} from 'react-toastify';
|
||||
|
||||
export function drawEntities(drawController: DrawController, entities: Entity[]) {
|
||||
@@ -15,9 +16,13 @@ export function drawEntities(drawController: DrawController, entities: Entity[])
|
||||
console.error('Failed to find layer for entity: ', entity);
|
||||
continue;
|
||||
}
|
||||
if (!layer?.isVisible) {
|
||||
continue; // Layer not visible, skip drawing
|
||||
if (!layer?.isVisible || layer.isFrozen) {
|
||||
continue; // 꺼졌거나 동결된 도면층은 그리지 않는다
|
||||
}
|
||||
if (isEntityHidden(entity)) {
|
||||
continue; // ISOLATEOBJECTS·HIDEOBJECTS로 숨긴 객체
|
||||
}
|
||||
drawController.setOpacity(entity.opacity ?? layer.opacity ?? 1);
|
||||
drawController.setLineStyles(
|
||||
isEntityHighlighted(entity),
|
||||
isEntitySelected(entity),
|
||||
@@ -26,6 +31,7 @@ export function drawEntities(drawController: DrawController, entities: Entity[])
|
||||
[]
|
||||
);
|
||||
entity.draw(drawController);
|
||||
drawController.setOpacity(1);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,22 @@
|
||||
/**
|
||||
* GROUP으로 묶인 객체를 함께 선택하기 위한 확장.
|
||||
* 선택 도구가 부르는 자리라 도구 모듈을 import하지 않는다 (순환 참조 방지).
|
||||
*/
|
||||
import { getEntities } from '../state';
|
||||
|
||||
export function expandSelectionWithGroups(entityIds: string[]): string[] {
|
||||
const entities = getEntities();
|
||||
const ids = new Set(entityIds);
|
||||
const selectedGroups = new Set(
|
||||
entities
|
||||
.filter((entity) => ids.has(entity.id) && entity.groupId)
|
||||
.map((entity) => entity.groupId as string)
|
||||
);
|
||||
if (!selectedGroups.size) return entityIds;
|
||||
|
||||
const expanded = new Set(entityIds);
|
||||
for (const entity of entities) {
|
||||
if (entity.groupId && selectedGroups.has(entity.groupId)) expanded.add(entity.id);
|
||||
}
|
||||
return [...expanded];
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
/**
|
||||
* 선택한 객체들을 하나의 닫힌 경계(점렬)로 잇는다.
|
||||
* HATCH·BOUNDARY·REGION·WIPEOUT이 공유한다.
|
||||
*/
|
||||
import type { Point } from '@flatten-js/core';
|
||||
import type { Entity } from '../../entities/Entity';
|
||||
import { dedupeConsecutive, sampleEntityPoints } from './sample-entity';
|
||||
|
||||
const DEFAULT_TOLERANCE = 1e-3;
|
||||
|
||||
const near = (a: Point, b: Point, tolerance: number): boolean =>
|
||||
Math.abs(a.x - b.x) <= tolerance && Math.abs(a.y - b.y) <= tolerance;
|
||||
|
||||
/**
|
||||
* 끝점이 맞닿는 순서대로 이어 붙여 경계를 만든다.
|
||||
* 이미 닫힌 객체(원·사각형) 하나만 골랐다면 그 점렬을 그대로 돌려준다.
|
||||
*/
|
||||
export function entitiesToLoop(entities: Entity[], tolerance = DEFAULT_TOLERANCE): Point[] {
|
||||
const chains = entities
|
||||
.map((entity) => sampleEntityPoints(entity))
|
||||
.filter((points) => points.length >= 2);
|
||||
if (!chains.length) return [];
|
||||
if (chains.length === 1) return closeLoop(chains[0], tolerance);
|
||||
|
||||
const remaining = [...chains];
|
||||
let loop = remaining.shift() as Point[];
|
||||
|
||||
while (remaining.length) {
|
||||
const tail = loop[loop.length - 1];
|
||||
const index = remaining.findIndex(
|
||||
(chain) =>
|
||||
near(chain[0], tail, tolerance) || near(chain[chain.length - 1], tail, tolerance)
|
||||
);
|
||||
if (index === -1) break; // 끊긴 경계 — 여기까지만 잇는다
|
||||
const [chain] = remaining.splice(index, 1);
|
||||
const ordered = near(chain[0], tail, tolerance) ? chain : [...chain].reverse();
|
||||
loop = dedupeConsecutive([...loop, ...ordered]);
|
||||
}
|
||||
|
||||
return closeLoop(loop, tolerance);
|
||||
}
|
||||
|
||||
/** 시작점과 끝점이 떨어져 있으면 닫는다 */
|
||||
export function closeLoop(points: Point[], tolerance = DEFAULT_TOLERANCE): Point[] {
|
||||
const cleaned = dedupeConsecutive(points);
|
||||
if (cleaned.length < 3) return cleaned;
|
||||
const first = cleaned[0];
|
||||
const last = cleaned[cleaned.length - 1];
|
||||
return near(first, last, tolerance) ? cleaned : [...cleaned, first.clone()];
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
/** 해치·그라데이션이 쓰는 스캔선 계산 — 상태 없는 순수 함수. */
|
||||
import { Point } from '@flatten-js/core';
|
||||
|
||||
export type Span = [Point, Point];
|
||||
|
||||
const rotate = (point: Point, cos: number, sin: number): Point =>
|
||||
new Point(point.x * cos - point.y * sin, point.x * sin + point.y * cos);
|
||||
|
||||
/**
|
||||
* 닫힌 다각형 내부를 angle 방향 평행선으로 채울 때의 선분 목록.
|
||||
* 오목한 다각형도 스캔선 교차를 짝지어 처리한다.
|
||||
*/
|
||||
export function hatchSpans(polygon: Point[], angleRad: number, spacing: number): Span[] {
|
||||
if (polygon.length < 3 || spacing <= 0) return [];
|
||||
|
||||
const cos = Math.cos(-angleRad);
|
||||
const sin = Math.sin(-angleRad);
|
||||
const rotated = polygon.map((point) => rotate(point, cos, sin));
|
||||
|
||||
const minY = Math.min(...rotated.map((point) => point.y));
|
||||
const maxY = Math.max(...rotated.map((point) => point.y));
|
||||
if (!Number.isFinite(minY) || !Number.isFinite(maxY)) return [];
|
||||
|
||||
// 스캔선 수 상한 — 축척이 클 때 무한 루프처럼 보이는 것을 막는다
|
||||
const lineCount = Math.floor((maxY - minY) / spacing);
|
||||
if (lineCount > 5000) return [];
|
||||
|
||||
const backCos = Math.cos(angleRad);
|
||||
const backSin = Math.sin(angleRad);
|
||||
const spans: Span[] = [];
|
||||
|
||||
for (let y = minY + spacing / 2; y < maxY; y += spacing) {
|
||||
const crossings: number[] = [];
|
||||
for (let index = 0; index < rotated.length; index++) {
|
||||
const start = rotated[index];
|
||||
const end = rotated[(index + 1) % rotated.length];
|
||||
if (start.y === end.y) continue;
|
||||
const low = Math.min(start.y, end.y);
|
||||
const high = Math.max(start.y, end.y);
|
||||
if (y < low || y >= high) continue;
|
||||
const ratio = (y - start.y) / (end.y - start.y);
|
||||
crossings.push(start.x + (end.x - start.x) * ratio);
|
||||
}
|
||||
crossings.sort((a, b) => a - b);
|
||||
for (let index = 0; index + 1 < crossings.length; index += 2) {
|
||||
spans.push([
|
||||
rotate(new Point(crossings[index], y), backCos, backSin),
|
||||
rotate(new Point(crossings[index + 1], y), backCos, backSin),
|
||||
]);
|
||||
}
|
||||
}
|
||||
return spans;
|
||||
}
|
||||
|
||||
/** 점이 닫힌 다각형 안에 있는가 (홀수 교차 판정) */
|
||||
export function isPointInPolygon(polygon: Point[], point: Point): boolean {
|
||||
let inside = false;
|
||||
for (let index = 0, previous = polygon.length - 1; index < polygon.length; previous = index++) {
|
||||
const a = polygon[index];
|
||||
const b = polygon[previous];
|
||||
const crosses = a.y > point.y !== b.y > point.y;
|
||||
if (crosses && point.x < ((b.x - a.x) * (point.y - a.y)) / (b.y - a.y) + a.x) {
|
||||
inside = !inside;
|
||||
}
|
||||
}
|
||||
return inside;
|
||||
}
|
||||
@@ -0,0 +1,125 @@
|
||||
/**
|
||||
* 엔티티를 점렬(폴리라인)로 펴는 순수 함수들.
|
||||
* 등분(DIVIDE)·길이분할(MEASURE)·경계(BOUNDARY)·해치가 모두 이 표현을 공유한다.
|
||||
*/
|
||||
import { Arc, Circle, Point, Polygon, Segment } from '@flatten-js/core';
|
||||
import type { Entity } from '../../entities/Entity';
|
||||
import { EntityName } from '../../entities/Entity';
|
||||
import type { PolyLineEntity } from '../../entities/PolyLineEntity';
|
||||
import { polygonToSegments } from '../polygon-to-segments';
|
||||
|
||||
const CURVE_SEGMENTS = 64;
|
||||
|
||||
/** 두 점이 사실상 같은 위치인지 (좌표 오차 허용) */
|
||||
const samePoint = (a: Point, b: Point, tolerance = 1e-6): boolean =>
|
||||
Math.abs(a.x - b.x) <= tolerance && Math.abs(a.y - b.y) <= tolerance;
|
||||
|
||||
function sampleArc(arc: Arc, segments: number): Point[] {
|
||||
const points: Point[] = [];
|
||||
const sweep = arc.sweep * (arc.counterClockwise ? 1 : -1);
|
||||
// flatten-js 타입 선언이 반지름을 Number 객체로 잡아 두어 숫자로 되돌린다
|
||||
const radius = Number(arc.r);
|
||||
for (let index = 0; index <= segments; index++) {
|
||||
const angle = arc.startAngle + (sweep * index) / segments;
|
||||
points.push(
|
||||
new Point(arc.center.x + radius * Math.cos(angle), arc.center.y + radius * Math.sin(angle))
|
||||
);
|
||||
}
|
||||
return points;
|
||||
}
|
||||
|
||||
function sampleCircle(circle: Circle, segments: number): Point[] {
|
||||
const points: Point[] = [];
|
||||
for (let index = 0; index <= segments; index++) {
|
||||
const angle = (2 * Math.PI * index) / segments;
|
||||
points.push(
|
||||
new Point(circle.center.x + circle.r * Math.cos(angle), circle.center.y + circle.r * Math.sin(angle))
|
||||
);
|
||||
}
|
||||
return points;
|
||||
}
|
||||
|
||||
/** 이어지는 중복점을 제거한다 (폴리선 이음매에서 생긴다) */
|
||||
export function dedupeConsecutive(points: Point[]): Point[] {
|
||||
return points.filter((point, index) => index === 0 || !samePoint(point, points[index - 1]));
|
||||
}
|
||||
|
||||
/** 엔티티 하나를 점렬로 편다. 곡선은 curveSegments 등분해 근사한다. */
|
||||
export function sampleEntityPoints(entity: Entity, curveSegments = CURVE_SEGMENTS): Point[] {
|
||||
if (entity.getType() === EntityName.PolyLine) {
|
||||
const children = (entity as PolyLineEntity).getEntities();
|
||||
return dedupeConsecutive(
|
||||
children.flatMap((child) => sampleEntityPoints(child, curveSegments))
|
||||
);
|
||||
}
|
||||
|
||||
const shape = entity.getShape();
|
||||
if (shape instanceof Segment) return [shape.start, shape.end];
|
||||
if (shape instanceof Arc) return sampleArc(shape, curveSegments);
|
||||
if (shape instanceof Circle) return sampleCircle(shape, curveSegments);
|
||||
if (shape instanceof Polygon) {
|
||||
const segments = polygonToSegments(shape);
|
||||
return dedupeConsecutive([
|
||||
...segments.map((segment) => segment.start),
|
||||
...(segments.length ? [segments[segments.length - 1].end] : []),
|
||||
]);
|
||||
}
|
||||
if (shape instanceof Point) return [shape];
|
||||
return [];
|
||||
}
|
||||
|
||||
/** 점렬의 누적 길이 */
|
||||
export function polylineLengths(points: Point[]): number[] {
|
||||
const lengths = [0];
|
||||
for (let index = 1; index < points.length; index++) {
|
||||
lengths.push(lengths[index - 1] + points[index - 1].distanceTo(points[index])[0]);
|
||||
}
|
||||
return lengths;
|
||||
}
|
||||
|
||||
export const polylineLength = (points: Point[]): number => {
|
||||
const lengths = polylineLengths(points);
|
||||
return lengths[lengths.length - 1] ?? 0;
|
||||
};
|
||||
|
||||
/** 시작점에서 distance만큼 진행한 위치 (길이를 넘으면 null) */
|
||||
export function pointAtDistance(points: Point[], distance: number): Point | null {
|
||||
if (points.length < 2) return null;
|
||||
const lengths = polylineLengths(points);
|
||||
const total = lengths[lengths.length - 1];
|
||||
if (distance < 0 || distance > total) return null;
|
||||
for (let index = 1; index < points.length; index++) {
|
||||
if (lengths[index] >= distance) {
|
||||
const segmentLength = lengths[index] - lengths[index - 1];
|
||||
const ratio = segmentLength === 0 ? 0 : (distance - lengths[index - 1]) / segmentLength;
|
||||
const start = points[index - 1];
|
||||
const end = points[index];
|
||||
return new Point(start.x + (end.x - start.x) * ratio, start.y + (end.y - start.y) * ratio);
|
||||
}
|
||||
}
|
||||
return points[points.length - 1];
|
||||
}
|
||||
|
||||
/** DIVIDE — 객체를 count 등분하는 내부 점 (count-1개) */
|
||||
export function dividePoints(points: Point[], count: number): Point[] {
|
||||
if (count < 2) return [];
|
||||
const total = polylineLength(points);
|
||||
const result: Point[] = [];
|
||||
for (let index = 1; index < count; index++) {
|
||||
const point = pointAtDistance(points, (total * index) / count);
|
||||
if (point) result.push(point);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
/** MEASURE — 시작점에서 spacing 간격마다 찍는 점 */
|
||||
export function measurePoints(points: Point[], spacing: number): Point[] {
|
||||
if (spacing <= 0) return [];
|
||||
const total = polylineLength(points);
|
||||
const result: Point[] = [];
|
||||
for (let distance = spacing; distance <= total + 1e-9; distance += spacing) {
|
||||
const point = pointAtDistance(points, Math.min(distance, total));
|
||||
if (point) result.push(point);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
@@ -0,0 +1,207 @@
|
||||
/**
|
||||
* 점 몇 개로 정의되는 도형의 좌표 계산 — 순수 함수.
|
||||
* 정다각형·타원·스플라인·구름형처럼 폴리선으로 근사해 그리는 도형이 여기서 나온다.
|
||||
*/
|
||||
import { Point } from '@flatten-js/core';
|
||||
|
||||
export interface ArcDefinition {
|
||||
center: Point;
|
||||
radius: number;
|
||||
startAngle: number;
|
||||
endAngle: number;
|
||||
counterClockwise: boolean;
|
||||
}
|
||||
|
||||
/** 3점(시작·통과·끝)을 지나는 호. 세 점이 일직선이면 null */
|
||||
export function arcThroughThreePoints(
|
||||
start: Point,
|
||||
through: Point,
|
||||
end: Point
|
||||
): ArcDefinition | null {
|
||||
const ax = start.x;
|
||||
const ay = start.y;
|
||||
const bx = through.x;
|
||||
const by = through.y;
|
||||
const cx = end.x;
|
||||
const cy = end.y;
|
||||
|
||||
const d = 2 * (ax * (by - cy) + bx * (cy - ay) + cx * (ay - by));
|
||||
if (Math.abs(d) < 1e-9) return null; // 일직선
|
||||
|
||||
const ux =
|
||||
((ax * ax + ay * ay) * (by - cy) +
|
||||
(bx * bx + by * by) * (cy - ay) +
|
||||
(cx * cx + cy * cy) * (ay - by)) /
|
||||
d;
|
||||
const uy =
|
||||
((ax * ax + ay * ay) * (cx - bx) +
|
||||
(bx * bx + by * by) * (ax - cx) +
|
||||
(cx * cx + cy * cy) * (bx - ax)) /
|
||||
d;
|
||||
|
||||
const center = new Point(ux, uy);
|
||||
const radius = center.distanceTo(start)[0];
|
||||
const startAngle = Math.atan2(ay - uy, ax - ux);
|
||||
const throughAngle = Math.atan2(by - uy, bx - ux);
|
||||
const endAngle = Math.atan2(cy - uy, cx - ux);
|
||||
|
||||
// 통과점이 시작→끝 사이에 오도록 회전 방향을 고른다
|
||||
const counterClockwise = isAngleBetween(throughAngle, startAngle, endAngle, true);
|
||||
return { center, radius, startAngle, endAngle, counterClockwise };
|
||||
}
|
||||
|
||||
/** 반시계(또는 시계) 방향으로 start에서 end로 갈 때 angle을 지나는가 */
|
||||
export function isAngleBetween(
|
||||
angle: number,
|
||||
start: number,
|
||||
end: number,
|
||||
counterClockwise: boolean
|
||||
): boolean {
|
||||
const normalize = (value: number) => ((value % (2 * Math.PI)) + 2 * Math.PI) % (2 * Math.PI);
|
||||
const sweep = counterClockwise ? normalize(end - start) : normalize(start - end);
|
||||
const offset = counterClockwise ? normalize(angle - start) : normalize(start - angle);
|
||||
return offset <= sweep;
|
||||
}
|
||||
|
||||
/** 정다각형 — 중심과 첫 꼭짓점, 변 수 */
|
||||
export function regularPolygonPoints(center: Point, vertex: Point, sides: number): Point[] {
|
||||
const count = Math.max(3, Math.round(sides));
|
||||
const radius = center.distanceTo(vertex)[0];
|
||||
const startAngle = Math.atan2(vertex.y - center.y, vertex.x - center.x);
|
||||
const points: Point[] = [];
|
||||
for (let index = 0; index < count; index++) {
|
||||
const angle = startAngle + (2 * Math.PI * index) / count;
|
||||
points.push(new Point(center.x + radius * Math.cos(angle), center.y + radius * Math.sin(angle)));
|
||||
}
|
||||
points.push(points[0].clone());
|
||||
return points;
|
||||
}
|
||||
|
||||
/** 타원 — 중심, 장축 끝점, 단축 반지름. 폴리선으로 근사한다 */
|
||||
export function ellipsePoints(
|
||||
center: Point,
|
||||
majorPoint: Point,
|
||||
minorRadius: number,
|
||||
segments = 72
|
||||
): Point[] {
|
||||
const majorRadius = center.distanceTo(majorPoint)[0];
|
||||
const rotation = Math.atan2(majorPoint.y - center.y, majorPoint.x - center.x);
|
||||
const cos = Math.cos(rotation);
|
||||
const sin = Math.sin(rotation);
|
||||
const points: Point[] = [];
|
||||
for (let index = 0; index <= segments; index++) {
|
||||
const angle = (2 * Math.PI * index) / segments;
|
||||
const x = majorRadius * Math.cos(angle);
|
||||
const y = minorRadius * Math.sin(angle);
|
||||
points.push(new Point(center.x + x * cos - y * sin, center.y + x * sin + y * cos));
|
||||
}
|
||||
return points;
|
||||
}
|
||||
|
||||
/** 조정점을 지나는 부드러운 곡선 (Catmull-Rom → 폴리선) */
|
||||
export function splinePoints(controlPoints: Point[], segmentsPerSpan = 12): Point[] {
|
||||
if (controlPoints.length < 3) return [...controlPoints];
|
||||
const extended = [
|
||||
controlPoints[0],
|
||||
...controlPoints,
|
||||
controlPoints[controlPoints.length - 1],
|
||||
];
|
||||
const result: Point[] = [];
|
||||
for (let index = 1; index < extended.length - 2; index++) {
|
||||
const p0 = extended[index - 1];
|
||||
const p1 = extended[index];
|
||||
const p2 = extended[index + 1];
|
||||
const p3 = extended[index + 2];
|
||||
for (let step = 0; step < segmentsPerSpan; step++) {
|
||||
const t = step / segmentsPerSpan;
|
||||
result.push(catmullRom(p0, p1, p2, p3, t));
|
||||
}
|
||||
}
|
||||
result.push(controlPoints[controlPoints.length - 1]);
|
||||
return result;
|
||||
}
|
||||
|
||||
function catmullRom(p0: Point, p1: Point, p2: Point, p3: Point, t: number): Point {
|
||||
const t2 = t * t;
|
||||
const t3 = t2 * t;
|
||||
const axis = (a: number, b: number, c: number, d: number) =>
|
||||
0.5 * (2 * b + (c - a) * t + (2 * a - 5 * b + 4 * c - d) * t2 + (3 * b - 3 * c + d - a) * t3);
|
||||
return new Point(axis(p0.x, p1.x, p2.x, p3.x), axis(p0.y, p1.y, p2.y, p3.y));
|
||||
}
|
||||
|
||||
/** 구름형 리비전 — 경로를 따라 반원 스캘럽을 이어붙인 점렬 */
|
||||
export function revisionCloudPoints(path: Point[], arcRadius: number): Point[] {
|
||||
if (path.length < 2 || arcRadius <= 0) return [...path];
|
||||
const result: Point[] = [];
|
||||
for (let index = 1; index < path.length; index++) {
|
||||
const start = path[index - 1];
|
||||
const end = path[index];
|
||||
const length = start.distanceTo(end)[0];
|
||||
const bulges = Math.max(1, Math.round(length / (arcRadius * 2)));
|
||||
for (let bulge = 0; bulge < bulges; bulge++) {
|
||||
const from = lerp(start, end, bulge / bulges);
|
||||
const to = lerp(start, end, (bulge + 1) / bulges);
|
||||
result.push(...halfArcPoints(from, to));
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
const lerp = (a: Point, b: Point, t: number): Point =>
|
||||
new Point(a.x + (b.x - a.x) * t, a.y + (b.y - a.y) * t);
|
||||
|
||||
/** 두 점을 지름으로 하는 반원 (구름형 한 칸) */
|
||||
function halfArcPoints(from: Point, to: Point, segments = 8): Point[] {
|
||||
const center = lerp(from, to, 0.5);
|
||||
const radius = from.distanceTo(to)[0] / 2;
|
||||
const baseAngle = Math.atan2(to.y - from.y, to.x - from.x);
|
||||
const points: Point[] = [];
|
||||
for (let index = 0; index <= segments; index++) {
|
||||
const angle = baseAngle + Math.PI - (Math.PI * index) / segments;
|
||||
points.push(new Point(center.x + radius * Math.cos(angle), center.y + radius * Math.sin(angle)));
|
||||
}
|
||||
return points;
|
||||
}
|
||||
|
||||
/** 폴리선을 distance만큼 나란히 민 점렬 (양수: 진행 방향 왼쪽) */
|
||||
export function offsetPolylinePoints(points: Point[], distance: number): Point[] {
|
||||
if (points.length < 2 || distance === 0) return [...points];
|
||||
const offsetLines: { start: Point; end: Point }[] = [];
|
||||
for (let index = 1; index < points.length; index++) {
|
||||
const start = points[index - 1];
|
||||
const end = points[index];
|
||||
const dx = end.x - start.x;
|
||||
const dy = end.y - start.y;
|
||||
const length = Math.hypot(dx, dy);
|
||||
if (length < 1e-9) continue;
|
||||
const nx = (-dy / length) * distance;
|
||||
const ny = (dx / length) * distance;
|
||||
offsetLines.push({
|
||||
start: new Point(start.x + nx, start.y + ny),
|
||||
end: new Point(end.x + nx, end.y + ny),
|
||||
});
|
||||
}
|
||||
if (!offsetLines.length) return [...points];
|
||||
|
||||
const result: Point[] = [offsetLines[0].start];
|
||||
for (let index = 1; index < offsetLines.length; index++) {
|
||||
const previous = offsetLines[index - 1];
|
||||
const current = offsetLines[index];
|
||||
const joint = intersectLines(previous.start, previous.end, current.start, current.end);
|
||||
result.push(joint ?? current.start);
|
||||
}
|
||||
result.push(offsetLines[offsetLines.length - 1].end);
|
||||
return result;
|
||||
}
|
||||
|
||||
/** 두 직선(무한 연장)의 교점. 평행이면 null */
|
||||
export function intersectLines(a1: Point, a2: Point, b1: Point, b2: Point): Point | null {
|
||||
const d1x = a2.x - a1.x;
|
||||
const d1y = a2.y - a1.y;
|
||||
const d2x = b2.x - b1.x;
|
||||
const d2y = b2.y - b1.y;
|
||||
const denominator = d1x * d2y - d1y * d2x;
|
||||
if (Math.abs(denominator) < 1e-12) return null;
|
||||
const t = ((b1.x - a1.x) * d2y - (b1.y - a1.y) * d2x) / denominator;
|
||||
return new Point(a1.x + d1x * t, a1.y + d1y * t);
|
||||
}
|
||||
+3
@@ -2,6 +2,7 @@ import {compact} from 'es-toolkit';
|
||||
import {ArcEntity, type ArcJsonData} from '../../entities/ArcEntity';
|
||||
import {CircleEntity, type CircleJsonData} from '../../entities/CircleEntity';
|
||||
import {type Entity, EntityName, type JsonEntity} from '../../entities/Entity';
|
||||
import {HatchEntity, type HatchJsonData} from '../../entities/HatchEntity';
|
||||
import {ImageEntity, type ImageJsonData} from '../../entities/ImageEntity.ts';
|
||||
import {LineEntity, type LineJsonData} from '../../entities/LineEntity';
|
||||
import {MeasurementEntity, type MeasurementJsonData} from '../../entities/MeasurementEntity.ts';
|
||||
@@ -60,6 +61,8 @@ export async function getEntitiesAndLayersFromJsonObject(
|
||||
return ImageEntity.fromJson(entity as JsonEntity<ImageJsonData>);
|
||||
case EntityName.PolyLine:
|
||||
return PolyLineEntity.fromJson(entity as JsonEntity<PolyLineJsonData>);
|
||||
case EntityName.Hatch:
|
||||
return HatchEntity.fromJson(entity as JsonEntity<HatchJsonData>);
|
||||
|
||||
default:
|
||||
throw new Error(`Invalid entity type: ${entity.type}`);
|
||||
|
||||
@@ -0,0 +1,32 @@
|
||||
/**
|
||||
* 도면층 상태 기록 — 이전 상태(LAYERP)와 이름 붙인 상태(LAYERSTATE).
|
||||
* 도면층 목록 자체를 통째로 복사해 둔다 (개수가 적어 비용이 무시할 만하다).
|
||||
*/
|
||||
import type { Layer } from '../App.types';
|
||||
|
||||
const HISTORY_LIMIT = 20;
|
||||
|
||||
const history: Layer[][] = [];
|
||||
const namedStates = new Map<string, Layer[]>();
|
||||
|
||||
const copy = (layers: Layer[]): Layer[] => layers.map((layer) => ({ ...layer }));
|
||||
|
||||
/** 도면층을 바꾸기 직전에 부른다 */
|
||||
export const pushLayerHistory = (layers: Layer[]): void => {
|
||||
history.push(copy(layers));
|
||||
if (history.length > HISTORY_LIMIT) history.shift();
|
||||
};
|
||||
|
||||
/** 직전 상태를 꺼낸다 (없으면 null) */
|
||||
export const popLayerHistory = (): Layer[] | null => history.pop() ?? null;
|
||||
|
||||
export const saveLayerState = (name: string, layers: Layer[]): void => {
|
||||
namedStates.set(name.trim().toUpperCase(), copy(layers));
|
||||
};
|
||||
|
||||
export const restoreLayerState = (name: string): Layer[] | null => {
|
||||
const saved = namedStates.get(name.trim().toUpperCase());
|
||||
return saved ? copy(saved) : null;
|
||||
};
|
||||
|
||||
export const getLayerStateNames = (): string[] => [...namedStates.keys()];
|
||||
@@ -0,0 +1,34 @@
|
||||
/**
|
||||
* 객체 분리·숨기기 상태 (ISOLATEOBJECTS·HIDEOBJECTS·UNISOLATEOBJECTS).
|
||||
* 도면층 가시성과 달리 객체 단위이며 저장하지 않는 일시 상태다.
|
||||
*/
|
||||
import { HtmlEvent } from '../App.types';
|
||||
import type { Entity } from '../entities/Entity';
|
||||
import { bumpSceneVersion } from './scene-version';
|
||||
|
||||
let hiddenEntityIds = new Set<string>();
|
||||
|
||||
export const isEntityHidden = (entity: Entity): boolean => hiddenEntityIds.has(entity.id);
|
||||
export const getHiddenEntityCount = (): number => hiddenEntityIds.size;
|
||||
|
||||
function apply(ids: Set<string>): void {
|
||||
hiddenEntityIds = ids;
|
||||
bumpSceneVersion();
|
||||
window.dispatchEvent(new CustomEvent(HtmlEvent.UPDATE_STATE));
|
||||
}
|
||||
|
||||
/** 지정 객체를 숨긴다 */
|
||||
export const hideEntities = (entities: Entity[]): void => {
|
||||
const next = new Set(hiddenEntityIds);
|
||||
for (const entity of entities) next.add(entity.id);
|
||||
apply(next);
|
||||
};
|
||||
|
||||
/** 지정 객체만 남기고 나머지를 숨긴다 */
|
||||
export const isolateEntities = (entities: Entity[], allEntities: Entity[]): void => {
|
||||
const keep = new Set(entities.map((entity) => entity.id));
|
||||
apply(new Set(allEntities.filter((entity) => !keep.has(entity.id)).map((entity) => entity.id)));
|
||||
};
|
||||
|
||||
/** 숨김 해제 */
|
||||
export const showAllEntities = (): void => apply(new Set());
|
||||
@@ -1,6 +1,5 @@
|
||||
import { Point } from '@flatten-js/core';
|
||||
import { compact, round } from 'es-toolkit';
|
||||
import { Actor } from 'xstate';
|
||||
import {
|
||||
CANVAS_INPUT_FIELD_BACKGROUND_COLOR,
|
||||
CANVAS_INPUT_FIELD_HEIGHT,
|
||||
@@ -32,7 +31,6 @@ import {
|
||||
getSnapPoint,
|
||||
getSnapPointOnAngleGuide,
|
||||
redo,
|
||||
setActiveToolActor,
|
||||
setGhostHelperEntities,
|
||||
setHighlightedEntityIds,
|
||||
setPanStartLocation,
|
||||
@@ -40,8 +38,13 @@ import {
|
||||
setShouldDrawCursor,
|
||||
undo,
|
||||
} from '../state.ts';
|
||||
import {
|
||||
describeCommand,
|
||||
matchCommandPrefixes,
|
||||
resolveCommandInput,
|
||||
} from '../commands/registry.ts';
|
||||
import { runCommand } from '../commands/run-command.ts';
|
||||
import { Tool } from '../tools.ts';
|
||||
import { TOOL_STATE_MACHINES } from '../tools/tool.consts.ts';
|
||||
import {
|
||||
type AbsolutePointInputEvent,
|
||||
ActorEvent,
|
||||
@@ -119,7 +122,7 @@ export class InputController {
|
||||
);
|
||||
}
|
||||
|
||||
const matchingToolNames = this.getToolNamesFromPrefixText();
|
||||
const matchingCommands = this.getCommandSuggestions();
|
||||
const toolInstruction = getLastStateInstructions();
|
||||
const texts: string[] = [];
|
||||
if (toolInstruction) {
|
||||
@@ -129,15 +132,16 @@ export class InputController {
|
||||
const roundedY = round(drawController.getWorldMouseLocation().y, 2);
|
||||
texts.push(`${roundedX},${roundedY}`);
|
||||
}
|
||||
if (matchingToolNames.length) {
|
||||
// Draw list of matching tools. eg: C => CIRCLE, COPY, ...
|
||||
texts.push(...matchingToolNames);
|
||||
if (matchingCommands.length) {
|
||||
// 입력 중인 문자로 시작하는 명령 후보. 예: C => CIRCLE (C), COPY (CO)
|
||||
texts.push(...matchingCommands);
|
||||
}
|
||||
this.drawListBelowInputField(drawController, texts);
|
||||
}
|
||||
|
||||
public submitText(value: string) {
|
||||
this.text = value.trim().toUpperCase();
|
||||
// 명령 해석은 대소문자를 가리지 않으므로 원문을 그대로 둔다 (문자 주석의 대소문자 보존)
|
||||
this.text = value.trim();
|
||||
this.handleEnterKey();
|
||||
}
|
||||
|
||||
@@ -399,13 +403,12 @@ export class InputController {
|
||||
}
|
||||
}
|
||||
|
||||
private getToolNamesFromPrefixText(): Tool[] {
|
||||
/** 커서 옆에 띄울 명령 후보 (이름·별칭 접두사 일치) */
|
||||
private getCommandSuggestions(): string[] {
|
||||
if (this.text === '') {
|
||||
return [];
|
||||
}
|
||||
return (Object.keys(TOOL_STATE_MACHINES).filter((cmd) =>
|
||||
cmd.startsWith(this.text.toUpperCase())
|
||||
) || null) as Tool[];
|
||||
return matchCommandPrefixes(this.text).slice(0, 6).map(describeCommand);
|
||||
}
|
||||
|
||||
public handleEnterKey() {
|
||||
@@ -436,21 +439,12 @@ export class InputController {
|
||||
value: this.text,
|
||||
} as TextInputEvent);
|
||||
this.text = '';
|
||||
} else if (this.getToolNamesFromPrefixText()[0]) {
|
||||
// User entered a command. eg: L or LINE
|
||||
const toolName = this.getToolNamesFromPrefixText()[0];
|
||||
|
||||
getActiveToolActor()?.stop();
|
||||
|
||||
const newToolActor = new Actor(TOOL_STATE_MACHINES[toolName]);
|
||||
setActiveToolActor(newToolActor);
|
||||
|
||||
console.log('SWITCH TO TOOL: ', {
|
||||
toolName,
|
||||
// biome-ignore lint/suspicious/noExplicitAny: <explanation>
|
||||
activeTool: (getActiveToolActor()?.src as any).config.context.type,
|
||||
});
|
||||
|
||||
} else if (resolveCommandInput(this.text)) {
|
||||
// 명령 이름 또는 AutoCAD 별칭을 입력했다. 예: L, LINE, REC
|
||||
const command = resolveCommandInput(this.text);
|
||||
if (command) {
|
||||
runCommand(command);
|
||||
}
|
||||
this.text = '';
|
||||
} else if (NUMBER_REGEXP.test(this.text)) {
|
||||
console.log(' NUMBER_INPUT: ', {
|
||||
|
||||
@@ -9,6 +9,7 @@ import { draw } from './helpers/draw';
|
||||
import { findClosestEntity } from './helpers/find-closest-entity';
|
||||
import { scenePerf } from './helpers/scene-cache';
|
||||
import { queryEntitiesNearPoint } from './helpers/spatial-index';
|
||||
import { registerCadDebugHook } from './helpers/debug-hook.ts';
|
||||
import { getNewLayer } from './helpers/get-new-layer.ts';
|
||||
import { trackHoveredSnapPoint } from './helpers/track-hovered-snap-points';
|
||||
import { InputController } from './inputController/input-controller.ts';
|
||||
@@ -33,7 +34,7 @@ import {
|
||||
} from './state';
|
||||
import { syncThemeFromHost } from './theme.ts';
|
||||
import { Tool } from './tools';
|
||||
import { TOOL_STATE_MACHINES } from './tools/tool.consts';
|
||||
import { TOOL_STATE_MACHINES } from './commands/registry';
|
||||
import { ActorEvent, type DrawEvent } from './tools/tool.types';
|
||||
|
||||
// 호스트 앱의 화이트/블랙 모드를 먼저 붙인 뒤 렌더한다.
|
||||
@@ -153,6 +154,7 @@ function initApplication() {
|
||||
setLayers(layers);
|
||||
setActiveLayerId(layers[0].id);
|
||||
registerAisloDrawingBridge();
|
||||
registerCadDebugHook();
|
||||
const screenCanvasDrawController = new ScreenCanvasDrawController(context);
|
||||
setScreenCanvasDrawController(screenCanvasDrawController);
|
||||
|
||||
|
||||
@@ -0,0 +1,148 @@
|
||||
/** 리본 렌더러 — ribbon.config.ts의 데이터와 명령 레지스트리만 보고 그린다. */
|
||||
import { type FC, type ReactNode, useState } from 'react';
|
||||
import { getActiveRibbonTab, setActiveRibbonTab } from '../components/ui-state';
|
||||
import type { CadCommand } from '../commands/command.types';
|
||||
import { getCommandById } from '../commands/registry';
|
||||
import { runCommand } from '../commands/run-command';
|
||||
import { type RibbonPanel, RIBBON_TABS, type RibbonWidget } from './ribbon.config';
|
||||
import type { Tool } from '../tools';
|
||||
|
||||
interface RibbonProps {
|
||||
activeTool: Tool | null;
|
||||
widgets: Partial<Record<RibbonWidget, ReactNode>>;
|
||||
onCommandRun?: (command: CadCommand) => void;
|
||||
}
|
||||
|
||||
interface CommandButtonProps {
|
||||
command: CadCommand;
|
||||
size: 'big' | 'small';
|
||||
activeTool: Tool | null;
|
||||
onRun: (command: CadCommand) => void;
|
||||
}
|
||||
|
||||
const CommandButton: FC<CommandButtonProps> = ({ command, size, activeTool, onRun }) => (
|
||||
<button
|
||||
type="button"
|
||||
className="cad-tool"
|
||||
data-size={size}
|
||||
data-active={!!command.tool && command.tool === activeTool}
|
||||
title={`${command.label}${command.aliases?.length ? ` (${command.aliases[0]})` : ''}${
|
||||
command.hint ? ` — ${command.hint}` : ''
|
||||
}`}
|
||||
onClick={() => onRun(command)}
|
||||
>
|
||||
<span className="cad-tool__glyph">{command.glyph}</span>
|
||||
<span className="cad-tool__label">{command.label}</span>
|
||||
</button>
|
||||
);
|
||||
|
||||
const resolve = (ids: string[] | undefined): CadCommand[] =>
|
||||
(ids ?? []).map((id) => getCommandById(id)).filter((command): command is CadCommand => !!command);
|
||||
|
||||
const Panel: FC<{
|
||||
panel: RibbonPanel;
|
||||
activeTool: Tool | null;
|
||||
widgets: Partial<Record<RibbonWidget, ReactNode>>;
|
||||
onRun: (command: CadCommand) => void;
|
||||
}> = ({ panel, activeTool, widgets, onRun }) => {
|
||||
const [overflowOpen, setOverflowOpen] = useState(false);
|
||||
const overflowCommands = resolve(panel.overflow);
|
||||
|
||||
return (
|
||||
<section className="cad-ribbon-group">
|
||||
<div className="cad-ribbon-tools">
|
||||
{panel.widget ? (
|
||||
(widgets[panel.widget] ?? null)
|
||||
) : (
|
||||
<>
|
||||
{resolve(panel.big).map((command) => (
|
||||
<CommandButton
|
||||
key={command.id}
|
||||
command={command}
|
||||
size="big"
|
||||
activeTool={activeTool}
|
||||
onRun={onRun}
|
||||
/>
|
||||
))}
|
||||
<div className="cad-ribbon-tools__grid">
|
||||
{resolve(panel.commands).map((command) => (
|
||||
<CommandButton
|
||||
key={command.id}
|
||||
command={command}
|
||||
size="small"
|
||||
activeTool={activeTool}
|
||||
onRun={onRun}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
className="cad-ribbon-group__label"
|
||||
data-expandable={overflowCommands.length > 0}
|
||||
onClick={() => overflowCommands.length && setOverflowOpen((open) => !open)}
|
||||
>
|
||||
{panel.label}
|
||||
{overflowCommands.length ? ' ▾' : ''}
|
||||
</button>
|
||||
{overflowOpen && overflowCommands.length > 0 && (
|
||||
<div className="cad-ribbon-overflow">
|
||||
{overflowCommands.map((command) => (
|
||||
<button
|
||||
type="button"
|
||||
key={command.id}
|
||||
title={command.hint}
|
||||
onClick={() => {
|
||||
setOverflowOpen(false);
|
||||
onRun(command);
|
||||
}}
|
||||
>
|
||||
<span className="cad-tool__glyph">{command.glyph}</span>
|
||||
{command.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
);
|
||||
};
|
||||
|
||||
export const Ribbon: FC<RibbonProps> = ({ activeTool, widgets, onCommandRun }) => {
|
||||
const activeTabId = getActiveRibbonTab();
|
||||
const activeTab = RIBBON_TABS.find((tab) => tab.id === activeTabId) ?? RIBBON_TABS[0];
|
||||
|
||||
const handleRun = (command: CadCommand) => {
|
||||
runCommand(command);
|
||||
onCommandRun?.(command);
|
||||
};
|
||||
|
||||
return (
|
||||
<nav className="cad-ribbon controls" aria-label="CAD 리본">
|
||||
<div className="cad-ribbon-tabs">
|
||||
{RIBBON_TABS.map((tab) => (
|
||||
<button
|
||||
type="button"
|
||||
key={tab.id}
|
||||
data-active={tab.id === activeTab.id}
|
||||
onClick={() => setActiveRibbonTab(tab.id)}
|
||||
>
|
||||
{tab.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<div className="cad-ribbon-panels">
|
||||
{activeTab.panels.map((panel) => (
|
||||
<Panel
|
||||
key={panel.label}
|
||||
panel={panel}
|
||||
activeTool={activeTool}
|
||||
widgets={widgets}
|
||||
onRun={handleRun}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</nav>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,203 @@
|
||||
/**
|
||||
* 리본 구성 — AutoCAD 2024 `제도 및 주석` 작업공간의 탭·패널 배치를 따른다.
|
||||
* 여기에는 데이터만 둔다. 버튼이 무엇을 하는지는 commands 레지스트리가 안다.
|
||||
*/
|
||||
|
||||
export type RibbonWidget = 'properties' | 'layers' | 'textStyle';
|
||||
|
||||
export interface RibbonPanel {
|
||||
label: string;
|
||||
/** 크게 표시할 대표 명령 (AutoCAD의 큰 버튼) */
|
||||
big?: string[];
|
||||
/** 작은 버튼으로 나열할 명령 */
|
||||
commands?: string[];
|
||||
/** 패널 확장(▾)을 눌러야 나오는 명령 */
|
||||
overflow?: string[];
|
||||
/** 명령 버튼이 아닌 특수 위젯 패널 */
|
||||
widget?: RibbonWidget;
|
||||
}
|
||||
|
||||
export interface RibbonTab {
|
||||
id: string;
|
||||
label: string;
|
||||
panels: RibbonPanel[];
|
||||
}
|
||||
|
||||
export const RIBBON_TABS: RibbonTab[] = [
|
||||
{
|
||||
id: 'home',
|
||||
label: '홈',
|
||||
panels: [
|
||||
{
|
||||
label: '그리기',
|
||||
big: ['LINE', 'PLINE'],
|
||||
commands: ['CIRCLE', 'ARC', 'RECTANG', 'HATCH'],
|
||||
overflow: [
|
||||
'POLYGON',
|
||||
'POINT',
|
||||
'ELLIPSE',
|
||||
'SPLINE',
|
||||
'MLINE',
|
||||
'MLSTYLE',
|
||||
'XLINE',
|
||||
'RAY',
|
||||
'DONUT',
|
||||
'DIVIDE',
|
||||
'MEASURE',
|
||||
'GRADIENT',
|
||||
'BOUNDARY',
|
||||
'REGION',
|
||||
'WIPEOUT',
|
||||
],
|
||||
},
|
||||
{
|
||||
label: '수정',
|
||||
big: ['MOVE', 'COPY'],
|
||||
commands: ['ROTATE', 'MIRROR', 'TRIM', 'OFFSET'],
|
||||
overflow: [
|
||||
'SCALE',
|
||||
'ERASE',
|
||||
'ALIGN',
|
||||
'ARRAY',
|
||||
'EXTEND',
|
||||
'STRETCH',
|
||||
'FILLET',
|
||||
'CHAMFER',
|
||||
'BLEND',
|
||||
'BREAK',
|
||||
'BREAKATPOINT',
|
||||
'JOIN',
|
||||
'EXPLODE',
|
||||
'LENGTHEN',
|
||||
'PEDIT',
|
||||
'HATCHEDIT',
|
||||
'DRAWORDER',
|
||||
'DRAWORDERBACK',
|
||||
'MATCHPROP',
|
||||
'OVERKILL',
|
||||
'REVERSE',
|
||||
'SETBYLAYER',
|
||||
'ALIGNLEFT',
|
||||
'ALIGNCENTERH',
|
||||
'ALIGNRIGHT',
|
||||
'ALIGNTOP',
|
||||
'ALIGNCENTERV',
|
||||
'ALIGNBOTTOM',
|
||||
],
|
||||
},
|
||||
{
|
||||
label: '도면층',
|
||||
widget: 'layers',
|
||||
overflow: [
|
||||
'LAYCURSET',
|
||||
'LAYOFF',
|
||||
'LAYON',
|
||||
'LAYFRZ',
|
||||
'LAYTHW',
|
||||
'LAYLCK',
|
||||
'LAYULK',
|
||||
'LAYISO',
|
||||
'LAYUNISO',
|
||||
'LAYERP',
|
||||
'LAYERSTATE',
|
||||
'LAYERSTATERESTORE',
|
||||
'LAYMCH',
|
||||
'LAYCUR',
|
||||
'LAYMRG',
|
||||
'LAYDEL',
|
||||
'LAYWALK',
|
||||
],
|
||||
},
|
||||
{
|
||||
label: '특성',
|
||||
widget: 'properties',
|
||||
overflow: ['PROPERTIES', 'QUICKPROPERTIES', 'COLOR', 'LINETYPE', 'LWEIGHT', 'TRANSPARENCY'],
|
||||
},
|
||||
{ label: '그룹', commands: ['GROUP', 'UNGROUP'] },
|
||||
{
|
||||
label: '유틸리티',
|
||||
big: ['SELECT'],
|
||||
commands: ['DIST', 'AREA'],
|
||||
overflow: [
|
||||
'ID',
|
||||
'LIST',
|
||||
'QSELECT',
|
||||
'SELECTSIMILAR',
|
||||
'RADIUS',
|
||||
'ANGLE',
|
||||
'MEASUREGEOM',
|
||||
'QUICKCALC',
|
||||
'ISOLATEOBJECTS',
|
||||
'HIDEOBJECTS',
|
||||
'UNISOLATEOBJECTS',
|
||||
],
|
||||
},
|
||||
{
|
||||
label: '클립보드',
|
||||
big: ['PASTECLIP'],
|
||||
commands: ['COPYCLIP', 'CUTCLIP'],
|
||||
overflow: ['COPYBASE', 'PASTEORIG', 'PASTEBLOCK'],
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'insert',
|
||||
label: '삽입',
|
||||
panels: [{ label: '참조', commands: ['IMAGEATTACH'] }],
|
||||
},
|
||||
{
|
||||
id: 'annotate',
|
||||
label: '주석',
|
||||
panels: [
|
||||
{
|
||||
label: '문자',
|
||||
big: ['MTEXT'],
|
||||
commands: ['TEXT', 'TEXTEDIT', 'FIND', 'STYLE'],
|
||||
},
|
||||
{ label: '문자 스타일', widget: 'textStyle' },
|
||||
{
|
||||
label: '치수',
|
||||
big: ['DIMLINEAR', 'DIMALIGNED'],
|
||||
commands: ['DIM', 'DIMANGULAR', 'DIMRADIUS', 'DIMDIAMETER', 'DIMCONTINUE', 'QDIM'],
|
||||
overflow: [
|
||||
'DIMARC',
|
||||
'DIMORDINATE',
|
||||
'DIMBASELINE',
|
||||
'CENTERMARK',
|
||||
'CENTERLINE',
|
||||
'DIMSTYLE',
|
||||
'DIMUPDATE',
|
||||
'DIMSPACE',
|
||||
],
|
||||
},
|
||||
{
|
||||
label: '지시선',
|
||||
big: ['MLEADER'],
|
||||
commands: ['MLEADERSTYLE', 'MLEADERALIGN'],
|
||||
},
|
||||
{ label: '표', commands: ['TABLE', 'TABLESTYLE'] },
|
||||
{ label: '표식', commands: ['REVCLOUD', 'ANNOSCALE'] },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'view',
|
||||
label: '뷰',
|
||||
panels: [
|
||||
{ label: '탐색', big: ['ZOOM'], commands: ['ZOOMIN', 'ZOOMOUT', 'PAN', 'REGEN'] },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'output',
|
||||
label: '출력',
|
||||
panels: [
|
||||
{
|
||||
label: '내보내기',
|
||||
big: ['EXPORT'],
|
||||
commands: ['EXPORTSVG', 'EXPORTPNG', 'QSAVE'],
|
||||
},
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
/** 제목표시줄의 빠른 실행 도구막대 */
|
||||
export const QUICK_ACCESS_COMMANDS: string[] = ['QSAVE', 'EXPORT', 'UNDO', 'REDO'];
|
||||
@@ -9,7 +9,6 @@ export enum Tool {
|
||||
ERASER = 'ERASER',
|
||||
IMAGE_IMPORT = 'IMAGE_IMPORT',
|
||||
ROTATE = 'ROTATE',
|
||||
MEASUREMENT = 'MEASUREMENT',
|
||||
ALIGN_LEFT = 'ALIGN_LEFT',
|
||||
ALIGN_RIGHT = 'ALIGN_RIGHT',
|
||||
ALIGN_CENTER_HORIZONTAL = 'ALIGN_CENTER_HORIZONTAL',
|
||||
@@ -17,5 +16,105 @@ export enum Tool {
|
||||
ALIGN_BOTTOM = 'ALIGN_BOTTOM',
|
||||
ALIGN_CENTER_VERTICAL = 'ALIGN_CENTER_VERTICAL',
|
||||
ARRAY = 'ARRAY',
|
||||
PEDIT = 'PEDIT'
|
||||
PEDIT = 'PEDIT',
|
||||
// 조사표 1절 — 그리기
|
||||
PLINE = 'PLINE',
|
||||
ARC = 'ARC',
|
||||
POLYGON = 'POLYGON',
|
||||
ELLIPSE = 'ELLIPSE',
|
||||
SPLINE = 'SPLINE',
|
||||
MLINE = 'MLINE',
|
||||
MLSTYLE = 'MLSTYLE',
|
||||
XLINE = 'XLINE',
|
||||
RAY = 'RAY',
|
||||
POINT = 'POINT',
|
||||
DONUT = 'DONUT',
|
||||
DIVIDE = 'DIVIDE',
|
||||
MEASURE_LENGTH = 'MEASURE_LENGTH',
|
||||
HATCH = 'HATCH',
|
||||
GRADIENT = 'GRADIENT',
|
||||
BOUNDARY = 'BOUNDARY',
|
||||
REGION = 'REGION',
|
||||
WIPEOUT = 'WIPEOUT',
|
||||
// 조사표 2절 — 수정
|
||||
MIRROR = 'MIRROR',
|
||||
ALIGN = 'ALIGN',
|
||||
OFFSET = 'OFFSET',
|
||||
STRETCH = 'STRETCH',
|
||||
LENGTHEN = 'LENGTHEN',
|
||||
FILLET = 'FILLET',
|
||||
CHAMFER = 'CHAMFER',
|
||||
BLEND = 'BLEND',
|
||||
BREAK = 'BREAK',
|
||||
BREAK_AT_POINT = 'BREAK_AT_POINT',
|
||||
EXTEND = 'EXTEND',
|
||||
JOIN = 'JOIN',
|
||||
EXPLODE = 'EXPLODE',
|
||||
ERASE = 'ERASE',
|
||||
MATCHPROP = 'MATCHPROP',
|
||||
DRAWORDER_FRONT = 'DRAWORDER_FRONT',
|
||||
DRAWORDER_BACK = 'DRAWORDER_BACK',
|
||||
SETBYLAYER = 'SETBYLAYER',
|
||||
REVERSE = 'REVERSE',
|
||||
OVERKILL = 'OVERKILL',
|
||||
HATCHEDIT = 'HATCHEDIT',
|
||||
// 조사표 3절 — 도면층·특성·그룹·유틸리티
|
||||
LAYER_CURRENT = 'LAYER_CURRENT',
|
||||
LAYOFF = 'LAYOFF',
|
||||
LAYFRZ = 'LAYFRZ',
|
||||
LAYLCK = 'LAYLCK',
|
||||
LAYULK = 'LAYULK',
|
||||
LAYISO = 'LAYISO',
|
||||
LAYERSTATE = 'LAYERSTATE',
|
||||
LAYERSTATE_RESTORE = 'LAYERSTATE_RESTORE',
|
||||
LAYMCH = 'LAYMCH',
|
||||
LAYCUR = 'LAYCUR',
|
||||
LAYMRG = 'LAYMRG',
|
||||
LAYDEL = 'LAYDEL',
|
||||
TRANSPARENCY = 'TRANSPARENCY',
|
||||
GROUP = 'GROUP',
|
||||
UNGROUP = 'UNGROUP',
|
||||
DIST = 'DIST',
|
||||
RADIUS_INQUIRY = 'RADIUS_INQUIRY',
|
||||
ANGLE_INQUIRY = 'ANGLE_INQUIRY',
|
||||
AREA = 'AREA',
|
||||
ID_POINT = 'ID_POINT',
|
||||
QUICKCALC = 'QUICKCALC',
|
||||
MEASUREGEOM = 'MEASUREGEOM',
|
||||
QSELECT = 'QSELECT',
|
||||
SELECTSIMILAR = 'SELECTSIMILAR',
|
||||
ISOLATEOBJECTS = 'ISOLATEOBJECTS',
|
||||
HIDEOBJECTS = 'HIDEOBJECTS',
|
||||
COPYBASE = 'COPYBASE',
|
||||
PASTECLIP = 'PASTECLIP',
|
||||
// 조사표 5절 — 주석
|
||||
TEXT = 'TEXT',
|
||||
MTEXT = 'MTEXT',
|
||||
TEXTEDIT = 'TEXTEDIT',
|
||||
FIND = 'FIND',
|
||||
DIM = 'DIM',
|
||||
DIMLINEAR = 'DIMLINEAR',
|
||||
DIMALIGNED = 'DIMALIGNED',
|
||||
DIMBASELINE = 'DIMBASELINE',
|
||||
DIMCONTINUE = 'DIMCONTINUE',
|
||||
QDIM = 'QDIM',
|
||||
DIMRADIUS = 'DIMRADIUS',
|
||||
DIMDIAMETER = 'DIMDIAMETER',
|
||||
DIMANGULAR = 'DIMANGULAR',
|
||||
DIMARC = 'DIMARC',
|
||||
DIMORDINATE = 'DIMORDINATE',
|
||||
CENTERMARK = 'CENTERMARK',
|
||||
CENTERLINE = 'CENTERLINE',
|
||||
DIMSPACE = 'DIMSPACE',
|
||||
DIMSTYLE = 'DIMSTYLE',
|
||||
MLEADER = 'MLEADER',
|
||||
MLEADERSTYLE = 'MLEADERSTYLE',
|
||||
MLEADERALIGN = 'MLEADERALIGN',
|
||||
TABLE = 'TABLE',
|
||||
TABLESTYLE = 'TABLESTYLE',
|
||||
REVCLOUD = 'REVCLOUD',
|
||||
ANNOSCALE = 'ANNOSCALE',
|
||||
COLOR = 'COLOR',
|
||||
LINETYPE = 'LINETYPE',
|
||||
LWEIGHT = 'LWEIGHT'
|
||||
}
|
||||
|
||||
@@ -0,0 +1,66 @@
|
||||
/** 치수·지시선이 함께 쓰는 조각 만들기 (화살표·치수 문자·직전 치수 기억) */
|
||||
import { Point } from '@flatten-js/core';
|
||||
import { getDimArrowSize, getDimDecimals, getDimTextHeight } from '../../commands/dim-settings';
|
||||
import { ArrowHeadEntity } from '../../entities/ArrowHeadEntity';
|
||||
import type { Entity } from '../../entities/Entity';
|
||||
import type { MeasurementEntity } from '../../entities/MeasurementEntity';
|
||||
import { getActiveLayerId, getActiveLineColor, getScreenCanvasDrawController } from '../../state';
|
||||
import { textEntity } from '../factories/entity-factory';
|
||||
|
||||
/** 화면 px 기준 상수를 도면 단위로 바꾼다 (줌이 달라도 크기가 일정하게 보인다) */
|
||||
export function worldFactor(): number {
|
||||
try {
|
||||
return getScreenCanvasDrawController().getScreenScale() || 1;
|
||||
} catch {
|
||||
return 1;
|
||||
}
|
||||
}
|
||||
|
||||
/** tip을 꼭짓점으로 하고 from 방향에서 들어오는 화살표 */
|
||||
export function arrowHead(tip: Point, from: Point, size = getDimArrowSize()): ArrowHeadEntity {
|
||||
const length = size / worldFactor();
|
||||
const dx = tip.x - from.x;
|
||||
const dy = tip.y - from.y;
|
||||
const distance = Math.hypot(dx, dy) || 1;
|
||||
const ux = dx / distance;
|
||||
const uy = dy / distance;
|
||||
const baseX = tip.x - ux * length;
|
||||
const baseY = tip.y - uy * length;
|
||||
const halfWidth = length * 0.35;
|
||||
|
||||
const head = new ArrowHeadEntity(
|
||||
getActiveLayerId(),
|
||||
tip,
|
||||
new Point(baseX - uy * halfWidth, baseY + ux * halfWidth),
|
||||
new Point(baseX + uy * halfWidth, baseY - ux * halfWidth)
|
||||
);
|
||||
head.lineColor = getActiveLineColor();
|
||||
head.fillColor = getActiveLineColor();
|
||||
return head;
|
||||
}
|
||||
|
||||
/** 치수 문자 — 설정한 소수 자릿수·문자 높이를 따른다 */
|
||||
export function dimensionText(value: number, position: Point, prefix = '', suffix = ''): Entity {
|
||||
const label = `${prefix}${value.toFixed(getDimDecimals())}${suffix}`;
|
||||
return textEntity(label, position, {
|
||||
fontSize: getDimTextHeight() / worldFactor(),
|
||||
textAlign: 'center',
|
||||
});
|
||||
}
|
||||
|
||||
/** 문자 그대로 찍는 주석 (좌표·각도처럼 단위가 다른 값) */
|
||||
export function annotationText(label: string, position: Point): Entity {
|
||||
return textEntity(label, position, {
|
||||
fontSize: getDimTextHeight() / worldFactor(),
|
||||
textAlign: 'center',
|
||||
});
|
||||
}
|
||||
|
||||
/** DIMBASELINE·DIMCONTINUE가 이어 그릴 직전 치수 */
|
||||
let lastDimension: MeasurementEntity | null = null;
|
||||
|
||||
export const rememberDimension = (dimension: MeasurementEntity): void => {
|
||||
lastDimension = dimension;
|
||||
};
|
||||
|
||||
export const getLastDimension = (): MeasurementEntity | null => lastDimension;
|
||||
@@ -0,0 +1,256 @@
|
||||
/** 반지름·지름·각도·호길이·세로좌표 치수와 중심 표식 (조사표 5절) */
|
||||
import { Arc, Circle, Point } from '@flatten-js/core';
|
||||
import { toast } from 'react-toastify';
|
||||
import { getDimDecimals } from '../../commands/dim-settings';
|
||||
import type { Entity } from '../../entities/Entity';
|
||||
import { MeasurementEntity } from '../../entities/MeasurementEntity';
|
||||
import { polylineLength, sampleEntityPoints } from '../../helpers/geometry/sample-entity';
|
||||
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 { annotationText, arrowHead, dimensionText } from './annotation.helpers';
|
||||
|
||||
interface CircularShape {
|
||||
center: Point;
|
||||
radius: number;
|
||||
}
|
||||
|
||||
/** 원·호에서 중심과 반지름을 꺼낸다 */
|
||||
function circularOf(entity: Entity): CircularShape | null {
|
||||
const shape = entity.getShape();
|
||||
if (shape instanceof Circle) return { center: shape.center, radius: Number(shape.r) };
|
||||
if (shape instanceof Arc) return { center: shape.center, radius: Number(shape.r) };
|
||||
return null;
|
||||
}
|
||||
|
||||
function radialDimension(entity: Entity, textPoint: Point, diameter: boolean): Entity[] | null {
|
||||
const circular = circularOf(entity);
|
||||
if (!circular) return null;
|
||||
|
||||
const angle = Math.atan2(textPoint.y - circular.center.y, textPoint.x - circular.center.x);
|
||||
const edge = new Point(
|
||||
circular.center.x + circular.radius * Math.cos(angle),
|
||||
circular.center.y + circular.radius * Math.sin(angle)
|
||||
);
|
||||
const start = diameter
|
||||
? new Point(
|
||||
circular.center.x - circular.radius * Math.cos(angle),
|
||||
circular.center.y - circular.radius * Math.sin(angle)
|
||||
)
|
||||
: circular.center;
|
||||
|
||||
return [
|
||||
lineEntity(start, textPoint),
|
||||
arrowHead(edge, circular.center),
|
||||
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);
|
||||
},
|
||||
});
|
||||
|
||||
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);
|
||||
},
|
||||
});
|
||||
|
||||
export const dimAngularToolStateMachine = createSequenceTool({
|
||||
tool: Tool.DIMANGULAR,
|
||||
steps: [
|
||||
{ kind: 'point', instructions: '각의 꼭짓점을 지정하십시오.' },
|
||||
{ kind: 'point', instructions: '첫 번째 변 위의 점을 지정하십시오.' },
|
||||
{ kind: 'point', instructions: '두 번째 변 위의 점을 지정하십시오.' },
|
||||
],
|
||||
commit: (input) => {
|
||||
const [vertex, first, second] = input.points();
|
||||
const radius = Math.min(vertex.distanceTo(first)[0], vertex.distanceTo(second)[0]) * 0.6;
|
||||
if (radius <= 0) return;
|
||||
|
||||
const startAngle = Math.atan2(first.y - vertex.y, first.x - vertex.x);
|
||||
const endAngle = Math.atan2(second.y - vertex.y, second.x - vertex.x);
|
||||
const sweep = ((endAngle - startAngle + 2 * Math.PI) % (2 * Math.PI));
|
||||
const midAngle = startAngle + sweep / 2;
|
||||
const degrees = (sweep * 180) / Math.PI;
|
||||
|
||||
addEntities(
|
||||
[
|
||||
lineEntity(vertex, first),
|
||||
lineEntity(vertex, second),
|
||||
arcEntity({
|
||||
center: vertex,
|
||||
radius,
|
||||
startAngle,
|
||||
endAngle,
|
||||
counterClockwise: true,
|
||||
}),
|
||||
annotationText(
|
||||
`${degrees.toFixed(getDimDecimals())}°`,
|
||||
new Point(
|
||||
vertex.x + radius * 1.25 * Math.cos(midAngle),
|
||||
vertex.y + radius * 1.25 * Math.sin(midAngle)
|
||||
)
|
||||
),
|
||||
],
|
||||
true
|
||||
);
|
||||
},
|
||||
});
|
||||
|
||||
export const dimArcToolStateMachine = createSequenceTool({
|
||||
tool: Tool.DIMARC,
|
||||
steps: [
|
||||
{ kind: 'entity', instructions: '호 길이를 기입할 호를 선택하십시오.' },
|
||||
{ kind: 'point', instructions: '치수 문자 위치를 지정하십시오.' },
|
||||
],
|
||||
commit: (input) => {
|
||||
const entity = input.entity(0);
|
||||
const circular = circularOf(entity);
|
||||
if (!circular) {
|
||||
toast.warn('호를 선택하십시오.');
|
||||
return;
|
||||
}
|
||||
const length = polylineLength(sampleEntityPoints(entity));
|
||||
addEntities([dimensionText(length, input.point(1), '⌒ ')], true);
|
||||
},
|
||||
});
|
||||
|
||||
export const dimOrdinateToolStateMachine = createSequenceTool({
|
||||
tool: Tool.DIMORDINATE,
|
||||
steps: [
|
||||
{ kind: 'point', instructions: '좌표를 기입할 피처 위치를 지정하십시오.' },
|
||||
{ kind: 'point', instructions: '지시선 끝점을 지정하십시오.' },
|
||||
],
|
||||
commit: (input) => {
|
||||
const [feature, leaderEnd] = input.points();
|
||||
// 지시선이 세로로 길면 X좌표를, 가로로 길면 Y좌표를 기입한다 (AutoCAD와 같다)
|
||||
const vertical = Math.abs(leaderEnd.y - feature.y) >= Math.abs(leaderEnd.x - feature.x);
|
||||
const value = vertical ? feature.x : feature.y;
|
||||
addEntities(
|
||||
[
|
||||
lineEntity(feature, leaderEnd),
|
||||
annotationText(
|
||||
`${vertical ? 'X' : 'Y'} ${value.toFixed(getDimDecimals())}`,
|
||||
new Point(leaderEnd.x, leaderEnd.y)
|
||||
),
|
||||
],
|
||||
true
|
||||
);
|
||||
},
|
||||
});
|
||||
|
||||
export const centerMarkToolStateMachine = createSequenceTool({
|
||||
tool: Tool.CENTERMARK,
|
||||
steps: [{ kind: 'entity', instructions: '중심 표식을 넣을 원 또는 호를 선택하십시오.' }],
|
||||
commit: (input) => {
|
||||
const circular = circularOf(input.entity(0));
|
||||
if (!circular) {
|
||||
toast.warn('원 또는 호를 선택하십시오.');
|
||||
return;
|
||||
}
|
||||
const size = circular.radius * 0.15;
|
||||
addEntities(
|
||||
[
|
||||
lineEntity(
|
||||
new Point(circular.center.x - size, circular.center.y),
|
||||
new Point(circular.center.x + size, circular.center.y)
|
||||
),
|
||||
lineEntity(
|
||||
new Point(circular.center.x, circular.center.y - size),
|
||||
new Point(circular.center.x, circular.center.y + size)
|
||||
),
|
||||
],
|
||||
true
|
||||
);
|
||||
},
|
||||
});
|
||||
|
||||
export const centerLineToolStateMachine = createSequenceTool({
|
||||
tool: Tool.CENTERLINE,
|
||||
helpers: false,
|
||||
steps: [
|
||||
{ kind: 'entity', instructions: '첫 번째 선을 선택하십시오.' },
|
||||
{ kind: 'entity', instructions: '두 번째 선을 선택하십시오.' },
|
||||
],
|
||||
commit: (input) => {
|
||||
const first = sampleEntityPoints(input.entity(0));
|
||||
const second = sampleEntityPoints(input.entity(1));
|
||||
if (first.length < 2 || second.length < 2) {
|
||||
toast.warn('두 선을 선택하십시오.');
|
||||
return;
|
||||
}
|
||||
const middle = (a: Point, b: Point) => new Point((a.x + b.x) / 2, (a.y + b.y) / 2);
|
||||
const line = lineEntity(
|
||||
middle(first[0], second[0]),
|
||||
middle(first[first.length - 1], second[second.length - 1])
|
||||
);
|
||||
line.lineDash = [12, 4, 2, 4];
|
||||
addEntities([line], true);
|
||||
},
|
||||
});
|
||||
|
||||
/** DIMSPACE — 선택한 치수들의 치수선 간격을 고르게 맞춘다 */
|
||||
export const dimSpaceToolStateMachine = createSequenceTool({
|
||||
tool: Tool.DIMSPACE,
|
||||
helpers: false,
|
||||
steps: [
|
||||
{ kind: 'selection', instructions: '간격을 맞출 치수를 선택한 뒤 ENTER.' },
|
||||
{ kind: 'number', instructions: '치수선 간격을 입력하십시오 <10>.', defaultValue: 10 },
|
||||
],
|
||||
commit: (input) => {
|
||||
const dimensions = input
|
||||
.entities(0)
|
||||
.filter((entity): entity is MeasurementEntity => entity instanceof MeasurementEntity);
|
||||
if (dimensions.length < 2) {
|
||||
toast.warn('치수를 두 개 이상 선택하십시오.');
|
||||
return;
|
||||
}
|
||||
const spacing = input.number(1);
|
||||
const base = dimensions[0];
|
||||
const baseStart = base.getStartPoint();
|
||||
const baseOffset = base.getOffsetPoint();
|
||||
const direction = new Point(baseOffset.x - baseStart.x, baseOffset.y - baseStart.y);
|
||||
const length = Math.hypot(direction.x, direction.y) || 1;
|
||||
|
||||
dimensions.forEach((dimension, index) => {
|
||||
if (index === 0) return;
|
||||
const start = dimension.getStartPoint();
|
||||
dimension.setOffsetPoint(
|
||||
new Point(
|
||||
start.x + (direction.x / length) * (length + spacing * index),
|
||||
start.y + (direction.y / length) * (length + spacing * index)
|
||||
)
|
||||
);
|
||||
});
|
||||
setEntities([...getEntities()], true);
|
||||
setSelectedEntityIds([]);
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,163 @@
|
||||
/** 선형 치수 계열 — 선형·정렬·기준선·연속·빠른 치수·자동 치수 (조사표 5절) */
|
||||
import { Circle, Point } from '@flatten-js/core';
|
||||
import { toast } from 'react-toastify';
|
||||
import { EntityName } from '../../entities/Entity';
|
||||
import { MeasurementEntity } from '../../entities/MeasurementEntity';
|
||||
import { sampleEntityPoints } from '../../helpers/geometry/sample-entity';
|
||||
import { addEntities, getActiveLayerId, setSelectedEntityIds } from '../../state';
|
||||
import { Tool } from '../../tools';
|
||||
import { styled } from '../factories/entity-factory';
|
||||
import { createSequenceTool } from '../factories/sequence-tool';
|
||||
import { getLastDimension, rememberDimension } from './annotation.helpers';
|
||||
|
||||
/** 치수 객체 하나 만들기 — 현재 선 특성을 입히고 직전 치수로 기억한다 */
|
||||
function makeDimension(start: Point, end: Point, offset: Point): MeasurementEntity {
|
||||
const dimension = styled(new MeasurementEntity(getActiveLayerId(), start, end, offset));
|
||||
rememberDimension(dimension);
|
||||
return dimension;
|
||||
}
|
||||
|
||||
/**
|
||||
* 선형 치수 — 치수선을 놓은 방향으로 수평/수직을 고른다.
|
||||
* 위·아래에 놓으면 가로 거리, 좌·우에 놓으면 세로 거리를 잰다 (AutoCAD와 같다).
|
||||
*/
|
||||
function projectForLinear(start: Point, end: Point, offset: Point): [Point, Point] {
|
||||
const horizontalSpan = Math.abs(end.x - start.x);
|
||||
const verticalSpan = Math.abs(end.y - start.y);
|
||||
const offsetIsVertical =
|
||||
Math.abs(offset.y - (start.y + end.y) / 2) >= Math.abs(offset.x - (start.x + end.x) / 2);
|
||||
if (offsetIsVertical || horizontalSpan >= verticalSpan) {
|
||||
return [start, new Point(end.x, start.y)];
|
||||
}
|
||||
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
|
||||
);
|
||||
addEntities([makeDimension(start, input.point(0), nextOffset)], true);
|
||||
},
|
||||
});
|
||||
|
||||
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
|
||||
);
|
||||
},
|
||||
});
|
||||
|
||||
export const qDimToolStateMachine = createSequenceTool({
|
||||
tool: Tool.QDIM,
|
||||
helpers: false,
|
||||
steps: [
|
||||
{ kind: 'selection', instructions: '치수를 넣을 객체를 선택한 뒤 ENTER.' },
|
||||
{ kind: 'point', instructions: '치수선 위치를 지정하십시오.' },
|
||||
],
|
||||
commit: (input) => {
|
||||
const offset = input.point(1);
|
||||
const dimensions = input
|
||||
.entities(0)
|
||||
.map((entity) => {
|
||||
const points = sampleEntityPoints(entity);
|
||||
if (points.length < 2) return null;
|
||||
return makeDimension(points[0], points[points.length - 1], offset);
|
||||
})
|
||||
.filter((dimension): dimension is MeasurementEntity => !!dimension);
|
||||
if (!dimensions.length) {
|
||||
toast.warn('치수를 넣을 수 있는 객체가 없습니다.');
|
||||
return;
|
||||
}
|
||||
addEntities(dimensions, true);
|
||||
setSelectedEntityIds([]);
|
||||
},
|
||||
});
|
||||
|
||||
/** DIM — 선택한 객체 종류에 맞는 치수를 자동으로 고른다 */
|
||||
export const dimAutoToolStateMachine = createSequenceTool({
|
||||
tool: Tool.DIM,
|
||||
helpers: false,
|
||||
steps: [
|
||||
{ kind: 'entity', instructions: '치수를 넣을 객체를 선택하십시오.' },
|
||||
{ kind: 'point', instructions: '치수선 위치를 지정하십시오.' },
|
||||
],
|
||||
commit: (input) => {
|
||||
const entity = input.entity(0);
|
||||
const offset = input.point(1);
|
||||
const shape = entity.getShape();
|
||||
|
||||
if (shape instanceof Circle || entity.getType() === EntityName.Arc) {
|
||||
// 원·호는 반지름 치수 명령이 더 알맞다
|
||||
toast.info('원·호에는 DIMRADIUS 또는 DIMDIAMETER를 사용하십시오.');
|
||||
return;
|
||||
}
|
||||
const points = sampleEntityPoints(entity);
|
||||
if (points.length < 2) {
|
||||
toast.warn('치수를 넣을 수 없는 객체입니다.');
|
||||
return;
|
||||
}
|
||||
addEntities([makeDimension(points[0], points[points.length - 1], offset)], true);
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,209 @@
|
||||
/** 지시선·표·구름형·스타일 설정 (조사표 5절 지시선·표·표식·주석 축척) */
|
||||
import { Point } from '@flatten-js/core';
|
||||
import { toast } from 'react-toastify';
|
||||
import {
|
||||
getAnnotationScale,
|
||||
getDimTextHeight,
|
||||
getTableColumnWidth,
|
||||
getTableRowHeight,
|
||||
setAnnotationScale,
|
||||
setDimStyle,
|
||||
setTableStyle,
|
||||
} from '../../commands/dim-settings';
|
||||
import type { Entity } from '../../entities/Entity';
|
||||
import { EntityName } from '../../entities/Entity';
|
||||
import type { TextEntity } from '../../entities/TextEntity';
|
||||
import { revisionCloudPoints } from '../../helpers/geometry/shape-points';
|
||||
import { addEntities, getEntities, setEntities, setSelectedEntityIds } from '../../state';
|
||||
import { Tool } from '../../tools';
|
||||
import { lineEntity, polyLineEntity } from '../factories/entity-factory';
|
||||
import { createSequenceTool } from '../factories/sequence-tool';
|
||||
import { annotationText, arrowHead, worldFactor } from './annotation.helpers';
|
||||
|
||||
export const mleaderToolStateMachine = createSequenceTool({
|
||||
tool: Tool.MLEADER,
|
||||
steps: [
|
||||
{ kind: 'point', instructions: '지시선 화살표 위치를 지정하십시오.' },
|
||||
{ kind: 'point', instructions: '지시선 꺾임점을 지정하십시오.' },
|
||||
{ kind: 'text', instructions: '지시선 문자를 입력하십시오.' },
|
||||
],
|
||||
preview: (input) => {
|
||||
const points = input.points();
|
||||
if (points.length !== 1) return [];
|
||||
return [lineEntity(points[0], input.cursor), arrowHead(points[0], input.cursor)];
|
||||
},
|
||||
commit: (input) => {
|
||||
const [tip, knee] = input.points();
|
||||
const textHeight = getDimTextHeight() / worldFactor();
|
||||
// 꺾임점에서 문자 쪽으로 짧은 가로선을 하나 더 뽑는다 (AutoCAD 지시선 모양)
|
||||
const landingLength = textHeight * 2;
|
||||
const toRight = knee.x >= tip.x;
|
||||
const landingEnd = new Point(knee.x + (toRight ? landingLength : -landingLength), knee.y);
|
||||
const label = annotationText(input.text(2), new Point(landingEnd.x, landingEnd.y + textHeight * 0.4));
|
||||
const groupId = crypto.randomUUID();
|
||||
const parts: Entity[] = [
|
||||
lineEntity(tip, knee),
|
||||
lineEntity(knee, landingEnd),
|
||||
arrowHead(tip, knee),
|
||||
label,
|
||||
];
|
||||
for (const part of parts) part.groupId = groupId;
|
||||
addEntities(parts, true);
|
||||
},
|
||||
});
|
||||
|
||||
export const mleaderStyleToolStateMachine = createSequenceTool({
|
||||
tool: Tool.MLEADERSTYLE,
|
||||
helpers: false,
|
||||
steps: [
|
||||
{ kind: 'number', instructions: '지시선 문자 높이를 입력하십시오 <16>.', defaultValue: 16 },
|
||||
{ kind: 'number', instructions: '화살표 크기를 입력하십시오 <20>.', defaultValue: 20 },
|
||||
],
|
||||
commit: (input) => {
|
||||
setDimStyle(input.number(0), input.number(1), -1);
|
||||
toast.success(`지시선 스타일: 문자 ${input.number(0)} · 화살표 ${input.number(1)}`);
|
||||
},
|
||||
});
|
||||
|
||||
/** 지시선 정렬 — 선택한 지시선 문자의 X 위치를 첫 문자에 맞춘다 */
|
||||
export const mleaderAlignToolStateMachine = createSequenceTool({
|
||||
tool: Tool.MLEADERALIGN,
|
||||
helpers: false,
|
||||
steps: [{ kind: 'selection', instructions: '정렬할 지시선 문자를 선택한 뒤 ENTER.' }],
|
||||
commit: (input) => {
|
||||
const texts = input
|
||||
.entities(0)
|
||||
.filter((entity) => entity.getType() === EntityName.Text) as TextEntity[];
|
||||
if (texts.length < 2) {
|
||||
toast.warn('지시선 문자를 두 개 이상 선택하십시오.');
|
||||
return;
|
||||
}
|
||||
const targetX = texts[0].getBoundingBox().xmin;
|
||||
for (const text of texts.slice(1)) {
|
||||
text.move(targetX - text.getBoundingBox().xmin, 0);
|
||||
}
|
||||
setEntities([...getEntities()], true);
|
||||
setSelectedEntityIds([]);
|
||||
toast.success(`${texts.length}개 지시선 문자를 정렬했습니다.`);
|
||||
},
|
||||
});
|
||||
|
||||
export const dimStyleToolStateMachine = createSequenceTool({
|
||||
tool: Tool.DIMSTYLE,
|
||||
helpers: false,
|
||||
steps: [
|
||||
{ kind: 'number', instructions: '치수 문자 높이를 입력하십시오 <16>.', defaultValue: 16 },
|
||||
{ kind: 'number', instructions: '화살표 크기를 입력하십시오 <20>.', defaultValue: 20 },
|
||||
{ kind: 'number', instructions: '소수 자릿수를 입력하십시오 <2>.', defaultValue: 2 },
|
||||
],
|
||||
commit: (input) => {
|
||||
setDimStyle(input.number(0), input.number(1), input.number(2));
|
||||
setEntities([...getEntities()], false);
|
||||
toast.success('치수 스타일을 바꿨습니다.');
|
||||
},
|
||||
});
|
||||
|
||||
/** 치수 업데이트 — 스타일을 바꾼 뒤 화면을 다시 그린다 */
|
||||
export function updateDimensions(): string {
|
||||
setEntities([...getEntities()], false);
|
||||
toast.success('치수를 현재 스타일로 갱신했습니다.');
|
||||
return '치수 업데이트';
|
||||
}
|
||||
|
||||
export const annotationScaleToolStateMachine = createSequenceTool({
|
||||
tool: Tool.ANNOSCALE,
|
||||
helpers: false,
|
||||
steps: [{ kind: 'number', instructions: '주석 축척 배율을 입력하십시오 <1>.', defaultValue: 1 }],
|
||||
commit: (input) => {
|
||||
setAnnotationScale(input.number(0));
|
||||
setEntities([...getEntities()], false);
|
||||
toast.success(`주석 축척 ${getAnnotationScale()}배`);
|
||||
},
|
||||
});
|
||||
|
||||
export const tableStyleToolStateMachine = createSequenceTool({
|
||||
tool: Tool.TABLESTYLE,
|
||||
helpers: false,
|
||||
steps: [
|
||||
{ kind: 'number', instructions: '열 너비를 입력하십시오 <40>.', defaultValue: 40 },
|
||||
{ kind: 'number', instructions: '행 높이를 입력하십시오 <10>.', defaultValue: 10 },
|
||||
],
|
||||
commit: (input) => {
|
||||
setTableStyle(input.number(0), input.number(1));
|
||||
toast.success(`표 스타일: 열 ${input.number(0)} · 행 ${input.number(1)}`);
|
||||
},
|
||||
});
|
||||
|
||||
export const tableToolStateMachine = createSequenceTool({
|
||||
tool: Tool.TABLE,
|
||||
steps: [
|
||||
{ kind: 'number', instructions: '열 수를 입력하십시오 <3>.', defaultValue: 3 },
|
||||
{ kind: 'number', instructions: '행 수를 입력하십시오 <3>.', defaultValue: 3 },
|
||||
{ kind: 'point', instructions: '표의 좌측 상단 삽입점을 지정하십시오.' },
|
||||
],
|
||||
commit: (input) => {
|
||||
const columns = Math.max(1, Math.round(input.number(0)));
|
||||
const rows = Math.max(1, Math.round(input.number(1)));
|
||||
const origin = input.point(2);
|
||||
const width = getTableColumnWidth();
|
||||
const height = getTableRowHeight();
|
||||
const groupId = crypto.randomUUID();
|
||||
const parts: Entity[] = [];
|
||||
|
||||
for (let column = 0; column <= columns; column++) {
|
||||
parts.push(
|
||||
lineEntity(
|
||||
new Point(origin.x + column * width, origin.y),
|
||||
new Point(origin.x + column * width, origin.y - rows * height)
|
||||
)
|
||||
);
|
||||
}
|
||||
for (let row = 0; row <= rows; row++) {
|
||||
parts.push(
|
||||
lineEntity(
|
||||
new Point(origin.x, origin.y - row * height),
|
||||
new Point(origin.x + columns * width, origin.y - row * height)
|
||||
)
|
||||
);
|
||||
}
|
||||
for (const part of parts) part.groupId = groupId;
|
||||
addEntities(parts, true);
|
||||
toast.info('표를 만들었습니다. 칸 내용은 문자(TEXT) 명령으로 채우십시오.');
|
||||
},
|
||||
});
|
||||
|
||||
export const revCloudToolStateMachine = createSequenceTool({
|
||||
tool: Tool.REVCLOUD,
|
||||
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 points = input.points();
|
||||
if (points.length < 2) return;
|
||||
const closed = [...points, points[0].clone()];
|
||||
const box = closed.reduce(
|
||||
(size, point) => ({
|
||||
minX: Math.min(size.minX, point.x),
|
||||
minY: Math.min(size.minY, point.y),
|
||||
maxX: Math.max(size.maxX, point.x),
|
||||
maxY: Math.max(size.maxY, point.y),
|
||||
}),
|
||||
{
|
||||
minX: Number.POSITIVE_INFINITY,
|
||||
minY: Number.POSITIVE_INFINITY,
|
||||
maxX: Number.NEGATIVE_INFINITY,
|
||||
maxY: Number.NEGATIVE_INFINITY,
|
||||
}
|
||||
);
|
||||
// 스캘럽 크기는 구름 크기에 비례시켜 어떤 축척에서도 비슷하게 보이게 한다
|
||||
const arcRadius = Math.max(Math.hypot(box.maxX - box.minX, box.maxY - box.minY) / 40, 1e-6);
|
||||
const cloud = polyLineEntity(revisionCloudPoints(closed, arcRadius));
|
||||
if (cloud) addEntities([cloud], true);
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,103 @@
|
||||
/** 문자 명령 — 여러 줄 문자·단일 행 문자·편집·찾기 (조사표 5절 문자 패널) */
|
||||
import { Point } from '@flatten-js/core';
|
||||
import { toast } from 'react-toastify';
|
||||
import { getAnnotationScale } from '../../commands/dim-settings';
|
||||
import { EntityName } from '../../entities/Entity';
|
||||
import type { Entity } from '../../entities/Entity';
|
||||
import type { TextEntity } from '../../entities/TextEntity';
|
||||
import { addEntities, getActiveTextStyle, getEntities, setEntities } from '../../state';
|
||||
import { Tool } from '../../tools';
|
||||
import { textEntity } from '../factories/entity-factory';
|
||||
import { createSequenceTool } from '../factories/sequence-tool';
|
||||
|
||||
/** AutoCAD 여러 줄 문자의 줄바꿈 표기(\P)와 \n을 모두 받는다 */
|
||||
const splitLines = (text: string): string[] =>
|
||||
text
|
||||
.replace(/\\P/gi, '\n')
|
||||
.split('\n')
|
||||
.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;
|
||||
});
|
||||
}
|
||||
|
||||
export const textToolStateMachine = createSequenceTool({
|
||||
tool: Tool.TEXT,
|
||||
steps: [
|
||||
{ kind: 'point', instructions: '문자의 시작점을 지정하십시오.' },
|
||||
{ kind: 'text', instructions: '문자를 입력하십시오.' },
|
||||
],
|
||||
commit: (input) => {
|
||||
const lines = splitLines(input.text(1));
|
||||
if (!lines.length) return;
|
||||
addEntities(buildTextLines(lines.slice(0, 1), input.point(0)), true);
|
||||
},
|
||||
});
|
||||
|
||||
export const mtextToolStateMachine = createSequenceTool({
|
||||
tool: Tool.MTEXT,
|
||||
steps: [
|
||||
{ kind: 'point', instructions: '여러 줄 문자의 첫 코너를 지정하십시오.' },
|
||||
{ kind: 'text', instructions: '문자를 입력하십시오 (줄바꿈은 \\P).' },
|
||||
],
|
||||
commit: (input) => {
|
||||
const lines = splitLines(input.text(1));
|
||||
if (!lines.length) return;
|
||||
addEntities(buildTextLines(lines, input.point(0)), true);
|
||||
},
|
||||
});
|
||||
|
||||
export const textEditToolStateMachine = createSequenceTool({
|
||||
tool: Tool.TEXTEDIT,
|
||||
helpers: false,
|
||||
steps: [
|
||||
{ kind: 'entity', instructions: '편집할 문자를 선택하십시오.' },
|
||||
{ kind: 'text', instructions: '새 문자를 입력하십시오.' },
|
||||
],
|
||||
commit: (input) => {
|
||||
const entity = input.entity(0);
|
||||
if (entity.getType() !== EntityName.Text) {
|
||||
toast.warn('문자 객체를 선택하십시오.');
|
||||
return;
|
||||
}
|
||||
(entity as TextEntity).setLabel(input.text(1));
|
||||
setEntities([...getEntities()], true);
|
||||
},
|
||||
});
|
||||
|
||||
export const findToolStateMachine = createSequenceTool({
|
||||
tool: Tool.FIND,
|
||||
helpers: false,
|
||||
steps: [
|
||||
{ kind: 'text', instructions: '찾을 문자열을 입력하십시오.' },
|
||||
{ kind: 'text', instructions: '바꿀 문자열을 입력하십시오 (그대로 두려면 ENTER).', defaultValue: '' },
|
||||
],
|
||||
commit: (input) => {
|
||||
const needle = input.text(0);
|
||||
const replacement = input.text(1);
|
||||
let found = 0;
|
||||
let replaced = 0;
|
||||
for (const entity of getEntities()) {
|
||||
if (entity.getType() !== EntityName.Text) continue;
|
||||
const text = entity as TextEntity;
|
||||
if (!text.getLabel().includes(needle)) continue;
|
||||
found += 1;
|
||||
if (replacement) {
|
||||
text.setLabel(text.getLabel().split(needle).join(replacement));
|
||||
replaced += 1;
|
||||
}
|
||||
}
|
||||
if (replaced) setEntities([...getEntities()], true);
|
||||
toast.info(replaced ? `${replaced}개 문자를 바꿨습니다.` : `${found}개 문자를 찾았습니다.`);
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,149 @@
|
||||
/** 그리기 명령 — 폴리선·호·다각형·타원·스플라인·점 (조사표 1절) */
|
||||
import { CircleEntity } from '../../entities/CircleEntity';
|
||||
import type { Entity } from '../../entities/Entity';
|
||||
import {
|
||||
arcThroughThreePoints,
|
||||
ellipsePoints,
|
||||
regularPolygonPoints,
|
||||
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 { createSequenceTool } from '../factories/sequence-tool';
|
||||
|
||||
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 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);
|
||||
},
|
||||
});
|
||||
|
||||
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);
|
||||
},
|
||||
});
|
||||
|
||||
export const splineToolStateMachine = createSequenceTool({
|
||||
tool: Tool.SPLINE,
|
||||
steps: [
|
||||
{ kind: 'point', instructions: '스플라인의 첫 점을 지정하십시오.' },
|
||||
{ kind: 'point', instructions: '다음 점을 지정하십시오. ENTER로 종료합니다.' },
|
||||
],
|
||||
repeatLastStep: true,
|
||||
preview: (input) => {
|
||||
const preview = polyLineEntity(splinePoints([...input.points(), input.cursor]));
|
||||
return preview ? [preview] : [];
|
||||
},
|
||||
commit: (input) => {
|
||||
const spline = polyLineEntity(splinePoints(input.points()));
|
||||
if (spline) addEntities([spline], true);
|
||||
},
|
||||
});
|
||||
|
||||
export const pointToolStateMachine = createSequenceTool({
|
||||
tool: Tool.POINT,
|
||||
steps: [{ kind: 'point', instructions: '점의 위치를 지정하십시오.' }],
|
||||
preview: (input) => [pointEntity(input.cursor)],
|
||||
commit: (input) => {
|
||||
addEntities([pointEntity(input.point(0))], true);
|
||||
},
|
||||
});
|
||||
|
||||
/** 도넛 — 안쪽·바깥쪽 지름을 받아 동심원 두 개를 놓는다 */
|
||||
export const donutToolStateMachine = createSequenceTool({
|
||||
tool: Tool.DONUT,
|
||||
steps: [
|
||||
{ kind: 'number', instructions: '도넛의 내부 지름을 입력하십시오 <1>.', defaultValue: 1 },
|
||||
{ kind: 'number', instructions: '도넛의 외부 지름을 입력하십시오 <2>.', defaultValue: 2 },
|
||||
{ kind: 'point', instructions: '도넛의 중심을 지정하십시오.' },
|
||||
],
|
||||
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);
|
||||
},
|
||||
});
|
||||
|
||||
function donutEntities(innerDiameter: number, outerDiameter: number, center: Parameters<typeof pointEntity>[0]): Entity[] {
|
||||
const circles: Entity[] = [];
|
||||
for (const diameter of [innerDiameter, outerDiameter]) {
|
||||
if (diameter > 0) {
|
||||
circles.push(styled(new CircleEntity(getActiveLayerId(), center, diameter / 2)));
|
||||
}
|
||||
}
|
||||
return circles;
|
||||
}
|
||||
@@ -0,0 +1,142 @@
|
||||
/** 구성선·광선·다중선·와이프아웃 (조사표 1절 후반) */
|
||||
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 { 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';
|
||||
|
||||
/**
|
||||
* 구성선은 원래 무한하지만, 무한 선은 경계상자를 망가뜨려 [범위 줌]을 못 쓰게 만든다.
|
||||
* 현재 화면 대각선의 20배로 그어 화면 안에서는 무한선처럼 보이게 한다.
|
||||
*/
|
||||
function constructionLength(): number {
|
||||
const controller = getScreenCanvasDrawController();
|
||||
const size = controller.getCanvasSize();
|
||||
const scale = controller.getScreenScale() || 1;
|
||||
return (Math.hypot(size.x, size.y) / scale) * 20;
|
||||
}
|
||||
|
||||
function extendFrom(base: Point, through: Point, bothWays: boolean): [Point, Point] {
|
||||
const dx = through.x - base.x;
|
||||
const dy = through.y - base.y;
|
||||
const length = Math.hypot(dx, dy) || 1;
|
||||
const reach = constructionLength();
|
||||
const forward = new Point(base.x + (dx / length) * reach, base.y + (dy / length) * reach);
|
||||
const backward = bothWays
|
||||
? new Point(base.x - (dx / length) * reach, base.y - (dy / length) * reach)
|
||||
: base;
|
||||
return [backward, forward];
|
||||
}
|
||||
|
||||
export const xlineToolStateMachine = createSequenceTool({
|
||||
tool: Tool.XLINE,
|
||||
steps: [
|
||||
{ kind: 'point', instructions: '구성선이 지날 점을 지정하십시오.' },
|
||||
{ kind: 'point', instructions: '방향을 지정하십시오.' },
|
||||
],
|
||||
preview: (input) => {
|
||||
const points = input.points();
|
||||
if (points.length !== 1) return [];
|
||||
const [start, end] = extendFrom(points[0], input.cursor, true);
|
||||
return [lineEntity(start, end)];
|
||||
},
|
||||
commit: (input) => {
|
||||
const [base, through] = input.points();
|
||||
const [start, end] = extendFrom(base, through, true);
|
||||
addEntities([lineEntity(start, end)], true);
|
||||
},
|
||||
});
|
||||
|
||||
export const rayToolStateMachine = createSequenceTool({
|
||||
tool: Tool.RAY,
|
||||
steps: [
|
||||
{ kind: 'point', instructions: '광선의 시작점을 지정하십시오.' },
|
||||
{ kind: 'point', instructions: '통과점을 지정하십시오.' },
|
||||
],
|
||||
preview: (input) => {
|
||||
const points = input.points();
|
||||
if (points.length !== 1) return [];
|
||||
const [start, end] = extendFrom(points[0], input.cursor, false);
|
||||
return [lineEntity(start, end)];
|
||||
},
|
||||
commit: (input) => {
|
||||
const [base, through] = input.points();
|
||||
const [start, end] = extendFrom(base, through, false);
|
||||
addEntities([lineEntity(start, end)], true);
|
||||
},
|
||||
});
|
||||
|
||||
/** 다중선 — 중심선을 기준으로 MLSTYLE의 요소 수·간격만큼 평행선을 만든다 */
|
||||
function mlineEntities(points: Point[]): Entity[] {
|
||||
if (points.length < 2) return [];
|
||||
const elements = getMlineElements();
|
||||
const spacing = getMlineSpacing();
|
||||
const result: Entity[] = [];
|
||||
for (let index = 0; index < elements; index++) {
|
||||
const offset = (index - (elements - 1) / 2) * spacing;
|
||||
const line = polyLineEntity(offsetPolylinePoints(points, offset));
|
||||
if (line) result.push(line);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
export const mlineToolStateMachine = createSequenceTool({
|
||||
tool: Tool.MLINE,
|
||||
steps: [
|
||||
{ kind: 'point', instructions: '다중선의 시작점을 지정하십시오.' },
|
||||
{ kind: 'point', instructions: '다음 점을 지정하십시오. ENTER로 종료합니다.' },
|
||||
],
|
||||
repeatLastStep: true,
|
||||
preview: (input) => mlineEntities([...input.points(), input.cursor]),
|
||||
commit: (input) => {
|
||||
const entities = mlineEntities(input.points());
|
||||
if (entities.length) addEntities(entities, true);
|
||||
},
|
||||
});
|
||||
|
||||
export const mlstyleToolStateMachine = createSequenceTool({
|
||||
tool: Tool.MLSTYLE,
|
||||
helpers: false,
|
||||
steps: [
|
||||
{ kind: 'number', instructions: '다중선 요소 수를 입력하십시오 <2>.', defaultValue: 2 },
|
||||
{ kind: 'number', instructions: '요소 간격을 입력하십시오 <1>.', defaultValue: 1 },
|
||||
],
|
||||
commit: (input) => {
|
||||
setMlineStyle(input.number(0), input.number(1));
|
||||
toast.success(`다중선 스타일: ${getMlineElements()}줄, 간격 ${getMlineSpacing()}`);
|
||||
},
|
||||
});
|
||||
|
||||
/** 캔버스 배경색 — 와이프아웃은 이 색으로 뒤 객체를 가린다 */
|
||||
function canvasBackgroundColor(): string {
|
||||
const value = getComputedStyle(document.documentElement).getPropertyValue('--cad-canvas').trim();
|
||||
return value || '#1e1e1e';
|
||||
}
|
||||
|
||||
export const wipeoutToolStateMachine = createSequenceTool({
|
||||
tool: Tool.WIPEOUT,
|
||||
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 points = input.points();
|
||||
if (points.length < 3) return;
|
||||
const mask = hatchEntity([...points, points[0].clone()], {
|
||||
style: 'solid',
|
||||
color: canvasBackgroundColor(),
|
||||
});
|
||||
mask.lineColor = canvasBackgroundColor();
|
||||
// 가리개는 뒤 객체를 덮어야 하므로 가장 나중에 그려지도록 목록 끝에 넣는다
|
||||
addEntities([mask], true);
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,41 @@
|
||||
/** 등분(DIVIDE)·길이분할(MEASURE) — 객체를 자르지 않고 점만 놓는다 */
|
||||
import { toast } from 'react-toastify';
|
||||
import { dividePoints, measurePoints, sampleEntityPoints } from '../../helpers/geometry/sample-entity';
|
||||
import { addEntities } from '../../state';
|
||||
import { Tool } from '../../tools';
|
||||
import { pointEntity } from '../factories/entity-factory';
|
||||
import { createSequenceTool } from '../factories/sequence-tool';
|
||||
|
||||
export const divideToolStateMachine = createSequenceTool({
|
||||
tool: Tool.DIVIDE,
|
||||
helpers: false,
|
||||
steps: [
|
||||
{ kind: 'entity', instructions: '등분할 객체를 선택하십시오.' },
|
||||
{ kind: 'number', instructions: '세그먼트 수를 입력하십시오 <4>.', defaultValue: 4 },
|
||||
],
|
||||
commit: (input) => {
|
||||
const points = dividePoints(sampleEntityPoints(input.entity(0)), input.number(1));
|
||||
if (!points.length) {
|
||||
toast.warn('등분할 수 없는 객체입니다.');
|
||||
return;
|
||||
}
|
||||
addEntities(points.map(pointEntity), true);
|
||||
},
|
||||
});
|
||||
|
||||
export const measureLengthToolStateMachine = createSequenceTool({
|
||||
tool: Tool.MEASURE_LENGTH,
|
||||
helpers: false,
|
||||
steps: [
|
||||
{ kind: 'entity', instructions: '길이로 분할할 객체를 선택하십시오.' },
|
||||
{ kind: 'number', instructions: '세그먼트 길이를 입력하십시오 <10>.', defaultValue: 10 },
|
||||
],
|
||||
commit: (input) => {
|
||||
const points = measurePoints(sampleEntityPoints(input.entity(0)), input.number(1));
|
||||
if (!points.length) {
|
||||
toast.warn('지정한 길이로 나눌 수 없습니다.');
|
||||
return;
|
||||
}
|
||||
addEntities(points.map(pointEntity), true);
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,113 @@
|
||||
/** 해치·그라데이션·경계·영역 — 선택한 객체가 이루는 닫힌 경계를 채우거나 뽑는다 */
|
||||
import type { Point } from '@flatten-js/core';
|
||||
import { toast } from 'react-toastify';
|
||||
import {
|
||||
autoHatchSpacing,
|
||||
getHatchAngle,
|
||||
getHatchSpacing,
|
||||
getHatchStyle,
|
||||
} from '../../commands/draw-settings';
|
||||
import { entitiesToLoop } from '../../helpers/geometry/entity-loop';
|
||||
import { addEntities, getActiveLineColor, setSelectedEntityIds } from '../../state';
|
||||
import { Tool } from '../../tools';
|
||||
import { hatchEntity, polyLineEntity } from '../factories/entity-factory';
|
||||
import { createSequenceTool, type SequenceInput } from '../factories/sequence-tool';
|
||||
|
||||
/** 선택 객체에서 닫힌 경계를 얻는다. 못 얻으면 안내 후 빈 배열 */
|
||||
function loopFromSelection(input: SequenceInput): Point[] {
|
||||
const loop = entitiesToLoop(input.entities(0));
|
||||
if (loop.length < 3) {
|
||||
toast.warn('닫힌 경계를 만들 객체를 선택하십시오.');
|
||||
return [];
|
||||
}
|
||||
return loop;
|
||||
}
|
||||
|
||||
function boundingSize(points: Point[]): { width: number; height: number } {
|
||||
const xs = points.map((point) => point.x);
|
||||
const ys = points.map((point) => point.y);
|
||||
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를 누르십시오.' },
|
||||
],
|
||||
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([]);
|
||||
},
|
||||
});
|
||||
|
||||
export const gradientToolStateMachine = createSequenceTool({
|
||||
tool: Tool.GRADIENT,
|
||||
helpers: false,
|
||||
steps: [
|
||||
{ kind: 'selection', instructions: '그라데이션을 넣을 경계 객체를 선택한 뒤 ENTER.' },
|
||||
],
|
||||
commit: (input) => {
|
||||
const loop = loopFromSelection(input);
|
||||
if (!loop.length) return;
|
||||
addEntities(
|
||||
[
|
||||
hatchEntity(loop, {
|
||||
style: 'gradient',
|
||||
color: getActiveLineColor(),
|
||||
color2: '#000000',
|
||||
}),
|
||||
],
|
||||
true
|
||||
);
|
||||
setSelectedEntityIds([]);
|
||||
},
|
||||
});
|
||||
|
||||
export const boundaryToolStateMachine = createSequenceTool({
|
||||
tool: Tool.BOUNDARY,
|
||||
helpers: false,
|
||||
steps: [
|
||||
{ kind: 'selection', instructions: '경계를 뽑을 객체를 선택한 뒤 ENTER를 누르십시오.' },
|
||||
],
|
||||
commit: (input) => {
|
||||
const loop = loopFromSelection(input);
|
||||
if (!loop.length) return;
|
||||
const boundary = polyLineEntity(loop);
|
||||
if (boundary) {
|
||||
addEntities([boundary], true);
|
||||
toast.success('닫힌 폴리선 경계를 만들었습니다.');
|
||||
}
|
||||
setSelectedEntityIds([]);
|
||||
},
|
||||
});
|
||||
|
||||
export const regionToolStateMachine = createSequenceTool({
|
||||
tool: Tool.REGION,
|
||||
helpers: false,
|
||||
steps: [{ kind: 'selection', instructions: '영역으로 만들 객체를 선택한 뒤 ENTER.' }],
|
||||
commit: (input) => {
|
||||
const loop = loopFromSelection(input);
|
||||
if (!loop.length) return;
|
||||
const region = polyLineEntity(loop);
|
||||
if (region) {
|
||||
addEntities([region], true);
|
||||
// 2D 웹 CAD에는 별도 영역 객체가 없다 — 닫힌 폴리선이 같은 역할을 한다
|
||||
toast.info('영역은 닫힌 폴리선으로 작성했습니다.');
|
||||
}
|
||||
setSelectedEntityIds([]);
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,64 @@
|
||||
/** 현재 도면층·선 특성을 입혀 엔티티를 만드는 공통 생성기. */
|
||||
import type { Point } from '@flatten-js/core';
|
||||
import { ArcEntity } from '../../entities/ArcEntity';
|
||||
import type { Entity } from '../../entities/Entity';
|
||||
import { HatchEntity, type HatchOptions } from '../../entities/HatchEntity';
|
||||
import { LineEntity } from '../../entities/LineEntity';
|
||||
import { PointEntity } from '../../entities/PointEntity';
|
||||
import { PolyLineEntity } from '../../entities/PolyLineEntity';
|
||||
import { TextEntity, type TextOptions } from '../../entities/TextEntity';
|
||||
import type { ArcDefinition } from '../../helpers/geometry/shape-points';
|
||||
import {
|
||||
getActiveLayerId,
|
||||
getActiveLineColor,
|
||||
getActiveLineDash,
|
||||
getActiveLineWidth,
|
||||
getActiveTextStyle,
|
||||
} from '../../state';
|
||||
|
||||
/** 현재 리본에서 고른 색·굵기·선종류를 엔티티에 입힌다 */
|
||||
export function styled<T extends Entity>(entity: T): T {
|
||||
entity.lineColor = getActiveLineColor();
|
||||
entity.lineWidth = getActiveLineWidth();
|
||||
entity.lineDash = getActiveLineDash();
|
||||
return entity;
|
||||
}
|
||||
|
||||
export const lineEntity = (start: Point, end: Point): LineEntity =>
|
||||
styled(new LineEntity(getActiveLayerId(), start, end));
|
||||
|
||||
export const pointEntity = (point: Point): PointEntity =>
|
||||
styled(new PointEntity(getActiveLayerId(), point));
|
||||
|
||||
export const arcEntity = (arc: ArcDefinition): ArcEntity =>
|
||||
styled(
|
||||
new ArcEntity(
|
||||
getActiveLayerId(),
|
||||
arc.center,
|
||||
arc.radius,
|
||||
arc.startAngle,
|
||||
arc.endAngle,
|
||||
arc.counterClockwise
|
||||
)
|
||||
);
|
||||
|
||||
/** 점렬을 하나의 폴리선 객체로 만든다 (2점 미만이면 null) */
|
||||
export function polyLineEntity(points: Point[]): PolyLineEntity | null {
|
||||
if (points.length < 2) return null;
|
||||
const segments: Entity[] = [];
|
||||
for (let index = 1; index < points.length; index++) {
|
||||
segments.push(lineEntity(points[index - 1], points[index]));
|
||||
}
|
||||
return styled(new PolyLineEntity(getActiveLayerId(), segments));
|
||||
}
|
||||
|
||||
export const hatchEntity = (points: Point[], options?: Partial<HatchOptions>): HatchEntity =>
|
||||
styled(new HatchEntity(getActiveLayerId(), points, options));
|
||||
|
||||
export const textEntity = (label: string, basePoint: Point, options?: Partial<TextOptions>) =>
|
||||
styled(
|
||||
new TextEntity(getActiveLayerId(), label, basePoint, {
|
||||
...getActiveTextStyle(),
|
||||
...options,
|
||||
})
|
||||
);
|
||||
@@ -0,0 +1,349 @@
|
||||
/**
|
||||
* 시퀀스 도구 공장 — AutoCAD 명령의 공통 골격(단계별 입력 → 미리보기 → 확정)을
|
||||
* 선언만으로 만든다.
|
||||
*
|
||||
* 명령마다 xstate 머신을 손으로 쓰면 2점 그리기에도 170줄이 든다. 여기서는
|
||||
* `steps`(점·숫자·문자·객체 선택)를 나열하고 `commit`만 채우면 같은 머신이 나온다.
|
||||
*/
|
||||
import type { Point } from '@flatten-js/core';
|
||||
import { Actor, assign, createMachine, sendTo } from 'xstate';
|
||||
import { HIGHLIGHT_ENTITY_DISTANCE } from '../../App.consts';
|
||||
import type { Entity } from '../../entities/Entity';
|
||||
import { findClosestEntity } from '../../helpers/find-closest-entity';
|
||||
import { getPointFromEvent } from '../../helpers/get-point-from-event';
|
||||
import { queryEntitiesNearPoint } from '../../helpers/spatial-index';
|
||||
import {
|
||||
getScreenCanvasDrawController,
|
||||
getSelectedEntities,
|
||||
setActiveToolActor,
|
||||
setAngleGuideOriginPoint,
|
||||
setGhostHelperEntities,
|
||||
setShouldDrawHelpers,
|
||||
} from '../../state';
|
||||
import type { Tool } from '../../tools';
|
||||
import { selectToolStateMachine } from '../select-tool';
|
||||
import type {
|
||||
DrawEvent,
|
||||
MouseClickEvent,
|
||||
NumberInputEvent,
|
||||
PointInputEvent,
|
||||
StateEvent,
|
||||
TextInputEvent,
|
||||
ToolContext,
|
||||
} from '../tool.types';
|
||||
|
||||
export type SequenceValue = Point | number | string | Entity | Entity[];
|
||||
|
||||
export type SequenceStepKind = 'point' | 'number' | 'text' | 'entity' | 'selection';
|
||||
|
||||
export interface SequenceStep {
|
||||
kind: SequenceStepKind;
|
||||
/** 명령행·커서 옆에 뜨는 안내문 */
|
||||
instructions: string;
|
||||
/** number·text 단계에서 ENTER만 눌렀을 때 채택할 값 */
|
||||
defaultValue?: number | string;
|
||||
}
|
||||
|
||||
/** commit·preview에 넘어가는 입력 묶음. 단계 순서 그대로 인덱스로 읽는다. */
|
||||
export interface SequenceInput {
|
||||
values: SequenceValue[];
|
||||
/** 커서(미리보기) 또는 마지막 확정 점 */
|
||||
cursor: Point;
|
||||
point(index: number): Point;
|
||||
/** 모든 점 값 (가변 점 명령에서 사용) */
|
||||
points(): Point[];
|
||||
number(index: number): number;
|
||||
text(index: number): string;
|
||||
entity(index: number): Entity;
|
||||
entities(index: number): Entity[];
|
||||
/** 객체 선택 단계에서 실제로 클릭한 위치 (어느 쪽을 집었는지 필요한 명령용) */
|
||||
pick(index: number): Point;
|
||||
}
|
||||
|
||||
export interface SequenceToolConfig {
|
||||
tool: Tool;
|
||||
steps: SequenceStep[];
|
||||
/** 마지막 점 단계를 ENTER 전까지 반복해 점을 모은다 (폴리선·스플라인) */
|
||||
repeatLastStep?: boolean;
|
||||
/** 커서를 따라 그려줄 임시 엔티티 */
|
||||
preview?: (input: SequenceInput) => Entity[];
|
||||
/** 확정 — 엔티티 추가·상태 변경을 직접 수행한다 */
|
||||
commit: (input: SequenceInput) => void;
|
||||
/**
|
||||
* 확정 뒤 같은 명령을 처음부터 다시 시작할지.
|
||||
* 기본값은 "점만 찍는 그리기 명령이면 계속, 숫자·문자·객체 선택이 섞이면 종료".
|
||||
* 종료하면 선택 도구로 돌아가 명령행 입력이 다음 명령으로 해석된다 (AutoCAD와 같다).
|
||||
*/
|
||||
restart?: boolean;
|
||||
/** 확정 뒤 마지막 점을 첫 값으로 이어받아 계속 그린다 (선) */
|
||||
chainFromLastPoint?: boolean;
|
||||
/** 점 입력 중 스냅·각도 가이드 사용 여부 (기본 true) */
|
||||
helpers?: boolean;
|
||||
}
|
||||
|
||||
export interface SequenceContext extends ToolContext {
|
||||
values: SequenceValue[];
|
||||
/** values와 같은 자리에 놓이는 클릭 위치 (객체를 집은 지점) */
|
||||
picks: (Point | null)[];
|
||||
}
|
||||
|
||||
const STEP_STATE = (index: number) => `STEP_${index}`;
|
||||
const COMMIT_STATE = 'COMMIT';
|
||||
const INIT_STATE = 'INIT';
|
||||
|
||||
function makeInput(
|
||||
values: SequenceValue[],
|
||||
cursor: Point,
|
||||
picks: (Point | null)[] = []
|
||||
): SequenceInput {
|
||||
/** 단계 인덱스로 값을 꺼낸다. 종류가 다르면 어느 단계가 잘못됐는지 바로 알린다. */
|
||||
const expect = <T>(index: number, kind: string, ok: (value: SequenceValue) => boolean): T => {
|
||||
const value = values[index];
|
||||
if (!ok(value)) {
|
||||
throw new Error(`[sequence-tool] ${index}번 단계 값이 ${kind}이(가) 아닙니다`);
|
||||
}
|
||||
return value as T;
|
||||
};
|
||||
|
||||
return {
|
||||
values,
|
||||
cursor,
|
||||
pick: (index: number) => (picks[index] ?? cursor) as Point,
|
||||
point: (index: number) => expect<Point>(index, '점', isPointValue),
|
||||
points: () => values.filter((value) => isPointValue(value)) as Point[],
|
||||
number: (index: number) => expect<number>(index, '숫자', (v) => typeof v === 'number'),
|
||||
text: (index: number) => expect<string>(index, '문자', (v) => typeof v === 'string'),
|
||||
entity: (index: number) =>
|
||||
expect<Entity>(index, '객체', (v) => !!v && typeof (v as Entity).getType === 'function'),
|
||||
entities: (index: number) => expect<Entity[]>(index, '선택 목록', Array.isArray),
|
||||
};
|
||||
}
|
||||
|
||||
/** flatten-js Point 판별 — instanceof는 번들 중복 시 실패할 수 있어 형태로 본다. */
|
||||
function isPointValue(value: SequenceValue): boolean {
|
||||
return (
|
||||
typeof value === 'object' &&
|
||||
value !== null &&
|
||||
typeof (value as Point).x === 'number' &&
|
||||
typeof (value as Point).y === 'number'
|
||||
);
|
||||
}
|
||||
|
||||
/** 마지막으로 찍은 점 — 상대 좌표·거리 입력의 기준점이 된다. */
|
||||
function lastPoint(values: SequenceValue[]): Point | null {
|
||||
for (let index = values.length - 1; index >= 0; index--) {
|
||||
if (isPointValue(values[index])) return values[index] as Point;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function cursorPoint(event: StateEvent | undefined): Point {
|
||||
if (event && (event as DrawEvent).drawController) {
|
||||
return (event as DrawEvent).drawController.getWorldMouseLocation();
|
||||
}
|
||||
return getScreenCanvasDrawController().getWorldMouseLocation();
|
||||
}
|
||||
|
||||
/** 클릭 지점에서 가장 가까운 엔티티 (선택 반경 안에 있을 때만) */
|
||||
function pickEntityAt(worldPoint: Point): Entity | null {
|
||||
const scale = getScreenCanvasDrawController().getScreenScale() || 1;
|
||||
const radius = HIGHLIGHT_ENTITY_DISTANCE / scale;
|
||||
const candidates = queryEntitiesNearPoint(worldPoint.x, worldPoint.y, radius);
|
||||
const { distance, entity } = findClosestEntity(worldPoint, candidates);
|
||||
return entity && distance <= radius ? entity : null;
|
||||
}
|
||||
|
||||
export function createSequenceTool(config: SequenceToolConfig) {
|
||||
const { steps, tool } = config;
|
||||
const useHelpers = config.helpers !== false;
|
||||
const lastIndex = steps.length - 1;
|
||||
|
||||
const nextTarget = (index: number): string => {
|
||||
if (config.repeatLastStep && index === lastIndex) return STEP_STATE(index);
|
||||
return index < lastIndex ? STEP_STATE(index + 1) : COMMIT_STATE;
|
||||
};
|
||||
|
||||
const resetTool = () => {
|
||||
setShouldDrawHelpers(useHelpers);
|
||||
setGhostHelperEntities([]);
|
||||
setAngleGuideOriginPoint(null);
|
||||
};
|
||||
|
||||
const drawPreview = ({ context, event }: { context: SequenceContext; event: StateEvent }) => {
|
||||
if (!config.preview) return;
|
||||
const ghosts = config.preview(makeInput(context.values, cursorPoint(event), context.picks));
|
||||
setGhostHelperEntities(ghosts);
|
||||
};
|
||||
|
||||
const pushPoint = assign(({ context, event }: { context: SequenceContext; event: StateEvent }) => {
|
||||
const point = getPointFromEvent(lastPoint(context.values), event as PointInputEvent);
|
||||
setAngleGuideOriginPoint(point);
|
||||
return { values: [...context.values, point], picks: [...context.picks, point] };
|
||||
});
|
||||
|
||||
const pushNumberFromEvent = assign(
|
||||
({ context, event }: { context: SequenceContext; event: StateEvent }) => {
|
||||
const value =
|
||||
event.type === 'NUMBER_INPUT'
|
||||
? (event as NumberInputEvent).value
|
||||
: Number.parseFloat((event as TextInputEvent).value);
|
||||
return { values: [...context.values, value], picks: [...context.picks, null] };
|
||||
}
|
||||
);
|
||||
|
||||
const pushDefault = (index: number) =>
|
||||
assign(({ context }: { context: SequenceContext }) => ({
|
||||
values: [...context.values, steps[index].defaultValue as SequenceValue],
|
||||
picks: [...context.picks, null],
|
||||
}));
|
||||
|
||||
const pushText = assign(({ context, event }: { context: SequenceContext; event: StateEvent }) => ({
|
||||
values: [...context.values, (event as TextInputEvent).value],
|
||||
picks: [...context.picks, null],
|
||||
}));
|
||||
|
||||
const pushEntity = assign(
|
||||
({ context, event }: { context: SequenceContext; event: StateEvent }) => {
|
||||
const location = (event as MouseClickEvent).worldMouseLocation;
|
||||
const picked = pickEntityAt(location);
|
||||
return picked
|
||||
? { values: [...context.values, picked], picks: [...context.picks, location] }
|
||||
: { values: context.values, picks: context.picks };
|
||||
}
|
||||
);
|
||||
|
||||
const pushSelection = assign(({ context }: { context: SequenceContext }) => ({
|
||||
values: [...context.values, getSelectedEntities()],
|
||||
picks: [...context.picks, null],
|
||||
}));
|
||||
|
||||
// 점만 받는 그리기 명령은 계속 그리게 두고, 그 밖의 명령은 끝나면 선택 도구로 돌아간다
|
||||
const keepRunning =
|
||||
config.restart ?? (steps.every((step) => step.kind === 'point') || !!config.chainFromLastPoint);
|
||||
|
||||
const commit = assign(({ context }: { context: SequenceContext }) => {
|
||||
const points = context.values.filter(isPointValue) as Point[];
|
||||
const cursor = points.length ? points[points.length - 1] : cursorPoint(undefined);
|
||||
config.commit(makeInput(context.values, cursor, context.picks));
|
||||
setGhostHelperEntities([]);
|
||||
if (!keepRunning) {
|
||||
// 전이 도중에 액터를 갈아치우지 않도록 다음 틱에 도구를 바꾼다
|
||||
setTimeout(() => setActiveToolActor(new Actor(selectToolStateMachine)), 0);
|
||||
}
|
||||
if (config.chainFromLastPoint && points.length) {
|
||||
const tail = points[points.length - 1];
|
||||
setAngleGuideOriginPoint(tail);
|
||||
return { values: [tail] as SequenceValue[], picks: [tail] as (Point | null)[] };
|
||||
}
|
||||
return { values: [] as SequenceValue[], picks: [] as (Point | null)[] };
|
||||
});
|
||||
|
||||
const cancel = assign(({ context }: { context: SequenceContext }) => {
|
||||
setGhostHelperEntities([]);
|
||||
setAngleGuideOriginPoint(null);
|
||||
if (context.values.length === 0) {
|
||||
// 입력이 하나도 없는 상태의 ESC → 선택 도구로 빠져나간다 (AutoCAD와 동일)
|
||||
setActiveToolActor(new Actor(selectToolStateMachine));
|
||||
}
|
||||
return { values: [] as SequenceValue[], picks: [] as (Point | null)[] };
|
||||
});
|
||||
|
||||
// biome-ignore lint/suspicious/noExplicitAny: xstate 상태 구성은 동적으로 만든다
|
||||
const states: Record<string, any> = {
|
||||
[INIT_STATE]: {
|
||||
always: { actions: resetTool, target: STEP_STATE(0) },
|
||||
},
|
||||
[COMMIT_STATE]: {
|
||||
always: {
|
||||
actions: commit,
|
||||
target: config.chainFromLastPoint ? STEP_STATE(1) : INIT_STATE,
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
steps.forEach((step, index) => {
|
||||
const target = nextTarget(index);
|
||||
const escTransition = { actions: cancel, target: INIT_STATE };
|
||||
// biome-ignore lint/suspicious/noExplicitAny: 이벤트 맵도 단계 종류마다 달라진다
|
||||
const on: Record<string, any> = { ESC: escTransition };
|
||||
|
||||
if (step.kind === 'point') {
|
||||
on.DRAW = { actions: drawPreview };
|
||||
on.MOUSE_CLICK = { actions: pushPoint, target };
|
||||
on.ABSOLUTE_POINT_INPUT = { actions: pushPoint, target };
|
||||
on.RELATIVE_POINT_INPUT = { actions: pushPoint, target };
|
||||
on.NUMBER_INPUT = { actions: pushPoint, target };
|
||||
if (config.repeatLastStep && index === lastIndex) {
|
||||
on.ENTER = { target: COMMIT_STATE };
|
||||
}
|
||||
} else if (step.kind === 'number') {
|
||||
on.DRAW = { actions: drawPreview };
|
||||
on.NUMBER_INPUT = { actions: pushNumberFromEvent, target };
|
||||
on.TEXT_INPUT = {
|
||||
guard: ({ event }: { event: StateEvent }) =>
|
||||
Number.isFinite(Number.parseFloat((event as TextInputEvent).value)),
|
||||
actions: pushNumberFromEvent,
|
||||
target,
|
||||
};
|
||||
if (step.defaultValue !== undefined) {
|
||||
on.ENTER = { actions: pushDefault(index), target };
|
||||
}
|
||||
} else if (step.kind === 'text') {
|
||||
on.TEXT_INPUT = { actions: pushText, target };
|
||||
on.NUMBER_INPUT = { actions: pushNumberFromEvent, target };
|
||||
if (step.defaultValue !== undefined) {
|
||||
on.ENTER = { actions: pushDefault(index), target };
|
||||
}
|
||||
} else if (step.kind === 'entity') {
|
||||
on.DRAW = { actions: drawPreview };
|
||||
on.MOUSE_CLICK = {
|
||||
guard: ({ event }: { event: StateEvent }) =>
|
||||
!!pickEntityAt((event as MouseClickEvent).worldMouseLocation),
|
||||
actions: pushEntity,
|
||||
target,
|
||||
};
|
||||
}
|
||||
|
||||
if (step.kind === 'selection') {
|
||||
const actorId = `selectInside_${index}`;
|
||||
states[STEP_STATE(index)] = {
|
||||
meta: { instructions: step.instructions },
|
||||
invoke: {
|
||||
id: actorId,
|
||||
src: selectToolStateMachine,
|
||||
onDone: { actions: pushSelection, target },
|
||||
},
|
||||
on: {
|
||||
MOUSE_CLICK: { actions: sendTo(actorId, ({ event }) => event) },
|
||||
ENTER: { actions: sendTo(actorId, ({ event }) => event) },
|
||||
DRAW: { actions: sendTo(actorId, ({ event }) => event) },
|
||||
ESC: escTransition,
|
||||
},
|
||||
};
|
||||
return;
|
||||
}
|
||||
|
||||
states[STEP_STATE(index)] = {
|
||||
meta: { instructions: step.instructions },
|
||||
entry: () => {
|
||||
// 객체 선택 단계가 아니면 스냅·각도 가이드를 켠다
|
||||
setShouldDrawHelpers(useHelpers && step.kind === 'point');
|
||||
},
|
||||
on,
|
||||
};
|
||||
});
|
||||
|
||||
return createMachine(
|
||||
{
|
||||
types: {} as { context: SequenceContext; events: StateEvent },
|
||||
context: { values: [] as SequenceValue[], picks: [] as (Point | null)[], type: tool },
|
||||
initial: INIT_STATE,
|
||||
states,
|
||||
},
|
||||
{
|
||||
// 하위에서 돌리는 선택 도구의 액션 구현을 함께 넘긴다
|
||||
// biome-ignore lint/suspicious/noExplicitAny: 자식 머신의 컨텍스트 타입이 달라 그대로 넘긴다
|
||||
actions: { ...(selectToolStateMachine.implementations.actions as any) },
|
||||
}
|
||||
);
|
||||
}
|
||||
@@ -1,221 +0,0 @@
|
||||
import { type Point, Vector } from '@flatten-js/core';
|
||||
import { MeasurementEntity } from '../entities/MeasurementEntity';
|
||||
import {
|
||||
addEntities,
|
||||
getActiveLayerId,
|
||||
getActiveLineColor,
|
||||
getActiveLineDash,
|
||||
getActiveLineWidth,
|
||||
setAngleGuideOriginPoint,
|
||||
setGhostHelperEntities,
|
||||
setSelectedEntityIds,
|
||||
setShouldDrawHelpers,
|
||||
} from '../state';
|
||||
import { Tool } from '../tools';
|
||||
import { assign, createMachine } from 'xstate';
|
||||
import type { DrawEvent, PointInputEvent, StateEvent, ToolContext } from './tool.types';
|
||||
import { MEASUREMENT_DEFAULT_OFFSET, TO_RADIANS } from '../App.consts';
|
||||
import { getPointFromEvent } from '../helpers/get-point-from-event.ts';
|
||||
import { isPointEqual } from '../helpers/is-point-equal.ts';
|
||||
|
||||
export interface MeasurementContext extends ToolContext {
|
||||
startPoint: Point | null;
|
||||
endPoint: Point | null;
|
||||
}
|
||||
|
||||
export enum MeasurementState {
|
||||
INIT = 'INIT',
|
||||
WAITING_FOR_START_POINT = 'WAITING_FOR_START_POINT',
|
||||
WAITING_FOR_END_POINT = 'WAITING_FOR_END_POINT',
|
||||
WAITING_FOR_OFFSET = 'WAITING_FOR_OFFSET',
|
||||
}
|
||||
|
||||
export enum MeasurementAction {
|
||||
INIT_MEASUREMENT_TOOL = 'INIT_MEASUREMENT_TOOL',
|
||||
RECORD_START_POINT = 'RECORD_START_POINT',
|
||||
RECORD_END_POINT = 'RECORD_END_POINT',
|
||||
DRAW_TEMP_MEASUREMENT = 'DRAW_TEMP_MEASUREMENT',
|
||||
DRAW_FINAL_MEASUREMENT = 'DRAW_FINAL_MEASUREMENT',
|
||||
}
|
||||
|
||||
export const measurementToolStateMachine = createMachine(
|
||||
{
|
||||
types: {} as {
|
||||
context: MeasurementContext;
|
||||
events: StateEvent;
|
||||
},
|
||||
context: {
|
||||
startPoint: null,
|
||||
endPoint: null,
|
||||
type: Tool.MEASUREMENT,
|
||||
},
|
||||
initial: MeasurementState.INIT,
|
||||
states: {
|
||||
[MeasurementState.INIT]: {
|
||||
description: 'Initializing the line tool',
|
||||
always: {
|
||||
actions: MeasurementAction.INIT_MEASUREMENT_TOOL,
|
||||
target: MeasurementState.WAITING_FOR_START_POINT,
|
||||
},
|
||||
},
|
||||
[MeasurementState.WAITING_FOR_START_POINT]: {
|
||||
description: 'Select the start point of the measurement',
|
||||
meta: {
|
||||
instructions: 'Select the start point of the measurement',
|
||||
},
|
||||
on: {
|
||||
MOUSE_CLICK: {
|
||||
actions: MeasurementAction.RECORD_START_POINT,
|
||||
target: MeasurementState.WAITING_FOR_END_POINT,
|
||||
},
|
||||
ABSOLUTE_POINT_INPUT: {
|
||||
actions: MeasurementAction.RECORD_START_POINT,
|
||||
target: MeasurementState.WAITING_FOR_END_POINT,
|
||||
},
|
||||
},
|
||||
},
|
||||
[MeasurementState.WAITING_FOR_END_POINT]: {
|
||||
description: 'Select the end point of the measurement',
|
||||
meta: {
|
||||
instructions: 'Select the end point of the measurement',
|
||||
},
|
||||
on: {
|
||||
DRAW: {
|
||||
actions: MeasurementAction.DRAW_TEMP_MEASUREMENT,
|
||||
},
|
||||
MOUSE_CLICK: {
|
||||
actions: MeasurementAction.RECORD_END_POINT,
|
||||
target: MeasurementState.WAITING_FOR_OFFSET,
|
||||
},
|
||||
NUMBER_INPUT: {
|
||||
actions: MeasurementAction.RECORD_END_POINT,
|
||||
target: MeasurementState.WAITING_FOR_OFFSET,
|
||||
},
|
||||
ABSOLUTE_POINT_INPUT: {
|
||||
actions: MeasurementAction.RECORD_END_POINT,
|
||||
target: MeasurementState.WAITING_FOR_OFFSET,
|
||||
},
|
||||
RELATIVE_POINT_INPUT: {
|
||||
actions: MeasurementAction.RECORD_END_POINT,
|
||||
target: MeasurementState.WAITING_FOR_OFFSET,
|
||||
},
|
||||
ESC: {
|
||||
target: MeasurementState.INIT,
|
||||
},
|
||||
},
|
||||
},
|
||||
[MeasurementState.WAITING_FOR_OFFSET]: {
|
||||
description: 'Select the offset to display the measurement at',
|
||||
meta: {
|
||||
instructions: 'Select the offset to display the measurement at',
|
||||
},
|
||||
on: {
|
||||
DRAW: {
|
||||
actions: MeasurementAction.DRAW_TEMP_MEASUREMENT,
|
||||
},
|
||||
MOUSE_CLICK: {
|
||||
actions: MeasurementAction.DRAW_FINAL_MEASUREMENT,
|
||||
target: MeasurementState.INIT,
|
||||
},
|
||||
NUMBER_INPUT: {
|
||||
actions: MeasurementAction.DRAW_FINAL_MEASUREMENT,
|
||||
target: MeasurementState.INIT,
|
||||
},
|
||||
ABSOLUTE_POINT_INPUT: {
|
||||
actions: MeasurementAction.DRAW_FINAL_MEASUREMENT,
|
||||
target: MeasurementState.INIT,
|
||||
},
|
||||
RELATIVE_POINT_INPUT: {
|
||||
actions: MeasurementAction.DRAW_FINAL_MEASUREMENT,
|
||||
target: MeasurementState.INIT,
|
||||
},
|
||||
ESC: {
|
||||
target: MeasurementState.INIT,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
actions: {
|
||||
[MeasurementAction.INIT_MEASUREMENT_TOOL]: assign(() => {
|
||||
setShouldDrawHelpers(true);
|
||||
setSelectedEntityIds([]);
|
||||
setGhostHelperEntities([]);
|
||||
setAngleGuideOriginPoint(null);
|
||||
return {
|
||||
startPoint: null,
|
||||
endPoint: null,
|
||||
};
|
||||
}),
|
||||
[MeasurementAction.RECORD_START_POINT]: assign(({ event }) => {
|
||||
const startPoint = getPointFromEvent(null, event as PointInputEvent);
|
||||
setAngleGuideOriginPoint(startPoint);
|
||||
return {
|
||||
startPoint,
|
||||
};
|
||||
}),
|
||||
[MeasurementAction.RECORD_END_POINT]: assign(({ context, event }) => {
|
||||
const endPoint = getPointFromEvent(context.startPoint, event as PointInputEvent);
|
||||
setAngleGuideOriginPoint(endPoint);
|
||||
return {
|
||||
...context,
|
||||
endPoint,
|
||||
};
|
||||
}),
|
||||
[MeasurementAction.DRAW_TEMP_MEASUREMENT]: ({ context, event }) => {
|
||||
const startPoint = context.startPoint as Point;
|
||||
|
||||
let endPoint: Point;
|
||||
let offsetPoint: Point;
|
||||
if (!context.endPoint) {
|
||||
// User has drawn startPoint, but not yet endPoint
|
||||
// Endpoint should be the mouse location and offset should be MEASUREMENT_DEFAULT_OFFSET to either direction
|
||||
endPoint = (event as DrawEvent).drawController.getWorldMouseLocation();
|
||||
|
||||
if (isPointEqual(startPoint, endPoint)) {
|
||||
return; // Cannot draw temp measurement when start and endpoint are equal
|
||||
}
|
||||
|
||||
const normalVector = new Vector(startPoint, endPoint)
|
||||
.rotate(-90 * TO_RADIANS)
|
||||
.normalize();
|
||||
// Pixel constant → world units so the default offset is zoom-independent
|
||||
const worldFactor = (event as DrawEvent).drawController.getScreenScale() || 1;
|
||||
offsetPoint = startPoint
|
||||
.clone()
|
||||
.translate(normalVector.multiply(MEASUREMENT_DEFAULT_OFFSET / worldFactor));
|
||||
} else {
|
||||
// User has already selected a startPoint and endPoint
|
||||
// The offsetPoint should be set to the mouse location
|
||||
endPoint = context.endPoint as Point;
|
||||
offsetPoint = (event as DrawEvent).drawController.getWorldMouseLocation();
|
||||
}
|
||||
|
||||
const activeMeasurement = new MeasurementEntity(
|
||||
getActiveLayerId(),
|
||||
context.startPoint as Point,
|
||||
endPoint,
|
||||
offsetPoint
|
||||
);
|
||||
activeMeasurement.lineColor = getActiveLineColor();
|
||||
activeMeasurement.lineWidth = getActiveLineWidth();
|
||||
activeMeasurement.lineDash = getActiveLineDash();
|
||||
setGhostHelperEntities([activeMeasurement]);
|
||||
},
|
||||
[MeasurementAction.DRAW_FINAL_MEASUREMENT]: ({ context, event }) => {
|
||||
const offsetPoint = getPointFromEvent(context.endPoint, event as PointInputEvent);
|
||||
const activeMeasurement = new MeasurementEntity(
|
||||
getActiveLayerId(),
|
||||
context.startPoint as Point,
|
||||
context.endPoint as Point,
|
||||
offsetPoint
|
||||
);
|
||||
activeMeasurement.lineColor = getActiveLineColor();
|
||||
activeMeasurement.lineWidth = getActiveLineWidth();
|
||||
activeMeasurement.lineDash = getActiveLineDash();
|
||||
addEntities([activeMeasurement], true);
|
||||
},
|
||||
},
|
||||
}
|
||||
);
|
||||
@@ -0,0 +1,113 @@
|
||||
/** 모깎기·모따기·곡선 혼합·끊기 (조사표 2절) */
|
||||
import { toast } from 'react-toastify';
|
||||
import type { Entity } from '../../entities/Entity';
|
||||
import { addEntities, deleteEntities } from '../../state';
|
||||
import { Tool } from '../../tools';
|
||||
import { createSequenceTool } from '../factories/sequence-tool';
|
||||
import { blendEntities, breakEntity, chamferLines, filletLines } from './corner.helpers';
|
||||
import type { CornerResult } from './corner.helpers';
|
||||
|
||||
function applyCorner(first: Entity, second: Entity, result: CornerResult | null): void {
|
||||
if (!result) {
|
||||
toast.warn('두 직선을 선택해야 합니다. 두 선이 평행하면 처리할 수 없습니다.');
|
||||
return;
|
||||
}
|
||||
deleteEntities([first, second], false);
|
||||
addEntities([...result.trimmed, ...(result.corner ? [result.corner] : [])], true);
|
||||
}
|
||||
|
||||
export const filletToolStateMachine = createSequenceTool({
|
||||
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))
|
||||
);
|
||||
},
|
||||
});
|
||||
|
||||
export const chamferToolStateMachine = createSequenceTool({
|
||||
tool: Tool.CHAMFER,
|
||||
helpers: false,
|
||||
steps: [
|
||||
{ kind: 'number', instructions: '첫 번째 모따기 거리를 입력하십시오 <1>.', defaultValue: 1 },
|
||||
{ kind: 'number', instructions: '두 번째 모따기 거리를 입력하십시오 <1>.', defaultValue: 1 },
|
||||
{ kind: 'entity', instructions: '첫 번째 객체를 남길 쪽에서 선택하십시오.' },
|
||||
{ kind: 'entity', instructions: '두 번째 객체를 남길 쪽에서 선택하십시오.' },
|
||||
],
|
||||
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)
|
||||
)
|
||||
);
|
||||
},
|
||||
});
|
||||
|
||||
export const blendToolStateMachine = createSequenceTool({
|
||||
tool: Tool.BLEND,
|
||||
helpers: false,
|
||||
steps: [
|
||||
{ kind: 'entity', instructions: '혼합할 첫 번째 곡선을 선택하십시오.' },
|
||||
{ kind: 'entity', instructions: '혼합할 두 번째 곡선을 선택하십시오.' },
|
||||
],
|
||||
commit: (input) => {
|
||||
const blend = blendEntities(input.entity(0), input.entity(1));
|
||||
if (!blend) {
|
||||
toast.warn('두 객체를 이을 수 없습니다.');
|
||||
return;
|
||||
}
|
||||
addEntities([blend], true);
|
||||
},
|
||||
});
|
||||
|
||||
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);
|
||||
},
|
||||
});
|
||||
|
||||
export const breakAtPointToolStateMachine = createSequenceTool({
|
||||
tool: Tool.BREAK_AT_POINT,
|
||||
steps: [
|
||||
{ kind: 'entity', instructions: '나눌 객체를 선택하십시오.' },
|
||||
{ kind: 'point', instructions: '나눌 점을 지정하십시오.' },
|
||||
],
|
||||
commit: (input) => {
|
||||
const entity = input.entity(0);
|
||||
const pieces = breakEntity(entity, input.point(1));
|
||||
if (pieces.length < 2) {
|
||||
toast.warn('이 위치에서는 나눌 수 없습니다.');
|
||||
return;
|
||||
}
|
||||
deleteEntities([entity], false);
|
||||
addEntities(pieces, true);
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,268 @@
|
||||
/** 모깎기·모따기·곡선 혼합·끊기의 기하 계산과 객체 생성 */
|
||||
import { Point, Segment } from '@flatten-js/core';
|
||||
import { ArcEntity } from '../../entities/ArcEntity';
|
||||
import type { Entity } from '../../entities/Entity';
|
||||
import { LineEntity } from '../../entities/LineEntity';
|
||||
import { PolyLineEntity } from '../../entities/PolyLineEntity';
|
||||
import {
|
||||
pointAtDistance,
|
||||
polylineLength,
|
||||
sampleEntityPoints,
|
||||
} from '../../helpers/geometry/sample-entity';
|
||||
import { intersectLines, splinePoints } from '../../helpers/geometry/shape-points';
|
||||
import { copyStyle } from './modify.helpers';
|
||||
|
||||
interface LinePick {
|
||||
entity: Entity;
|
||||
segment: Segment;
|
||||
pick: Point;
|
||||
}
|
||||
|
||||
const unit = (from: Point, to: Point): { x: number; y: number } => {
|
||||
const dx = to.x - from.x;
|
||||
const dy = to.y - from.y;
|
||||
const length = Math.hypot(dx, dy) || 1;
|
||||
return { x: dx / length, y: dy / length };
|
||||
};
|
||||
|
||||
const makeLine = (source: Entity, start: Point, end: Point): LineEntity =>
|
||||
copyStyle(source, new LineEntity(source.layerId, start, end));
|
||||
|
||||
/** 선을 집은 쪽에서 살아남는 끝점 (교차점 반대편 끝) */
|
||||
function keepEndpoint(segment: Segment, corner: Point, pick: Point): Point {
|
||||
const towardPick = unit(corner, pick);
|
||||
const towardStart = unit(corner, segment.start);
|
||||
const startAligned = towardPick.x * towardStart.x + towardPick.y * towardStart.y;
|
||||
return startAligned > 0 ? segment.start : segment.end;
|
||||
}
|
||||
|
||||
function asLinePick(entity: Entity, pick: Point): LinePick | null {
|
||||
const shape = entity.getShape();
|
||||
return shape instanceof Segment ? { entity, segment: shape, pick } : null;
|
||||
}
|
||||
|
||||
export interface CornerResult {
|
||||
/** 잘려서 새로 만들어진 두 선 */
|
||||
trimmed: Entity[];
|
||||
/** 모서리에 새로 놓이는 객체 (호 또는 선) */
|
||||
corner: Entity | null;
|
||||
}
|
||||
|
||||
/** FILLET — 두 선을 반지름 radius의 호로 잇는다 */
|
||||
export function filletLines(
|
||||
first: Entity,
|
||||
firstPick: Point,
|
||||
second: Entity,
|
||||
secondPick: Point,
|
||||
radius: number
|
||||
): CornerResult | null {
|
||||
const a = asLinePick(first, firstPick);
|
||||
const b = asLinePick(second, secondPick);
|
||||
if (!a || !b) return null;
|
||||
|
||||
const corner = intersectLines(a.segment.start, a.segment.end, b.segment.start, b.segment.end);
|
||||
if (!corner) return null;
|
||||
|
||||
const keepA = keepEndpoint(a.segment, corner, a.pick);
|
||||
const keepB = keepEndpoint(b.segment, corner, b.pick);
|
||||
const ua = unit(corner, keepA);
|
||||
const ub = unit(corner, keepB);
|
||||
|
||||
const angle = Math.acos(Math.min(1, Math.max(-1, ua.x * ub.x + ua.y * ub.y)));
|
||||
if (!Number.isFinite(angle) || angle < 1e-6 || Math.abs(angle - Math.PI) < 1e-6) return null;
|
||||
|
||||
if (radius <= 0) {
|
||||
// 반지름 0 = 두 선을 모서리에서 딱 맞춘다
|
||||
return {
|
||||
trimmed: [makeLine(a.entity, keepA, corner), makeLine(b.entity, keepB, corner)],
|
||||
corner: null,
|
||||
};
|
||||
}
|
||||
|
||||
const tangentDistance = radius / Math.tan(angle / 2);
|
||||
const tangentA = new Point(
|
||||
corner.x + ua.x * tangentDistance,
|
||||
corner.y + ua.y * tangentDistance
|
||||
);
|
||||
const tangentB = new Point(
|
||||
corner.x + ub.x * tangentDistance,
|
||||
corner.y + ub.y * tangentDistance
|
||||
);
|
||||
|
||||
const bisector = unit(new Point(0, 0), new Point(ua.x + ub.x, ua.y + ub.y));
|
||||
const centerDistance = radius / Math.sin(angle / 2);
|
||||
const center = new Point(
|
||||
corner.x + bisector.x * centerDistance,
|
||||
corner.y + bisector.y * centerDistance
|
||||
);
|
||||
|
||||
const startAngle = Math.atan2(tangentA.y - center.y, tangentA.x - center.x);
|
||||
const endAngle = Math.atan2(tangentB.y - center.y, tangentB.x - center.x);
|
||||
const cross =
|
||||
(tangentA.x - center.x) * (tangentB.y - center.y) -
|
||||
(tangentA.y - center.y) * (tangentB.x - center.x);
|
||||
|
||||
const arc = copyStyle(
|
||||
a.entity,
|
||||
new ArcEntity(a.entity.layerId, center, radius, startAngle, endAngle, cross > 0)
|
||||
);
|
||||
|
||||
return {
|
||||
trimmed: [makeLine(a.entity, keepA, tangentA), makeLine(b.entity, keepB, tangentB)],
|
||||
corner: arc,
|
||||
};
|
||||
}
|
||||
|
||||
/** CHAMFER — 두 선을 직선 모따기로 잇는다 */
|
||||
export function chamferLines(
|
||||
first: Entity,
|
||||
firstPick: Point,
|
||||
second: Entity,
|
||||
secondPick: Point,
|
||||
firstDistance: number,
|
||||
secondDistance: number
|
||||
): CornerResult | null {
|
||||
const a = asLinePick(first, firstPick);
|
||||
const b = asLinePick(second, secondPick);
|
||||
if (!a || !b) return null;
|
||||
|
||||
const corner = intersectLines(a.segment.start, a.segment.end, b.segment.start, b.segment.end);
|
||||
if (!corner) return null;
|
||||
|
||||
const keepA = keepEndpoint(a.segment, corner, a.pick);
|
||||
const keepB = keepEndpoint(b.segment, corner, b.pick);
|
||||
const ua = unit(corner, keepA);
|
||||
const ub = unit(corner, keepB);
|
||||
|
||||
const cutA = new Point(corner.x + ua.x * firstDistance, corner.y + ua.y * firstDistance);
|
||||
const cutB = new Point(corner.x + ub.x * secondDistance, corner.y + ub.y * secondDistance);
|
||||
|
||||
return {
|
||||
trimmed: [makeLine(a.entity, keepA, cutA), makeLine(b.entity, keepB, cutB)],
|
||||
corner: makeLine(a.entity, cutA, cutB),
|
||||
};
|
||||
}
|
||||
|
||||
/** BLEND — 두 객체의 가까운 끝점을 부드러운 곡선으로 잇는다 */
|
||||
export function blendEntities(first: Entity, second: Entity): Entity | null {
|
||||
const firstPoints = sampleEntityPoints(first);
|
||||
const secondPoints = sampleEntityPoints(second);
|
||||
if (firstPoints.length < 2 || secondPoints.length < 2) return null;
|
||||
|
||||
// 서로 가장 가까운 끝점 쌍을 고른다
|
||||
const candidates: [Point, Point, Point, Point][] = [
|
||||
[firstPoints[1], firstPoints[0], secondPoints[0], secondPoints[1]],
|
||||
[
|
||||
firstPoints[1],
|
||||
firstPoints[0],
|
||||
secondPoints[secondPoints.length - 1],
|
||||
secondPoints[secondPoints.length - 2],
|
||||
],
|
||||
[
|
||||
firstPoints[firstPoints.length - 2],
|
||||
firstPoints[firstPoints.length - 1],
|
||||
secondPoints[0],
|
||||
secondPoints[1],
|
||||
],
|
||||
[
|
||||
firstPoints[firstPoints.length - 2],
|
||||
firstPoints[firstPoints.length - 1],
|
||||
secondPoints[secondPoints.length - 1],
|
||||
secondPoints[secondPoints.length - 2],
|
||||
],
|
||||
];
|
||||
const best = candidates.reduce((chosen, candidate) =>
|
||||
candidate[1].distanceTo(candidate[2])[0] < chosen[1].distanceTo(chosen[2])[0]
|
||||
? candidate
|
||||
: chosen
|
||||
);
|
||||
|
||||
const curve = splinePoints([best[0], best[1], best[2], best[3]], 16);
|
||||
const segments: Entity[] = [];
|
||||
for (let index = 1; index < curve.length; index++) {
|
||||
segments.push(makeLine(first, curve[index - 1], curve[index]));
|
||||
}
|
||||
return copyStyle(first, new PolyLineEntity(first.layerId, segments));
|
||||
}
|
||||
|
||||
/**
|
||||
* BREAK — 두 점 사이를 지운다. 한 점만 주면 그 자리에서 둘로 나눈다.
|
||||
* 선·호는 원래 형상을 유지하고, 그 밖의 객체는 점렬로 나눈다.
|
||||
*/
|
||||
export function breakEntity(entity: Entity, first: Point, second?: Point): Entity[] {
|
||||
const cutPoints = second ? [first, second] : [first];
|
||||
|
||||
const cuttable = entity as Entity & { cutAtPoints?: (points: Point[]) => Entity[] };
|
||||
if (typeof cuttable.cutAtPoints === 'function') {
|
||||
const pieces = cuttable.cutAtPoints(cutPoints).map((piece) => copyStyle(entity, piece));
|
||||
if (!second) return pieces;
|
||||
const middle = new Point((first.x + second.x) / 2, (first.y + second.y) / 2);
|
||||
return pieces.filter((piece) => !piece.containsPointOnShape(middle));
|
||||
}
|
||||
|
||||
const points = sampleEntityPoints(entity);
|
||||
if (points.length < 2) return [entity];
|
||||
const total = polylineLength(points);
|
||||
const firstDistance = distanceAlong(points, first);
|
||||
const secondDistance = second === undefined ? firstDistance : distanceAlong(points, second);
|
||||
const [from, to] = [firstDistance, secondDistance].sort((a, b) => a - b);
|
||||
|
||||
const head = pointsUpTo(points, from);
|
||||
const tail = pointsFrom(points, to, total);
|
||||
const pieces: Entity[] = [];
|
||||
for (const piece of [head, tail]) {
|
||||
if (piece.length >= 2) {
|
||||
const segments: Entity[] = [];
|
||||
for (let index = 1; index < piece.length; index++) {
|
||||
segments.push(makeLine(entity, piece[index - 1], piece[index]));
|
||||
}
|
||||
pieces.push(copyStyle(entity, new PolyLineEntity(entity.layerId, segments)));
|
||||
}
|
||||
}
|
||||
return pieces;
|
||||
}
|
||||
|
||||
/** 점렬 시작점에서 target까지의 진행 거리 (가장 가까운 위치 기준) */
|
||||
function distanceAlong(points: Point[], target: Point): number {
|
||||
let bestDistance = 0;
|
||||
let bestGap = Number.POSITIVE_INFINITY;
|
||||
let travelled = 0;
|
||||
for (let index = 1; index < points.length; index++) {
|
||||
const segment = new Segment(points[index - 1], points[index]);
|
||||
const [gap, connector] = target.distanceTo(segment);
|
||||
if (gap < bestGap) {
|
||||
bestGap = gap;
|
||||
bestDistance = travelled + points[index - 1].distanceTo(connector.end)[0];
|
||||
}
|
||||
travelled += points[index - 1].distanceTo(points[index])[0];
|
||||
}
|
||||
return bestDistance;
|
||||
}
|
||||
|
||||
function pointsUpTo(points: Point[], distance: number): Point[] {
|
||||
const result: Point[] = [];
|
||||
let travelled = 0;
|
||||
result.push(points[0]);
|
||||
for (let index = 1; index < points.length; index++) {
|
||||
const step = points[index - 1].distanceTo(points[index])[0];
|
||||
if (travelled + step >= distance) break;
|
||||
travelled += step;
|
||||
result.push(points[index]);
|
||||
}
|
||||
const cut = pointAtDistance(points, distance);
|
||||
if (cut) result.push(cut);
|
||||
return result;
|
||||
}
|
||||
|
||||
function pointsFrom(points: Point[], distance: number, total: number): Point[] {
|
||||
if (distance >= total) return [];
|
||||
const result: Point[] = [];
|
||||
const cut = pointAtDistance(points, distance);
|
||||
if (cut) result.push(cut);
|
||||
let travelled = 0;
|
||||
for (let index = 1; index < points.length; index++) {
|
||||
travelled += points[index - 1].distanceTo(points[index])[0];
|
||||
if (travelled > distance) result.push(points[index]);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
@@ -0,0 +1,229 @@
|
||||
/**
|
||||
* 수정 명령의 객체 조작 — 간격띄우기·연장·길이조정·분해·결합·중복정리.
|
||||
* 기하 계산은 helpers/geometry의 순수 함수를 쓰고, 여기서는 엔티티로 바꾼다.
|
||||
*/
|
||||
import { Circle, Point, Segment } from '@flatten-js/core';
|
||||
import { ArcEntity } from '../../entities/ArcEntity';
|
||||
import { CircleEntity } from '../../entities/CircleEntity';
|
||||
import { type Entity, EntityName } from '../../entities/Entity';
|
||||
import type { HatchEntity } from '../../entities/HatchEntity';
|
||||
import { LineEntity } from '../../entities/LineEntity';
|
||||
import { PolyLineEntity } from '../../entities/PolyLineEntity';
|
||||
import type { RectangleEntity } from '../../entities/RectangleEntity';
|
||||
import { dedupeConsecutive, sampleEntityPoints } from '../../helpers/geometry/sample-entity';
|
||||
import { intersectLines, offsetPolylinePoints } from '../../helpers/geometry/shape-points';
|
||||
import { polygonToSegments } from '../../helpers/polygon-to-segments';
|
||||
import { getActiveLayerId } from '../../state';
|
||||
|
||||
/** 원본 객체의 표시 특성을 새 객체에 옮긴다 */
|
||||
export function copyStyle<T extends Entity>(source: Entity, target: T): T {
|
||||
target.lineColor = source.lineColor;
|
||||
target.lineWidth = source.lineWidth;
|
||||
target.lineDash = source.lineDash;
|
||||
target.layerId = source.layerId;
|
||||
return target;
|
||||
}
|
||||
|
||||
const makeLine = (source: Entity, start: Point, end: Point): LineEntity =>
|
||||
copyStyle(source, new LineEntity(source.layerId || getActiveLayerId(), start, end));
|
||||
|
||||
const makePolyLine = (source: Entity, points: Point[]): PolyLineEntity | null => {
|
||||
if (points.length < 2) return null;
|
||||
const segments: Entity[] = [];
|
||||
for (let index = 1; index < points.length; index++) {
|
||||
segments.push(makeLine(source, points[index - 1], points[index]));
|
||||
}
|
||||
return copyStyle(source, new PolyLineEntity(source.layerId || getActiveLayerId(), segments));
|
||||
};
|
||||
|
||||
/** 점이 선의 어느 쪽에 있는지 (+1 왼쪽, -1 오른쪽) */
|
||||
function sideOfLine(start: Point, end: Point, point: Point): number {
|
||||
const cross = (end.x - start.x) * (point.y - start.y) - (end.y - start.y) * (point.x - start.x);
|
||||
return cross >= 0 ? 1 : -1;
|
||||
}
|
||||
|
||||
/** OFFSET — 객체를 distance만큼 sidePoint 쪽으로 민 새 객체 */
|
||||
export function offsetEntity(entity: Entity, distance: number, sidePoint: Point): Entity | null {
|
||||
const shape = entity.getShape();
|
||||
|
||||
if (shape instanceof Segment) {
|
||||
const side = sideOfLine(shape.start, shape.end, sidePoint);
|
||||
const points = offsetPolylinePoints([shape.start, shape.end], distance * side);
|
||||
return makeLine(entity, points[0], points[points.length - 1]);
|
||||
}
|
||||
|
||||
if (shape instanceof Circle) {
|
||||
const outward = sidePoint.distanceTo(shape.center)[0] > Number(shape.r);
|
||||
const radius = Number(shape.r) + (outward ? distance : -distance);
|
||||
if (radius <= 0) return null;
|
||||
return copyStyle(entity, new CircleEntity(entity.layerId, shape.center, radius));
|
||||
}
|
||||
|
||||
if (entity.getType() === EntityName.Arc) {
|
||||
const arcShape = entity.getShape() as unknown as {
|
||||
center: Point;
|
||||
r: number;
|
||||
startAngle: number;
|
||||
endAngle: number;
|
||||
counterClockwise: boolean;
|
||||
};
|
||||
const outward = sidePoint.distanceTo(arcShape.center)[0] > Number(arcShape.r);
|
||||
const radius = Number(arcShape.r) + (outward ? distance : -distance);
|
||||
if (radius <= 0) return null;
|
||||
return copyStyle(
|
||||
entity,
|
||||
new ArcEntity(
|
||||
entity.layerId,
|
||||
arcShape.center,
|
||||
radius,
|
||||
arcShape.startAngle,
|
||||
arcShape.endAngle,
|
||||
arcShape.counterClockwise
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
// 폴리선·사각형·해치 등은 점렬을 밀어서 만든다
|
||||
const points = sampleEntityPoints(entity);
|
||||
if (points.length < 2) return null;
|
||||
const side = sideOfLine(points[0], points[1], sidePoint);
|
||||
return makePolyLine(entity, offsetPolylinePoints(points, distance * side));
|
||||
}
|
||||
|
||||
/** EXTEND — 대상 선을 경계 객체와 만나는 곳까지 늘린다 */
|
||||
export function extendLineToBoundary(target: Entity, boundary: Entity): Entity | null {
|
||||
const shape = target.getShape();
|
||||
if (!(shape instanceof Segment)) return null;
|
||||
|
||||
const boundaryPoints = sampleEntityPoints(boundary);
|
||||
if (boundaryPoints.length < 2) return null;
|
||||
|
||||
let best: { point: Point; distance: number; fromStart: boolean } | null = null;
|
||||
for (let index = 1; index < boundaryPoints.length; index++) {
|
||||
const crossing = intersectLines(
|
||||
shape.start,
|
||||
shape.end,
|
||||
boundaryPoints[index - 1],
|
||||
boundaryPoints[index]
|
||||
);
|
||||
if (!crossing) continue;
|
||||
// 교점이 경계 선분 안에 있어야 한다
|
||||
if (!isBetween(crossing, boundaryPoints[index - 1], boundaryPoints[index])) continue;
|
||||
|
||||
const fromEnd = shape.end.distanceTo(crossing)[0];
|
||||
const fromStart = shape.start.distanceTo(crossing)[0];
|
||||
const useStart = fromStart < fromEnd;
|
||||
const distance = Math.min(fromStart, fromEnd);
|
||||
if (!best || distance < best.distance) {
|
||||
best = { point: crossing, distance, fromStart: useStart };
|
||||
}
|
||||
}
|
||||
if (!best) return null;
|
||||
return best.fromStart
|
||||
? makeLine(target, best.point, shape.end)
|
||||
: makeLine(target, shape.start, best.point);
|
||||
}
|
||||
|
||||
/** 점이 두 점 사이 선분 위(오차 허용)에 있는가 */
|
||||
export function isBetween(point: Point, start: Point, end: Point, tolerance = 1e-6): boolean {
|
||||
const minX = Math.min(start.x, end.x) - tolerance;
|
||||
const maxX = Math.max(start.x, end.x) + tolerance;
|
||||
const minY = Math.min(start.y, end.y) - tolerance;
|
||||
const maxY = Math.max(start.y, end.y) + tolerance;
|
||||
return point.x >= minX && point.x <= maxX && point.y >= minY && point.y <= maxY;
|
||||
}
|
||||
|
||||
/** LENGTHEN — 선의 끝을 delta만큼 늘리거나(양수) 줄인다(음수) */
|
||||
export function lengthenLine(entity: Entity, delta: number, nearPoint: Point): Entity | null {
|
||||
const shape = entity.getShape();
|
||||
if (!(shape instanceof Segment)) return null;
|
||||
const atStart = shape.start.distanceTo(nearPoint)[0] < shape.end.distanceTo(nearPoint)[0];
|
||||
const length = shape.start.distanceTo(shape.end)[0] || 1;
|
||||
const dx = (shape.end.x - shape.start.x) / length;
|
||||
const dy = (shape.end.y - shape.start.y) / length;
|
||||
if (atStart) {
|
||||
return makeLine(
|
||||
entity,
|
||||
new Point(shape.start.x - dx * delta, shape.start.y - dy * delta),
|
||||
shape.end
|
||||
);
|
||||
}
|
||||
return makeLine(entity, shape.start, new Point(shape.end.x + dx * delta, shape.end.y + dy * delta));
|
||||
}
|
||||
|
||||
/** EXPLODE — 복합 객체를 구성요소로 나눈다. 나눌 게 없으면 빈 배열 */
|
||||
export function explodeEntity(entity: Entity): Entity[] {
|
||||
if (entity.getType() === EntityName.PolyLine) {
|
||||
return (entity as PolyLineEntity).getEntities().map((child) => copyStyle(entity, child));
|
||||
}
|
||||
if (entity.getType() === EntityName.Rectangle) {
|
||||
const polygon = (entity as RectangleEntity).getShape();
|
||||
if (!polygon) return [];
|
||||
return polygonToSegments(polygon as never).map((segment) =>
|
||||
makeLine(entity, segment.start, segment.end)
|
||||
);
|
||||
}
|
||||
if (entity.getType() === EntityName.Hatch) {
|
||||
const boundary = makePolyLine(entity, (entity as HatchEntity).getPoints());
|
||||
return boundary ? [boundary] : [];
|
||||
}
|
||||
return [];
|
||||
}
|
||||
|
||||
/** JOIN — 끝점이 맞닿는 객체들을 하나의 폴리선으로 잇는다 */
|
||||
export function joinEntities(entities: Entity[], tolerance = 1e-3): PolyLineEntity | null {
|
||||
const chains = entities
|
||||
.map((entity) => sampleEntityPoints(entity))
|
||||
.filter((points) => points.length >= 2);
|
||||
if (chains.length < 2) return null;
|
||||
|
||||
const near = (a: Point, b: Point) =>
|
||||
Math.abs(a.x - b.x) <= tolerance && Math.abs(a.y - b.y) <= tolerance;
|
||||
|
||||
const remaining = [...chains];
|
||||
let joined = remaining.shift() as Point[];
|
||||
let progress = true;
|
||||
while (remaining.length && progress) {
|
||||
progress = false;
|
||||
const head = joined[0];
|
||||
const tail = joined[joined.length - 1];
|
||||
for (let index = 0; index < remaining.length; index++) {
|
||||
const chain = remaining[index];
|
||||
const start = chain[0];
|
||||
const end = chain[chain.length - 1];
|
||||
if (near(start, tail)) joined = [...joined, ...chain.slice(1)];
|
||||
else if (near(end, tail)) joined = [...joined, ...[...chain].reverse().slice(1)];
|
||||
else if (near(end, head)) joined = [...chain.slice(0, -1), ...joined];
|
||||
else if (near(start, head)) joined = [...[...chain].reverse().slice(0, -1), ...joined];
|
||||
else continue;
|
||||
remaining.splice(index, 1);
|
||||
progress = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (remaining.length === chains.length - 1) return null; // 하나도 못 이었다
|
||||
return makePolyLine(entities[0], dedupeConsecutive(joined));
|
||||
}
|
||||
|
||||
/** REVERSE — 방향을 뒤집은 새 객체 */
|
||||
export function reverseEntity(entity: Entity): Entity | null {
|
||||
const shape = entity.getShape();
|
||||
if (shape instanceof Segment) return makeLine(entity, shape.end, shape.start);
|
||||
const points = sampleEntityPoints(entity);
|
||||
if (points.length < 2) return null;
|
||||
return makePolyLine(entity, [...points].reverse());
|
||||
}
|
||||
|
||||
/** OVERKILL — 형상이 같은 객체의 중복분을 골라낸다 */
|
||||
export function findDuplicateEntities(entities: Entity[], precision = 4): Entity[] {
|
||||
const seen = new Set<string>();
|
||||
const duplicates: Entity[] = [];
|
||||
for (const entity of entities) {
|
||||
const signature = `${entity.getType()}|${sampleEntityPoints(entity)
|
||||
.map((point) => `${point.x.toFixed(precision)},${point.y.toFixed(precision)}`)
|
||||
.join(';')}`;
|
||||
if (seen.has(signature)) duplicates.push(entity);
|
||||
else seen.add(signature);
|
||||
}
|
||||
return duplicates;
|
||||
}
|
||||
@@ -0,0 +1,154 @@
|
||||
/** 특성 일치·그리기 순서·ByLayer·방향 반전·중복 정리·해치 편집 (조사표 2절) */
|
||||
import { toast } from 'react-toastify';
|
||||
import type { Entity } from '../../entities/Entity';
|
||||
import { EntityName } from '../../entities/Entity';
|
||||
import type { HatchEntity, HatchStyle } from '../../entities/HatchEntity';
|
||||
import {
|
||||
addEntities,
|
||||
deleteEntities,
|
||||
getEntities,
|
||||
getLayerById,
|
||||
setEntities,
|
||||
setSelectedEntityIds,
|
||||
} from '../../state';
|
||||
import { Tool } from '../../tools';
|
||||
import { createSequenceTool } from '../factories/sequence-tool';
|
||||
import { findDuplicateEntities, reverseEntity } from './modify.helpers';
|
||||
|
||||
export const matchPropToolStateMachine = createSequenceTool({
|
||||
tool: Tool.MATCHPROP,
|
||||
helpers: false,
|
||||
steps: [
|
||||
{ kind: 'entity', instructions: '특성을 가져올 원본 객체를 선택하십시오.' },
|
||||
{ kind: 'entity', instructions: '특성을 적용할 대상 객체를 선택하십시오.' },
|
||||
],
|
||||
commit: (input) => {
|
||||
const source = input.entity(0);
|
||||
const target = input.entity(1);
|
||||
target.lineColor = source.lineColor;
|
||||
target.lineWidth = source.lineWidth;
|
||||
target.lineDash = source.lineDash;
|
||||
target.layerId = source.layerId;
|
||||
setEntities([...getEntities()], true);
|
||||
},
|
||||
});
|
||||
|
||||
/** 선택 객체를 목록 맨 앞(뒤)으로 옮겨 그리기 순서를 바꾼다 */
|
||||
function reorder(selected: Entity[], toFront: boolean): void {
|
||||
if (!selected.length) return;
|
||||
const ids = new Set(selected.map((entity) => entity.id));
|
||||
const rest = getEntities().filter((entity) => !ids.has(entity.id));
|
||||
setEntities(toFront ? [...rest, ...selected] : [...selected, ...rest], true);
|
||||
}
|
||||
|
||||
export const drawOrderFrontToolStateMachine = createSequenceTool({
|
||||
tool: Tool.DRAWORDER_FRONT,
|
||||
helpers: false,
|
||||
steps: [{ kind: 'selection', instructions: '맨 앞으로 보낼 객체를 선택한 뒤 ENTER.' }],
|
||||
commit: (input) => {
|
||||
reorder(input.entities(0), true);
|
||||
setSelectedEntityIds([]);
|
||||
},
|
||||
});
|
||||
|
||||
export const drawOrderBackToolStateMachine = createSequenceTool({
|
||||
tool: Tool.DRAWORDER_BACK,
|
||||
helpers: false,
|
||||
steps: [{ kind: 'selection', instructions: '맨 뒤로 보낼 객체를 선택한 뒤 ENTER.' }],
|
||||
commit: (input) => {
|
||||
reorder(input.entities(0), false);
|
||||
setSelectedEntityIds([]);
|
||||
},
|
||||
});
|
||||
|
||||
export const setByLayerToolStateMachine = createSequenceTool({
|
||||
tool: Tool.SETBYLAYER,
|
||||
helpers: false,
|
||||
steps: [{ kind: 'selection', instructions: 'ByLayer로 되돌릴 객체를 선택한 뒤 ENTER.' }],
|
||||
commit: (input) => {
|
||||
let applied = 0;
|
||||
for (const entity of input.entities(0)) {
|
||||
const layer = getLayerById(entity.layerId);
|
||||
if (!layer) continue;
|
||||
if (layer.color) entity.lineColor = layer.color;
|
||||
if (layer.lineWidth) entity.lineWidth = layer.lineWidth;
|
||||
entity.lineDash = layer.lineDash ? [...layer.lineDash] : undefined;
|
||||
applied += 1;
|
||||
}
|
||||
setEntities([...getEntities()], true);
|
||||
setSelectedEntityIds([]);
|
||||
toast.success(`${applied}개 객체를 도면층 특성으로 되돌렸습니다.`);
|
||||
},
|
||||
});
|
||||
|
||||
export const reverseToolStateMachine = createSequenceTool({
|
||||
tool: Tool.REVERSE,
|
||||
helpers: false,
|
||||
steps: [{ kind: 'selection', instructions: '방향을 뒤집을 객체를 선택한 뒤 ENTER.' }],
|
||||
commit: (input) => {
|
||||
const originals = input.entities(0);
|
||||
const reversed: Entity[] = [];
|
||||
const consumed: Entity[] = [];
|
||||
for (const entity of originals) {
|
||||
const flipped = reverseEntity(entity);
|
||||
if (flipped) {
|
||||
reversed.push(flipped);
|
||||
consumed.push(entity);
|
||||
}
|
||||
}
|
||||
if (!consumed.length) {
|
||||
toast.info('방향을 뒤집을 수 있는 객체가 없습니다.');
|
||||
return;
|
||||
}
|
||||
deleteEntities(consumed, false);
|
||||
addEntities(reversed, true);
|
||||
setSelectedEntityIds([]);
|
||||
},
|
||||
});
|
||||
|
||||
export const overkillToolStateMachine = createSequenceTool({
|
||||
tool: Tool.OVERKILL,
|
||||
helpers: false,
|
||||
steps: [{ kind: 'selection', instructions: '중복을 정리할 객체를 선택한 뒤 ENTER.' }],
|
||||
commit: (input) => {
|
||||
const selected = input.entities(0);
|
||||
const target = selected.length ? selected : getEntities();
|
||||
const duplicates = findDuplicateEntities(target);
|
||||
if (!duplicates.length) {
|
||||
toast.info('중복된 객체가 없습니다.');
|
||||
return;
|
||||
}
|
||||
deleteEntities(duplicates, true);
|
||||
setSelectedEntityIds([]);
|
||||
toast.success(`중복 객체 ${duplicates.length}개를 삭제했습니다.`);
|
||||
},
|
||||
});
|
||||
|
||||
const HATCH_STYLES: HatchStyle[] = ['solid', 'pattern', 'cross', 'gradient'];
|
||||
|
||||
export const hatchEditToolStateMachine = createSequenceTool({
|
||||
tool: Tool.HATCHEDIT,
|
||||
helpers: false,
|
||||
steps: [
|
||||
{ kind: 'entity', instructions: '편집할 해치를 선택하십시오.' },
|
||||
{
|
||||
kind: 'text',
|
||||
instructions: '패턴을 입력하십시오 (SOLID · PATTERN · CROSS · GRADIENT) <PATTERN>.',
|
||||
defaultValue: 'PATTERN',
|
||||
},
|
||||
],
|
||||
commit: (input) => {
|
||||
const entity = input.entity(0);
|
||||
if (entity.getType() !== EntityName.Hatch) {
|
||||
toast.warn('해치 객체를 선택하십시오.');
|
||||
return;
|
||||
}
|
||||
const requested = input.text(1).trim().toLowerCase() as HatchStyle;
|
||||
if (!HATCH_STYLES.includes(requested)) {
|
||||
toast.warn('SOLID · PATTERN · CROSS · GRADIENT 중 하나를 입력하십시오.');
|
||||
return;
|
||||
}
|
||||
(entity as HatchEntity).options.style = requested;
|
||||
setEntities([...getEntities()], true);
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,81 @@
|
||||
/** 연장·결합·분해·지우기 (조사표 2절) */
|
||||
import { toast } from 'react-toastify';
|
||||
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';
|
||||
|
||||
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);
|
||||
},
|
||||
});
|
||||
|
||||
export const joinToolStateMachine = createSequenceTool({
|
||||
tool: Tool.JOIN,
|
||||
helpers: false,
|
||||
steps: [{ kind: 'selection', instructions: '결합할 객체를 선택한 뒤 ENTER.' }],
|
||||
commit: (input) => {
|
||||
const entities = input.entities(0);
|
||||
const joined = joinEntities(entities);
|
||||
if (!joined) {
|
||||
toast.warn('끝점이 맞닿는 객체가 없어 결합하지 못했습니다.');
|
||||
return;
|
||||
}
|
||||
deleteEntities(entities, false);
|
||||
addEntities([joined], true);
|
||||
setSelectedEntityIds([]);
|
||||
},
|
||||
});
|
||||
|
||||
export const explodeToolStateMachine = createSequenceTool({
|
||||
tool: Tool.EXPLODE,
|
||||
helpers: false,
|
||||
steps: [{ kind: 'selection', instructions: '분해할 객체를 선택한 뒤 ENTER.' }],
|
||||
commit: (input) => {
|
||||
const entities = input.entities(0);
|
||||
const exploded: Entity[] = [];
|
||||
const consumed: Entity[] = [];
|
||||
for (const entity of entities) {
|
||||
const parts = explodeEntity(entity);
|
||||
if (parts.length) {
|
||||
exploded.push(...parts);
|
||||
consumed.push(entity);
|
||||
}
|
||||
}
|
||||
if (!consumed.length) {
|
||||
toast.info('분해할 수 있는 복합 객체가 없습니다.');
|
||||
return;
|
||||
}
|
||||
deleteEntities(consumed, false);
|
||||
addEntities(exploded, true);
|
||||
setSelectedEntityIds([]);
|
||||
},
|
||||
});
|
||||
|
||||
export const eraseToolStateMachine = createSequenceTool({
|
||||
tool: Tool.ERASE,
|
||||
helpers: false,
|
||||
steps: [{ kind: 'selection', instructions: '지울 객체를 선택한 뒤 ENTER.' }],
|
||||
commit: (input) => {
|
||||
const entities = input.entities(0);
|
||||
if (!entities.length) return;
|
||||
deleteEntities(entities, true);
|
||||
setSelectedEntityIds([]);
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,144 @@
|
||||
/** 대칭·정렬·간격띄우기·신축·길이조정 (조사표 2절) */
|
||||
import { Point } from '@flatten-js/core';
|
||||
import { toast } from 'react-toastify';
|
||||
import type { Entity } from '../../entities/Entity';
|
||||
import { LineEntity } from '../../entities/LineEntity';
|
||||
import { addEntities, deleteEntities, getActiveLayerId, setSelectedEntityIds } from '../../state';
|
||||
import { Tool } from '../../tools';
|
||||
import { createSequenceTool } from '../factories/sequence-tool';
|
||||
import { copyStyle, lengthenLine, offsetEntity } from './modify.helpers';
|
||||
|
||||
/** 원본 특성을 유지한 복사본 */
|
||||
const cloneStyled = (entity: Entity): Entity => copyStyle(entity, entity.clone());
|
||||
|
||||
export const mirrorToolStateMachine = createSequenceTool({
|
||||
tool: Tool.MIRROR,
|
||||
steps: [
|
||||
{ kind: 'selection', instructions: '대칭 복사할 객체를 선택한 뒤 ENTER.' },
|
||||
{ kind: 'point', instructions: '대칭축의 첫 점을 지정하십시오.' },
|
||||
{ kind: 'point', instructions: '대칭축의 두 번째 점을 지정하십시오.' },
|
||||
],
|
||||
preview: (input) => {
|
||||
const points = input.points();
|
||||
if (points.length !== 1) return [];
|
||||
return [new LineEntity(getActiveLayerId(), points[0], input.cursor)];
|
||||
},
|
||||
commit: (input) => {
|
||||
const [first, second] = input.points();
|
||||
const axis = new LineEntity(getActiveLayerId(), first, second);
|
||||
const mirrored = input.entities(0).map((entity) => {
|
||||
const copy = cloneStyled(entity);
|
||||
copy.mirror(axis);
|
||||
return copy;
|
||||
});
|
||||
if (mirrored.length) addEntities(mirrored, true);
|
||||
setSelectedEntityIds([]);
|
||||
},
|
||||
});
|
||||
|
||||
export const alignToolStateMachine = createSequenceTool({
|
||||
tool: Tool.ALIGN,
|
||||
steps: [
|
||||
{ kind: 'selection', instructions: '정렬할 객체를 선택한 뒤 ENTER.' },
|
||||
{ kind: 'point', instructions: '첫 번째 원본점을 지정하십시오.' },
|
||||
{ kind: 'point', instructions: '첫 번째 대상점을 지정하십시오.' },
|
||||
{ kind: 'point', instructions: '두 번째 원본점을 지정하십시오.' },
|
||||
{ kind: 'point', instructions: '두 번째 대상점을 지정하십시오.' },
|
||||
],
|
||||
commit: (input) => {
|
||||
const [source1, target1, source2, target2] = input.points();
|
||||
const sourceAngle = Math.atan2(source2.y - source1.y, source2.x - source1.x);
|
||||
const targetAngle = Math.atan2(target2.y - target1.y, target2.x - target1.x);
|
||||
const rotation = targetAngle - sourceAngle;
|
||||
|
||||
// 원본을 그대로 두고 사본을 변환해 교체한다 (실행취소가 원본을 되살릴 수 있어야 한다)
|
||||
const originals = input.entities(0);
|
||||
const aligned = originals.map((entity) => {
|
||||
const copy = cloneStyled(entity);
|
||||
copy.move(target1.x - source1.x, target1.y - source1.y);
|
||||
copy.rotate(target1, rotation);
|
||||
return copy;
|
||||
});
|
||||
if (originals.length) {
|
||||
deleteEntities(originals, false);
|
||||
addEntities(aligned, true);
|
||||
}
|
||||
setSelectedEntityIds([]);
|
||||
},
|
||||
});
|
||||
|
||||
export const offsetToolStateMachine = createSequenceTool({
|
||||
tool: Tool.OFFSET,
|
||||
steps: [
|
||||
{ kind: 'number', instructions: '간격띄우기 거리를 입력하십시오 <1>.', defaultValue: 1 },
|
||||
{ kind: 'entity', instructions: '간격띄우기할 객체를 선택하십시오.' },
|
||||
{ kind: 'point', instructions: '간격을 띄울 방향의 점을 지정하십시오.' },
|
||||
],
|
||||
commit: (input) => {
|
||||
const result = offsetEntity(input.entity(1), input.number(0), input.point(2));
|
||||
if (!result) {
|
||||
toast.warn('이 객체는 지정한 거리로 간격을 띄울 수 없습니다.');
|
||||
return;
|
||||
}
|
||||
addEntities([result], true);
|
||||
},
|
||||
});
|
||||
|
||||
export const stretchToolStateMachine = createSequenceTool({
|
||||
tool: Tool.STRETCH,
|
||||
steps: [
|
||||
{ kind: 'selection', instructions: '신축할 객체를 선택한 뒤 ENTER.' },
|
||||
{ kind: 'point', instructions: '기준점을 지정하십시오.' },
|
||||
{ kind: 'point', instructions: '이동할 위치를 지정하십시오.' },
|
||||
],
|
||||
commit: (input) => {
|
||||
const [base, destination] = input.points();
|
||||
const dx = destination.x - base.x;
|
||||
const dy = destination.y - base.y;
|
||||
const replaced: Entity[] = [];
|
||||
const removed: Entity[] = [];
|
||||
|
||||
for (const entity of input.entities(0)) {
|
||||
const shape = entity.getShape();
|
||||
// 선은 기준점에 가까운 끝점만 끌어당긴다. 그 밖의 객체는 통째로 옮긴다.
|
||||
if (shape && 'start' in shape && 'end' in shape) {
|
||||
const start = (shape as { start: Point }).start;
|
||||
const end = (shape as { end: Point }).end;
|
||||
const moveStart = start.distanceTo(base)[0] < end.distanceTo(base)[0];
|
||||
const newLine = new LineEntity(
|
||||
entity.layerId,
|
||||
moveStart ? new Point(start.x + dx, start.y + dy) : start,
|
||||
moveStart ? end : new Point(end.x + dx, end.y + dy)
|
||||
);
|
||||
replaced.push(copyStyle(entity, newLine));
|
||||
removed.push(entity);
|
||||
} else {
|
||||
const copy = cloneStyled(entity);
|
||||
copy.move(dx, dy);
|
||||
replaced.push(copy);
|
||||
removed.push(entity);
|
||||
}
|
||||
}
|
||||
if (removed.length) deleteEntities(removed, false);
|
||||
addEntities(replaced, true);
|
||||
setSelectedEntityIds([]);
|
||||
},
|
||||
});
|
||||
|
||||
export const lengthenToolStateMachine = createSequenceTool({
|
||||
tool: Tool.LENGTHEN,
|
||||
steps: [
|
||||
{ kind: 'entity', instructions: '길이를 바꿀 선을 늘릴 쪽 끝 근처에서 선택하십시오.' },
|
||||
{ kind: 'number', instructions: '증분 길이를 입력하십시오 (음수는 단축) <10>.', defaultValue: 10 },
|
||||
],
|
||||
commit: (input) => {
|
||||
const entity = input.entity(0);
|
||||
const changed = lengthenLine(entity, input.number(1), input.pick(0));
|
||||
if (!changed) {
|
||||
toast.warn('선만 길이를 조정할 수 있습니다.');
|
||||
return;
|
||||
}
|
||||
deleteEntities([entity], false);
|
||||
addEntities([changed], true);
|
||||
},
|
||||
});
|
||||
@@ -22,6 +22,7 @@ import {
|
||||
} from '../state';
|
||||
import type {SelectContext} from './select-tool';
|
||||
import type {MouseClickEvent} from './tool.types';
|
||||
import {expandSelectionWithGroups} from '../helpers/entity-groups';
|
||||
|
||||
export function handleFirstSelectionPoint(
|
||||
context: SelectContext,
|
||||
@@ -34,7 +35,8 @@ export function handleFirstSelectionPoint(
|
||||
// Select the entity close to the mouse
|
||||
const closestEntity = closestEntityInfo.entity;
|
||||
if (!event.holdingCtrl && !event.holdingShift) {
|
||||
setSelectedEntityIds([closestEntity.id]);
|
||||
// 그룹으로 묶인 객체는 하나만 집어도 함께 선택된다 (GROUP)
|
||||
setSelectedEntityIds(expandSelectionWithGroups([closestEntity.id]));
|
||||
} else if (event.holdingCtrl) {
|
||||
// ctrl => toggle selection
|
||||
if (isEntitySelected(closestEntity)) {
|
||||
@@ -42,11 +44,15 @@ export function handleFirstSelectionPoint(
|
||||
setSelectedEntityIds(getSelectedEntityIds().filter((id) => id !== closestEntity.id));
|
||||
} else {
|
||||
// Add the entity to the selection
|
||||
setSelectedEntityIds([...getSelectedEntityIds(), closestEntity.id]);
|
||||
setSelectedEntityIds(
|
||||
expandSelectionWithGroups([...getSelectedEntityIds(), closestEntity.id])
|
||||
);
|
||||
}
|
||||
} else {
|
||||
// shift => add to selection
|
||||
setSelectedEntityIds([...getSelectedEntityIds(), closestEntity.id]);
|
||||
setSelectedEntityIds(
|
||||
expandSelectionWithGroups([...getSelectedEntityIds(), closestEntity.id])
|
||||
);
|
||||
}
|
||||
return {
|
||||
...context,
|
||||
@@ -116,7 +122,7 @@ export function selectEntitiesInsideRectangle(
|
||||
return null;
|
||||
})
|
||||
);
|
||||
setSelectedEntityIds(newSelectedEntityIds);
|
||||
setSelectedEntityIds(expandSelectionWithGroups(newSelectedEntityIds));
|
||||
}
|
||||
|
||||
export function drawTempSelectionRectangle(startPoint: Point, endPoint: Point) {
|
||||
|
||||
@@ -1,75 +0,0 @@
|
||||
import type {StateMachine} from 'xstate'; /* eslint-disable @typescript-eslint/no-explicit-any */
|
||||
import {Tool} from '../tools';
|
||||
import {alignBottomToolStateMachine} from './align-bottom-tool.ts';
|
||||
import {alignCenterHorizontalToolStateMachine} from './align-center-horizontal-tool.ts';
|
||||
import {alignLeftToolStateMachine} from './align-left-tool.ts';
|
||||
import {alignCenterVerticalToolStateMachine} from './align-middle-vertical-tool.ts';
|
||||
import {alignRightToolStateMachine} from './align-right-tool.ts';
|
||||
import {alignTopToolStateMachine} from './align-top-tool.ts';
|
||||
import {arrayToolStateMachine} from './array-tool.ts';
|
||||
import {circleToolStateMachine} from './circle-tool';
|
||||
import {copyToolStateMachine} from './copy-tool.ts';
|
||||
import {eraserToolStateMachine} from './eraser-tool';
|
||||
import {imageImportToolStateMachine} from './image-import-tool';
|
||||
import {lineToolStateMachine} from './line-tool';
|
||||
import {measurementToolStateMachine} from './measurement-tool';
|
||||
import {moveToolStateMachine} from './move-tool';
|
||||
import {rectangleToolStateMachine} from './rectangle-tool';
|
||||
import {rotateToolStateMachine} from './rotate-tool';
|
||||
import {scaleToolStateMachine} from './scale-tool';
|
||||
import {selectToolStateMachine} from './select-tool';
|
||||
import {peditToolStateMachine} from "./pedit-tool.ts";
|
||||
|
||||
export const TOOL_STATE_MACHINES: Record<
|
||||
Partial<Tool>,
|
||||
StateMachine<
|
||||
// biome-ignore lint/suspicious/noExplicitAny: <explanation>
|
||||
any,
|
||||
// biome-ignore lint/suspicious/noExplicitAny: <explanation>
|
||||
any,
|
||||
// biome-ignore lint/suspicious/noExplicitAny: <explanation>
|
||||
any,
|
||||
// biome-ignore lint/suspicious/noExplicitAny: <explanation>
|
||||
any,
|
||||
// biome-ignore lint/suspicious/noExplicitAny: <explanation>
|
||||
any,
|
||||
// biome-ignore lint/suspicious/noExplicitAny: <explanation>
|
||||
any,
|
||||
// biome-ignore lint/suspicious/noExplicitAny: <explanation>
|
||||
any,
|
||||
// biome-ignore lint/suspicious/noExplicitAny: <explanation>
|
||||
any,
|
||||
// biome-ignore lint/suspicious/noExplicitAny: <explanation>
|
||||
any,
|
||||
// biome-ignore lint/suspicious/noExplicitAny: <explanation>
|
||||
any,
|
||||
// biome-ignore lint/suspicious/noExplicitAny: <explanation>
|
||||
any,
|
||||
// biome-ignore lint/suspicious/noExplicitAny: <explanation>
|
||||
any,
|
||||
// biome-ignore lint/suspicious/noExplicitAny: <explanation>
|
||||
any,
|
||||
// biome-ignore lint/suspicious/noExplicitAny: <explanation>
|
||||
any
|
||||
>
|
||||
> = {
|
||||
[Tool.LINE]: lineToolStateMachine,
|
||||
[Tool.RECTANGLE]: rectangleToolStateMachine,
|
||||
[Tool.CIRCLE]: circleToolStateMachine,
|
||||
[Tool.SELECT]: selectToolStateMachine,
|
||||
[Tool.ERASER]: eraserToolStateMachine,
|
||||
[Tool.MOVE]: moveToolStateMachine,
|
||||
[Tool.COPY]: copyToolStateMachine,
|
||||
[Tool.SCALE]: scaleToolStateMachine,
|
||||
[Tool.ROTATE]: rotateToolStateMachine,
|
||||
[Tool.IMAGE_IMPORT]: imageImportToolStateMachine,
|
||||
[Tool.MEASUREMENT]: measurementToolStateMachine,
|
||||
[Tool.ALIGN_LEFT]: alignLeftToolStateMachine,
|
||||
[Tool.ALIGN_CENTER_HORIZONTAL]: alignCenterHorizontalToolStateMachine,
|
||||
[Tool.ALIGN_RIGHT]: alignRightToolStateMachine,
|
||||
[Tool.ALIGN_TOP]: alignTopToolStateMachine,
|
||||
[Tool.ALIGN_CENTER_VERTICAL]: alignCenterVerticalToolStateMachine,
|
||||
[Tool.ALIGN_BOTTOM]: alignBottomToolStateMachine,
|
||||
[Tool.ARRAY]: arrayToolStateMachine,
|
||||
[Tool.PEDIT]: peditToolStateMachine,
|
||||
};
|
||||
@@ -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('그룹을 해제했습니다.');
|
||||
},
|
||||
});
|
||||
@@ -4,7 +4,7 @@ import type {ScreenCanvasDrawController} from '../../src/drawControllers/screenC
|
||||
import {InputController} from '../../src/inputController/input-controller';
|
||||
import {setActiveToolActor, setEntities, setInputController, setScreenCanvasDrawController,} from '../../src/state';
|
||||
import {Tool} from '../../src/tools';
|
||||
import {TOOL_STATE_MACHINES} from '../../src/tools/tool.consts';
|
||||
import {TOOL_STATE_MACHINES} from '../../src/commands/registry';
|
||||
import {ScreenCanvasDrawController as ScreenCanvasDrawControllerMock} from '../mocks/drawControllers/screenCanvas.drawController';
|
||||
import {CANVAS_HEIGHT, CANVAS_WIDTH} from './tests.consts';
|
||||
|
||||
|
||||
@@ -12,7 +12,7 @@ const DATA_ID_TO_TOOL_NAME: Record<string, Tool | null> = {
|
||||
'move-button': Tool.MOVE,
|
||||
'scale-button': Tool.SCALE,
|
||||
'rotate-button': Tool.ROTATE,
|
||||
'measurement-button': Tool.MEASUREMENT,
|
||||
'measurement-button': Tool.DIMALIGNED,
|
||||
'undo-button': null,
|
||||
'redo-button': null,
|
||||
'delete-segment-button': Tool.ERASER,
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import type { Tool } from '../../src/tools';
|
||||
import { getActiveToolActor, setActiveToolActor } from '../../src/state';
|
||||
import { Actor } from 'xstate';
|
||||
import { TOOL_STATE_MACHINES } from '../../src/tools/tool.consts';
|
||||
import { TOOL_STATE_MACHINES } from '../../src/commands/registry';
|
||||
|
||||
export function setActiveTool(toolName: Tool) {
|
||||
getActiveToolActor()?.stop();
|
||||
|
||||
@@ -0,0 +1,20 @@
|
||||
/**
|
||||
* 저장소 tmp/tests에 있는 CAD 테스트 실행용 설정 (테스트 코드는 git 추적 제외).
|
||||
* npx vitest run --config vitest.tmp.config.ts
|
||||
* tmp에는 node_modules가 없어 라이브러리를 openwebcad 쪽으로 이어 준다.
|
||||
*/
|
||||
import { resolve } from 'node:path';
|
||||
import { defineConfig } from 'vitest/config';
|
||||
|
||||
export default defineConfig({
|
||||
root: resolve('../..'),
|
||||
resolve: {
|
||||
alias: {
|
||||
'@flatten-js/core': resolve('node_modules/@flatten-js/core'),
|
||||
'es-toolkit': resolve('node_modules/es-toolkit'),
|
||||
},
|
||||
},
|
||||
test: {
|
||||
include: ['tmp/tests/cad/**/*.test.ts'],
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,383 @@
|
||||
# AutoCAD 2D 기능·명령 조사 (임시)
|
||||
|
||||
- 조사 기준: AutoCAD 2024 Windows의 기본 2D 제도 기능
|
||||
- 조사일: 2026-08-29
|
||||
- 추가검증: 2026-08-29, Autodesk 공식 명령 참조·2024 신규/변경 명령·상태막대 참조와 재대조
|
||||
- 용도: B07 상세설계 웹 CAD 기능 검토용 임시 참고자료
|
||||
- 기본 입력 기준: AutoCAD 2024의 명령 별칭(`acad.pgp`)과 키 조합·기능 키(CUIx)를 `단축키` 열에 함께 기록했다. `—`는 공식 기본 입력을 확인하지 못했거나 리본·상황별 조작만 있는 기능이다. 사용자 설정에 따라 값이 달라질 수 있다.
|
||||
- 자료 성격: 아래 표는 기본 2D 제도 기능과 명령을 작업 기준으로 분류한 **기능 인벤토리**이며, 실제 리본에 표시되는 아이콘의 전수 목록이 아니다.
|
||||
- 아이콘 주의: 리본은 제품·작업공간·화면 폭·CUI 사용자화에 따라 탭, 패널, 버튼, 드롭다운 및 슬라이드아웃 구성이 달라진다. 실제 아이콘 목록으로 사용하려면 AutoCAD 2024 기본 `제도 및 주석` 작업공간의 CUIx 또는 실행 화면을 기준으로 버튼 단위 검증이 추가로 필요하다.
|
||||
- 반영 등급: `A` B07 웹 CAD 우선 구현 · `B` 후속·조건부(엔진·구조 확장이 선행되어야 함) · `C` 미반영(DWG·데스크톱·클라우드 종속이거나 임도 설계에 불필요)
|
||||
- 반영: `☑` 반영 완료 · `◐` 부분 반영 · `☐` 미반영. `기존`은 이번 AutoCAD 대응 작업 이전부터 있던 기능이다. 구현할 때마다 이 열을 갱신한다.
|
||||
- 제외 범위: 3D 모델링, Architecture 등 전문화 도구 세트, Express Tools 및 타사 애드인. 같은 기능이 여러 위치에 나타나는 경우 최초 한 번만 기재했다.
|
||||
|
||||
## 1. 홈 탭 — 그리기
|
||||
|
||||
| 그룹(패널) | 기능/명령 | 단축키 | 짧은 설명 | 반영 등급 | 반영 |
|
||||
|---|---|---|---|---|---|
|
||||
| 그리기 | 선 `LINE` | L | 두 점 사이에 직선 세그먼트를 작성한다. | A | ☑ 기존 |
|
||||
| 그리기 | 폴리선 `PLINE` | PL | 연결된 선·호를 하나의 객체로 작성한다. | A | ☑ |
|
||||
| 그리기 | 원 `CIRCLE` | C | 중심·반지름 등 여러 조건으로 원을 작성한다. | A | ☑ 기존 |
|
||||
| 그리기 | 호 `ARC` | A | 3점, 중심, 시작·끝점 등의 조건으로 호를 작성한다. | A | ☑ |
|
||||
| 그리기 | 직사각형 `RECTANG` | REC | 직사각형 형태의 닫힌 폴리선을 작성한다. | A | ☑ 기존 |
|
||||
| 그리기 | 다각형 `POLYGON` | POL | 지정한 변 수의 정다각형을 작성한다. | A | ☑ |
|
||||
| 그리기 | 타원 `ELLIPSE` | EL | 중심 또는 축 길이로 타원·타원호를 작성한다. | A | ◐ |
|
||||
| 그리기 | 스플라인 `SPLINE` | SPL | 맞춤점 또는 조정 정점으로 부드러운 곡선을 작성한다. | B | ☑ |
|
||||
| 그리기 | 다중선 `MLINE` | ML | 여러 평행선으로 구성된 다중선 객체를 작성한다. | B | ◐ |
|
||||
| 그리기 | 다중선 스타일 `MLSTYLE` | — | 다중선의 요소 수·간격·선종류와 끝막음을 관리한다. | B | ☑ |
|
||||
| 그리기 | 구성선 `XLINE` | XL | 양방향으로 무한한 기준선을 작성한다. | B | ◐ |
|
||||
| 그리기 | 광선 `RAY` | — | 한 방향으로 무한한 기준선을 작성한다. | B | ◐ |
|
||||
| 그리기 | 점 `POINT` | PO | 점 객체를 작성한다. | A | ☑ |
|
||||
| 그리기 | 도넛 `DONUT` | DO | 채워진 원 또는 링 모양 폴리선을 작성한다. | C | ◐ |
|
||||
| 그리기 | 등분 `DIVIDE` | DIV | 객체를 자르지 않고 동일 간격의 점 또는 블록을 지정 개수만큼 배치한다. | A | ☑ |
|
||||
| 그리기 | 길이분할 `MEASURE` | ME | 객체를 자르지 않고 지정 거리마다 점 또는 블록을 배치한다. | A | ☑ |
|
||||
| 그리기 | 해치 `HATCH` | H / BH | 닫힌 경계에 패턴·솔리드·그라데이션 채움을 작성한다. | A | ☑ |
|
||||
| 그리기 | 그라데이션 `GRADIENT` | GD | 닫힌 영역에 색상 그라데이션을 적용한다. | C | ☑ |
|
||||
| 그리기 | 경계 `BOUNDARY` | BO | 닫힌 영역에서 폴리선 또는 영역 객체를 만든다. | B | ☑ |
|
||||
| 그리기 | 영역 `REGION` | REG | 닫힌 평면 객체를 영역 객체로 변환한다. | C | ◐ |
|
||||
| 그리기 | 와이프아웃 `WIPEOUT` | — | 뒤쪽 객체를 가리는 마스크 영역을 작성한다. | B | ☑ |
|
||||
|
||||
## 2. 홈 탭 — 수정
|
||||
|
||||
| 그룹(패널) | 기능/명령 | 단축키 | 짧은 설명 | 반영 등급 | 반영 |
|
||||
|---|---|---|---|---|---|
|
||||
| 수정 | 이동 `MOVE` | M | 선택 객체를 기준점에서 새 위치로 이동한다. | A | ☑ 기존 |
|
||||
| 수정 | 복사 `COPY` | CO | 선택 객체를 하나 이상의 위치에 복제한다. | A | ☑ 기존 |
|
||||
| 수정 | 회전 `ROTATE` | RO | 기준점을 중심으로 객체를 회전한다. | A | ☑ 기존 |
|
||||
| 수정 | 축척 `SCALE` | SC | 기준점과 비율 또는 참조 길이로 크기를 변경한다. | A | ☑ 기존 |
|
||||
| 수정 | 정렬 `ALIGN` | AL | 원본점과 대상점 쌍으로 객체를 이동·회전하고 선택적으로 축척한다. | B | ☑ |
|
||||
| 수정 | 대칭 `MIRROR` | MI | 대칭축을 기준으로 객체를 반사 복사한다. | A | ☑ |
|
||||
| 수정 | 간격띄우기 `OFFSET` | O | 평행선·동심원·등거리 곡선을 작성한다. | A | ☑ |
|
||||
| 수정 | 배열 `ARRAY` | AR | 직사각형·경로·원형 패턴으로 객체를 반복 배치한다. | A | ☑ 기존 |
|
||||
| 수정 | 자르기 `TRIM` | TR | 경계를 기준으로 불필요한 객체 부분을 자른다. | A | ☑ |
|
||||
| 수정 | 연장 `EXTEND` | EX | 객체 끝을 지정 경계까지 연장한다. | A | ◐ |
|
||||
| 수정 | 신축 `STRETCH` | S | 교차 선택한 정점과 객체 일부를 늘이거나 이동한다. | B | ◐ |
|
||||
| 수정 | 모깎기 `FILLET` | F | 두 객체를 지정 반지름의 호로 연결한다. | A | ◐ |
|
||||
| 수정 | 모따기 `CHAMFER` | CHA | 두 객체를 직선 모따기로 연결한다. | A | ◐ |
|
||||
| 수정 | 곡선 혼합 `BLEND` | — | 두 곡선을 부드러운 스플라인으로 연결한다. | C | ☑ |
|
||||
| 수정 | 끊기 `BREAK` | BR | 객체의 두 점 사이를 제거하거나 한 점에서 나눈다. | A | ☑ |
|
||||
| 수정 | 점에서 끊기 `BREAKATPOINT` | — | 객체를 지정점에서 두 객체로 나눈다. | A | ☑ |
|
||||
| 수정 | 결합 `JOIN` | J | 끝이 맞는 선·호·폴리선 등을 하나로 결합한다. | A | ☑ |
|
||||
| 수정 | 분해 `EXPLODE` | X | 블록·폴리선 등 복합 객체를 구성요소로 분해한다. | A | ☑ |
|
||||
| 수정 | 지우기 `ERASE` | E | 선택 객체를 삭제한다. | A | ☑ 기존 |
|
||||
| 수정 | 길이조정 `LENGTHEN` | LEN | 선과 호의 길이 또는 끼인각을 변경한다. | B | ◐ |
|
||||
| 수정 | 폴리선 편집 `PEDIT` | PE | 폴리선 결합, 폭, 정점, 곡선 맞춤 등을 편집한다. | A | ☑ 기존 |
|
||||
| 수정 | 해치 편집 `HATCHEDIT` | HE | 기존 해치의 패턴, 축척, 각도, 경계를 수정한다. | B | ☑ |
|
||||
| 수정 | 그리기 순서 `DRAWORDER` | DR | 객체의 앞뒤 표시 순서를 변경한다. | B | ☑ |
|
||||
| 수정 | 특성 일치 `MATCHPROP` | MA | 원본 객체의 표시 특성을 다른 객체에 복사한다. | A | ☑ |
|
||||
| 수정 | 중복 객체 삭제 `OVERKILL` | — | 중복·겹침 형상을 제거하고 이어진 선·호를 결합한다. | B | ☑ |
|
||||
| 수정 | 방향 반전 `REVERSE` | — | 선·폴리선·스플라인 등의 시작점과 끝점 방향을 뒤집는다. | C | ☑ |
|
||||
| 수정 | 특성을 ByLayer로 `SETBYLAYER` | — | 선택 객체의 특성 재지정을 ByLayer 값으로 변경한다. | B | ☑ |
|
||||
|
||||
## 3. 홈 탭 — 도면층·특성·그룹·유틸리티
|
||||
|
||||
| 그룹(패널) | 기능/명령 | 단축키 | 짧은 설명 | 반영 등급 | 반영 |
|
||||
|---|---|---|---|---|---|
|
||||
| 도면층 | 도면층 특성 `LAYER` | LA | 도면층을 만들고 이름·색상·선종류·출력 여부를 관리한다. | A | ☑ 기존 |
|
||||
| 도면층 | 현재 도면층 설정 | — | 선택한 도면층을 새 객체의 기본 도면층으로 지정한다. | A | ☑ 기존 |
|
||||
| 도면층 | 켜기/끄기 | — | 도면층 객체의 표시를 켜거나 끈다. | A | ☑ 기존 |
|
||||
| 도면층 | 동결/동결해제 | — | 도면층을 재생성 및 표시 대상에서 제외하거나 복원한다. | C | ☑ |
|
||||
| 도면층 | 잠금/잠금해제 | — | 도면층 객체의 편집 가능 여부를 전환한다. | A | ☑ 기존 |
|
||||
| 도면층 | 분리 `LAYISO` | — | 선택 객체의 도면층만 남기고 나머지를 숨기거나 잠근다. | B | ☑ |
|
||||
| 도면층 | 분리 해제 `LAYUNISO` | — | 도면층 분리 이전 상태를 복원한다. | B | ☑ |
|
||||
| 도면층 | 이전 상태 `LAYERP` | — | 직전 도면층 설정 변경을 복원한다. | C | ☑ |
|
||||
| 도면층 | 도면층 상태 `LAYERSTATE` | LAS | 도면층 설정 조합을 이름으로 저장하고 복원한다. | B | ☑ |
|
||||
| 도면층 | 도면층 일치 `LAYMCH` | — | 선택 객체를 대상 객체의 도면층으로 이동한다. | A | ☑ |
|
||||
| 도면층 | 현재 도면층으로 `LAYCUR` | — | 선택 객체를 현재 도면층으로 이동한다. | A | ☑ |
|
||||
| 도면층 | 도면층 병합 `LAYMRG` | — | 한 도면층의 객체를 다른 도면층으로 옮기고 원래 도면층을 제거한다. | B | ☑ |
|
||||
| 도면층 | 도면층 삭제 `LAYDEL` | — | 선택한 도면층과 그 객체를 삭제한다. | B | ☑ |
|
||||
| 도면층 | 도면층 탐색 `LAYWALK` | — | 도면층을 선택적으로 표시하며 포함 객체를 확인한다. | C | ☑ |
|
||||
| 특성 | 색상 `COLOR` | COL | 객체 색상을 직접 또는 `ByLayer`·`ByBlock`으로 지정한다. | A | ☑ |
|
||||
| 특성 | 선종류 `LINETYPE` | LT | 실선·점선·중심선 등 객체 선종류를 지정한다. | A | ☑ |
|
||||
| 특성 | 선가중치 `LWEIGHT` | LW | 화면 및 출력에 사용할 선 굵기를 지정한다. | A | ☑ |
|
||||
| 특성 | 투명도 | — | 객체 또는 도면층의 투명도를 지정한다. | B | ☑ |
|
||||
| 특성 | 특성 팔레트 `PROPERTIES` | Ctrl+1 / CH / MO / PR | 선택 객체의 형상·표시·데이터 특성을 조회하고 수정한다. | A | ☑ |
|
||||
| 특성 | 빠른 특성 `QUICKPROPERTIES` | QP | 선택 객체 주변에 주요 특성만 간단히 표시한다. | B | ☑ |
|
||||
| 그룹 | 그룹 `GROUP` | G | 여러 객체를 함께 선택할 수 있는 명명 그룹으로 묶는다. | B | ☑ |
|
||||
| 그룹 | 그룹 해제 `UNGROUP` | — | 객체 그룹을 해제한다. | B | ☑ |
|
||||
| 유틸리티 | 거리 `DIST` | DI | 두 점 사이의 거리와 각도 차이를 측정한다. | A | ☑ |
|
||||
| 유틸리티 | 반지름 | — | 원 또는 호의 반지름을 측정한다. | A | ☑ |
|
||||
| 유틸리티 | 각도 | — | 선·호 또는 지정점 사이의 각도를 측정한다. | A | ☑ |
|
||||
| 유틸리티 | 면적 `AREA` | AA | 객체 또는 지정 경계의 면적과 둘레를 계산한다. | A | ☑ |
|
||||
| 유틸리티 | 빠른 측정 `MEASUREGEOM` | MEA | 커서 주변의 치수·거리·각도와 닫힌 영역의 면적을 동적으로 표시한다. | B | ☑ |
|
||||
| 유틸리티 | 점 좌표 `ID` | ID | 지정점의 X·Y·Z 좌표를 표시한다. | A | ☑ |
|
||||
| 유틸리티 | 리스트 `LIST` | LI | 선택 객체의 상세 데이터와 기하 정보를 표시한다. | B | ☑ |
|
||||
| 유틸리티 | 빠른 선택 `QSELECT` | — | 객체 유형과 특성 조건으로 선택 집합을 만든다. | B | ☑ |
|
||||
| 유틸리티 | 계산기 `QUICKCALC` | QC / Ctrl+8 | 수식, 단위 변환 및 도면 값을 계산한다. | C | ☑ |
|
||||
| 유틸리티 | 유사 선택 `SELECTSIMILAR` | — | 선택 객체와 지정 특성이 같은 객체를 모두 선택한다. | B | ☑ |
|
||||
| 유틸리티 | 객체 분리 `ISOLATEOBJECTS` | — | 선택 객체만 표시하고 나머지 객체를 숨긴다. | B | ☑ |
|
||||
| 유틸리티 | 객체 숨기기 `HIDEOBJECTS` | — | 선택 객체를 일시적으로 숨긴다. | B | ☑ |
|
||||
| 유틸리티 | 객체 분리 종료 `UNISOLATEOBJECTS` | UNHIDE / UNISOLATE | 숨기거나 분리한 객체를 다시 표시한다. | B | ☑ |
|
||||
| 클립보드 | 잘라내기·복사·붙여넣기 | Ctrl+X / Ctrl+C / Ctrl+V | 객체를 시스템 클립보드로 이동·복제한다. | A | ☑ |
|
||||
| 클립보드 | 기준점과 함께 복사 | Ctrl+Shift+C | 지정 기준점을 포함해 객체를 클립보드에 복사한다. | B | ☑ |
|
||||
| 클립보드 | 원래 좌표로 붙여넣기 | — | 복사한 객체를 원본 도면 좌표에 배치한다. | B | ☑ |
|
||||
| 클립보드 | 블록으로 붙여넣기 | Ctrl+Shift+V | 클립보드 객체를 새 블록으로 삽입한다. | C | ◐ |
|
||||
|
||||
## 4. 삽입 탭 — 블록·참조·데이터
|
||||
|
||||
| 그룹(패널) | 기능/명령 | 단축키 | 짧은 설명 | 반영 등급 | 반영 |
|
||||
|---|---|---|---|---|---|
|
||||
| 블록 | 블록 삽입 `INSERT` | I | 현재 도면·최근 항목·라이브러리의 블록을 배치한다. | B | ☐ |
|
||||
| 블록 | 스마트 블록 배치 | — | 블록 팔레트로 삽입할 때 기존 동일 블록을 바탕으로 위치·회전·축척을 제안한다. | C | ☐ |
|
||||
| 블록 | 블록 교체 `BREPLACE` | — | 선택 블록을 제안 목록 또는 지정한 다른 블록으로 교체한다. | C | ☐ |
|
||||
| 블록 정의 | 블록 작성 `BLOCK` | B | 선택 객체와 기준점으로 블록 정의를 만든다. | B | ☐ |
|
||||
| 블록 정의 | 블록 편집기 `BEDIT` | BE | 블록 정의와 동적 블록 동작을 편집한다. | C | ☐ |
|
||||
| 블록 정의 | 블록 쓰기 `WBLOCK` | W | 선택 객체 또는 블록을 별도 DWG로 저장한다. | C | ☐ |
|
||||
| 블록 정의 | 속성 정의 `ATTDEF` | ATT | 블록에 포함할 문자 데이터 필드를 정의한다. | C | ☐ |
|
||||
| 블록 정의 | 속성 관리 `BATTMAN` | — | 블록 속성의 순서·표시·기본값을 관리한다. | C | ☐ |
|
||||
| 블록 정의 | 속성 동기화 `ATTSYNC` | — | 변경된 속성 정의를 기존 블록 참조에 반영한다. | C | ☐ |
|
||||
| 블록 정의 | 향상된 속성 편집 `EATTEDIT` | — | 삽입된 블록 참조의 속성 값과 문자·표시 특성을 편집한다. | C | ☐ |
|
||||
| 참조 | DWG 부착 `XATTACH` | XA | 다른 DWG를 외부 참조로 연결한다. | C | ☐ |
|
||||
| 참조 | 이미지 부착 `IMAGEATTACH` | IAT | 래스터 이미지를 참조로 연결한다. | A | ☑ 기존 |
|
||||
| 참조 | PDF 부착 `PDFATTACH` | — | PDF 페이지를 언더레이로 연결한다. | C | ☐ |
|
||||
| 참조 | DWF/DGN 부착 | — | DWF 또는 DGN을 언더레이로 연결한다. | C | ☐ |
|
||||
| 참조 | 점 구름 부착 | — | 스캔된 점 구름 데이터를 도면에 연결한다. | C | ☐ |
|
||||
| 참조 | 외부 참조 팔레트 `XREF` | XR | 참조의 로드·언로드·재로드·경로·결합을 관리한다. | C | ☐ |
|
||||
| 참조 | 참조 내부 편집 `REFEDIT` | — | 블록 또는 외부 참조를 현재 도면 안에서 직접 편집한다. | C | ☐ |
|
||||
| 참조 | 외부 참조 자르기 `XCLIP` | XC | DWG 외부 참조 또는 블록의 표시 경계를 지정한다. | C | ☐ |
|
||||
| 참조 | 이미지 자르기 `IMAGECLIP` | ICL | 부착 이미지의 표시 경계를 지정한다. | B | ☐ |
|
||||
| 참조 | PDF 자르기 `PDFCLIP` | — | PDF 언더레이의 표시 경계를 지정한다. | C | ☐ |
|
||||
| 가져오기 | PDF 가져오기 `PDFIMPORT` | — | PDF의 벡터 형상·문자·채움을 도면 객체로 변환한다. | C | ☐ |
|
||||
| 가져오기 | 가져오기 `IMPORT` | IMP | 지원되는 다른 형식의 데이터를 현재 도면으로 가져온다. | B | ☐ |
|
||||
| 링크 및 추출 | 데이터 링크 `DATALINK` | DL | Excel 등 외부 표 데이터와 도면 테이블을 연결한다. | C | ☐ |
|
||||
| 링크 및 추출 | 데이터 추출 `DATAEXTRACTION` | DX | 객체·블록·속성 정보를 표 또는 외부 파일로 추출한다. | B | ☐ |
|
||||
| 링크 및 추출 | 하이퍼링크 `HYPERLINK` | — | 객체에 웹·파일·도면 뷰 링크를 연결한다. | C | ☐ |
|
||||
| 링크 및 추출 | 필드 `FIELD` | — | 날짜·도면·객체 특성 등 갱신 가능한 문자 값을 삽입한다. | C | ☐ |
|
||||
| 위치 | 지리적 위치 `GEOGRAPHICLOCATION` | GEO / NORTH | 좌표계와 지도상의 도면 위치를 지정한다. | C | ☐ |
|
||||
|
||||
## 5. 주석 탭 — 문자·치수·지시선·표·표식
|
||||
|
||||
| 그룹(패널) | 기능/명령 | 단축키 | 짧은 설명 | 반영 등급 | 반영 |
|
||||
|---|---|---|---|---|---|
|
||||
| 문자 | 여러 줄 문자 `MTEXT` | T / MT | 서식과 줄바꿈을 지원하는 문단 문자를 작성한다. | A | ☑ |
|
||||
| 문자 | 단일 행 문자 `TEXT` | DT | 각 행이 독립 객체인 간단한 문자를 작성한다. | A | ☑ |
|
||||
| 문자 | 문자 스타일 `STYLE` | ST | 글꼴·높이·폭 비율·기울기 등의 스타일을 관리한다. | B | ◐ |
|
||||
| 문자 | 문자 편집 `TEXTEDIT` | TEDIT | 기존 문자·치수 문자·속성 문자를 수정한다. | A | ☑ |
|
||||
| 문자 | 찾기·대치 `FIND` | — | 도면의 문자 내용을 검색하고 필요한 문자열을 대치한다. | B | ☑ |
|
||||
| 문자 | 맞춤법 검사 `SPELL` | SP | 도면 문자와 속성 값의 철자를 검사한다. | C | ☐ |
|
||||
| 치수 | 빠른 치수 `QDIM` | — | 선택 객체에 여러 치수를 한 번에 작성한다. | B | ☑ |
|
||||
| 치수 | 치수 `DIM` | — | 선택한 객체와 지정점에 적합한 치수 유형을 작성한다. | A | ☑ |
|
||||
| 치수 | 선형 치수 `DIMLINEAR` | — | 수평·수직·회전 선형 치수를 작성한다. | A | ☑ |
|
||||
| 치수 | 정렬 치수 `DIMALIGNED` | — | 두 점과 평행한 실제 길이 치수를 작성한다. | A | ☑ |
|
||||
| 치수 | 각도 치수 `DIMANGULAR` | DAN | 두 선 또는 호의 각도를 기입한다. | A | ☑ |
|
||||
| 치수 | 호 길이 치수 `DIMARC` | DAR | 호의 곡선 길이를 기입한다. | B | ☑ |
|
||||
| 치수 | 반지름 `DIMRADIUS`·지름 `DIMDIAMETER` 치수 | DRA / DDI | 원과 호의 반지름 또는 지름을 기입한다. | A | ☑ |
|
||||
| 치수 | 꺾기 치수 `DIMJOGGED` | DJO / JOG | 큰 반지름의 중심 위치를 축약해 표시한다. | C | ☐ |
|
||||
| 치수 | 세로좌표 치수 `DIMORDINATE` | DOR | 기준 원점에 대한 X 또는 Y 좌표를 기입한다. | B | ☑ |
|
||||
| 치수 | 기준선 `DIMBASELINE`·연속 `DIMCONTINUE` 치수 | DBA / DCO | 공통 기준 또는 앞 치수 끝점에서 연속 기입한다. | A | ☑ |
|
||||
| 치수 | 중심 표식 `CENTERMARK` | — | 원 또는 호 중심에 연관 중심 표식을 작성한다. | B | ☑ |
|
||||
| 치수 | 중심선 `CENTERLINE` | — | 두 선 또는 폴리선 세그먼트 사이에 연관 중심선을 작성한다. | B | ☑ |
|
||||
| 치수 | 치수 스타일 `DIMSTYLE` | D | 치수선·화살표·문자·단위·공차 형식을 관리한다. | A | ☑ |
|
||||
| 치수 | 치수 업데이트 | — | 변경한 치수 스타일을 기존 치수에 적용한다. | B | ☑ |
|
||||
| 치수 | 치수 끊기 `DIMBREAK` | — | 교차 객체나 지정점에서 치수선·연장선을 끊어 표시한다. | C | ☐ |
|
||||
| 치수 | 치수 간격 `DIMSPACE` | — | 평행 또는 동심 치수 사이의 간격을 조정한다. | B | ☑ |
|
||||
| 치수 | 치수 재연관 `DIMREASSOCIATE` | DRE | 끊어진 치수를 실제 형상점과 다시 연관시킨다. | C | ☐ |
|
||||
| 치수 | 검사 치수 `DIMINSPECT` | — | 검사 빈도·값 정보를 포함한 검사 치수를 작성한다. | C | ☐ |
|
||||
| 기호 | 기하공차 `TOLERANCE` | TOL | 형상·자세·위치 공차 기호 프레임을 작성한다. | C | ☐ |
|
||||
| 지시선 | 다중 지시선 `MLEADER` | MLD | 화살표와 문자 또는 블록 주석을 연결한다. | A | ☑ |
|
||||
| 지시선 | 다중 지시선 스타일 `MLEADERSTYLE` | MLS | 다중 지시선의 형식과 기본 동작을 작성·수정한다. | B | ☑ |
|
||||
| 지시선 | 지시선 추가·제거·정렬 | — | 기존 다중 지시선의 가지와 배치를 편집한다. | B | ◐ |
|
||||
| 지시선 | 지시선 수집 `MLEADERCOLLECT` | MLC | 블록 내용의 여러 다중 지시선을 행 또는 열로 정렬한다. | C | ☐ |
|
||||
| 표 | 테이블 `TABLE` | TB | 행·열과 셀 서식을 가진 도면 표를 작성한다. | A | ◐ |
|
||||
| 표 | 테이블 스타일 `TABLESTYLE` | TS | 표의 제목·머리글·데이터 셀 형식을 관리한다. | B | ☑ |
|
||||
| 표식 | 구름형 리비전 `REVCLOUD` | — | 변경 또는 검토 범위를 구름형 선으로 표시한다. | B | ☑ |
|
||||
| 주석 축척 | 주석 객체·축척 | — | 여러 뷰포트 축척에서 일정한 출력 크기를 유지한다. | B | ☑ |
|
||||
| 주석 축척 | 축척 리스트 편집 `SCALELISTEDIT` | — | 도면에서 사용할 주석·뷰포트 축척 목록을 관리한다. | C | ☐ |
|
||||
|
||||
## 6. 파라메트릭 탭 — 구속조건
|
||||
|
||||
| 그룹(패널) | 기능/명령 | 단축키 | 짧은 설명 | 반영 등급 | 반영 |
|
||||
|---|---|---|---|---|---|
|
||||
| 기하 구속 | 자동 구속 `AUTOCONSTRAIN` | — | 객체 관계를 분석해 여러 기하 구속을 자동 적용한다. | C | ☐ |
|
||||
| 기하 구속 | 일치 | — | 두 점 또는 점과 객체를 같은 위치에 구속한다. | C | ☐ |
|
||||
| 기하 구속 | 동일선상 | — | 객체들이 같은 무한선 위에 있도록 구속한다. | C | ☐ |
|
||||
| 기하 구속 | 동심 | — | 원·호·타원이 같은 중심을 갖도록 구속한다. | C | ☐ |
|
||||
| 기하 구속 | 고정 | — | 점 또는 객체의 위치·방향을 고정한다. | C | ☐ |
|
||||
| 기하 구속 | 평행·직교 | — | 두 선을 평행 또는 직각으로 유지한다. | C | ☐ |
|
||||
| 기하 구속 | 수평·수직 | — | 선 또는 두 점을 수평·수직으로 유지한다. | C | ☐ |
|
||||
| 기하 구속 | 접선 | — | 두 곡선이 서로 접하도록 구속한다. | C | ☐ |
|
||||
| 기하 구속 | 매끄럽게 | — | 스플라인과 다른 곡선을 부드럽게 연결한다. | C | ☐ |
|
||||
| 기하 구속 | 대칭 | — | 객체를 지정 대칭축에 대해 대칭으로 유지한다. | C | ☐ |
|
||||
| 기하 구속 | 같음 | — | 객체의 길이 또는 반지름을 같게 유지한다. | C | ☐ |
|
||||
| 치수 구속 | 선형·정렬·수평·수직 | — | 거리 값을 매개변수로 지정해 형상을 제어한다. | C | ☐ |
|
||||
| 치수 구속 | 반지름·지름·각도 | — | 크기와 각도를 매개변수로 지정한다. | C | ☐ |
|
||||
| 치수 구속 | 변환 | — | 일반 치수를 연관 치수 구속으로 변환한다. | C | ☐ |
|
||||
| 관리 | 매개변수 관리자 `PARAMETERS` | PAR | 이름 있는 변수, 값 및 수식을 관리한다. | C | ☐ |
|
||||
| 관리 | 구속 표시·숨기기·삭제 | — | 구속 막대와 치수 구속의 표시 및 제거를 관리한다. | C | ☐ |
|
||||
|
||||
## 7. 뷰 탭 — 탐색·뷰·뷰포트·팔레트
|
||||
|
||||
| 그룹(패널) | 기능/명령 | 단축키 | 짧은 설명 | 반영 등급 | 반영 |
|
||||
|---|---|---|---|---|---|
|
||||
| 탐색 | 줌 `ZOOM` | Z | 확대·축소, 범위 전체, 윈도우 등의 방법으로 뷰를 변경한다. | A | ☑ 기존 |
|
||||
| 탐색 | 초점이동 `PAN` | P | 배율을 유지한 채 화면의 관찰 위치를 이동한다. | A | ☑ 기존 |
|
||||
| 탐색 | 탐색 막대 | — | 줌·초점이동 등 화면 탐색 도구를 모아 제공한다. | A | ☑ 기존 |
|
||||
| 뷰 | 명명된 뷰 `VIEW` | V | 현재 화면·도면층·UCS 상태를 이름으로 저장하고 복원한다. | B | ☐ |
|
||||
| 뷰 | 새 명명 뷰 `NEWVIEW` | NVIEW | 현재 화면 또는 지정 영역을 새 이름의 뷰로 저장한다. | B | ☐ |
|
||||
| 뷰 | 재생성 `REGEN` | RE | 현재 뷰포트의 객체 표시를 다시 계산한다. | B | ☐ |
|
||||
| 뷰 | 전체 재생성 `REGENALL` | REA | 모든 뷰포트의 객체 표시를 다시 계산한다. | C | ☐ |
|
||||
| 뷰포트 | 모형 공간 뷰포트 | — | 도면 영역을 여러 독립 관찰 창으로 나눈다. | C | ☐ |
|
||||
| 뷰포트 | 배치 뷰포트 `MVIEW` | MV | 종이 공간에 모형을 표시하는 출력 창을 작성한다. | B | ☐ |
|
||||
| 뷰포트 | 뷰포트 잠금 | — | 배치 뷰포트의 축척과 관찰 상태 변경을 막는다. | C | ☐ |
|
||||
| 좌표 | UCS `UCS` | — | 사용자 좌표계의 원점과 축 방향을 설정한다. | B | ☐ |
|
||||
| 좌표 | 월드 UCS | — | 좌표계를 기본 WCS로 복원한다. | B | ☐ |
|
||||
| 팔레트 | 도구 팔레트 `TOOLPALETTES` | TP / Ctrl+3 | 자주 쓰는 블록·해치·명령 도구 모음을 표시한다. | C | ☐ |
|
||||
| 팔레트 | 개수 `COUNT` | — | 선택 객체·블록의 인스턴스를 세고 결과를 강조·검토한다. | B | ☐ |
|
||||
| 팔레트 | DesignCenter `ADCENTER` | ADC / Ctrl+2 | 다른 도면의 블록·도면층·스타일을 탐색해 가져온다. | C | ☐ |
|
||||
| 팔레트 | 시트 세트 관리자 | Ctrl+4 / SSM | 여러 도면 시트의 집합·번호·출력을 관리한다. | C | ☐ |
|
||||
| 팔레트 | 명령행 | Ctrl+9 / CLI | 명령 입력, 옵션 선택 및 작업 기록을 표시한다. | A | ☑ 기존 |
|
||||
| 팔레트 | 명령 매크로 | — | 반복 명령 시퀀스를 검토·저장·실행한다. | C | ☐ |
|
||||
| 인터페이스 | 파일 탭 | Ctrl+Tab | 열려 있는 여러 도면 사이를 전환한다. | B | ☐ |
|
||||
| 인터페이스 | 타일·계단식 배열 | — | 여러 열린 도면 창의 배치를 조정한다. | C | ☐ |
|
||||
| 인터페이스 | 전체 화면 정리 `CLEANSCREENON` | Ctrl+0 | 도구막대와 팔레트를 숨겨 도면 영역을 최대화한다. | B | ☐ |
|
||||
|
||||
## 8. 관리 탭 — 표준·사용자화·자동화
|
||||
|
||||
| 그룹(패널) | 기능/명령 | 단축키 | 짧은 설명 | 반영 등급 | 반영 |
|
||||
|---|---|---|---|---|---|
|
||||
| 작업 기록 | 동작 기록기 `ACTRECORD` | ARR | 사용자 명령과 입력을 기록해 반복 재생한다. | C | ☐ |
|
||||
| 사용자화 | 사용자 인터페이스 `CUI` | — | 리본·도구막대·메뉴·단축키·마우스 동작을 사용자화한다. | C | ☐ |
|
||||
| 사용자화 | 별칭 편집 | — | 명령행에서 쓰는 짧은 명령 별칭을 관리한다. | C | ☐ |
|
||||
| 사용자화 | 작업공간 | — | 리본·팔레트·도구막대 배치를 저장하고 전환한다. | C | ☐ |
|
||||
| CAD 표준 | 표준 구성 `STANDARDS` | STA | 도면층·문자·치수·선종류 표준 파일을 연결한다. | C | ☐ |
|
||||
| CAD 표준 | 표준 검사 `CHECKSTANDARDS` | CHK | 현재 도면이 연결된 CAD 표준과 다른 항목을 찾는다. | C | ☐ |
|
||||
| CAD 표준 | 도면층 변환 `LAYTRANS` | — | 현재 도면층을 지정 표준 도면층 이름과 특성으로 매핑한다. | C | ☐ |
|
||||
| 응용프로그램 | 응용프로그램 로드 `APPLOAD` | AP | AutoLISP·ObjectARX 등 확장 프로그램을 로드한다. | C | ☐ |
|
||||
| 응용프로그램 | Visual LISP | — | AutoLISP 코드를 작성·검사하는 개발 환경을 연다. | C | ☐ |
|
||||
| 스크립트 | 스크립트 실행 `SCRIPT` | SCR | 텍스트 파일에 기록된 명령 시퀀스를 실행한다. | C | ☐ |
|
||||
| 정리 | 소거 `PURGE` | PU | 사용하지 않는 블록·도면층·스타일 정의를 제거한다. | B | ☐ |
|
||||
|
||||
## 9. 출력 탭 — 배치·플롯·게시·내보내기
|
||||
|
||||
| 그룹(패널) | 기능/명령 | 단축키 | 짧은 설명 | 반영 등급 | 반영 |
|
||||
|---|---|---|---|---|---|
|
||||
| 플롯 | 플롯 `PLOT` | PRINT / Ctrl+P | 프린터·용지·영역·축척·스타일을 설정해 출력한다. | A | ☐ |
|
||||
| 플롯 | 미리보기 `PREVIEW` | PRE | 실제 출력 전 용지 결과를 확인한다. | A | ☐ |
|
||||
| 플롯 | 페이지 설정 `PAGESETUP` | — | 배치별 출력 장치와 용지 설정을 저장한다. | B | ☐ |
|
||||
| 플롯 | 플롯 스타일 | — | 색상·선가중치 등의 CTB/STB 출력 규칙을 관리한다. | C | ☐ |
|
||||
| 플롯 | 플로터 관리자 `PLOTTERMANAGER` | — | 플로터 구성 파일과 장치 설정을 관리한다. | C | ☐ |
|
||||
| 플롯 | 플롯 스타일 관리자 `STYLESMANAGER` | — | CTB·STB 플롯 스타일 테이블 파일을 관리한다. | C | ☐ |
|
||||
| 게시 | 게시 `PUBLISH` | — | 여러 도면·배치를 한 번에 DWF·PDF·프린터로 출력한다. | B | ☐ |
|
||||
| 게시 | 배치 플롯 | — | 시트 목록을 구성해 일괄 출력한다. | B | ☐ |
|
||||
| 내보내기 | PDF 내보내기 `EXPORTPDF` | EPDF | 도면 또는 배치를 PDF 파일로 만든다. | A | ☐ |
|
||||
| 내보내기 | DWF/DWFx 내보내기 | — | 검토·배포용 Autodesk 형식으로 내보낸다. | C | ☐ |
|
||||
| 내보내기 | 기타 형식 `EXPORT` | EXP | 지원되는 다른 교환 파일 형식으로 저장한다. | A | ☑ 기존 |
|
||||
| 전송 | 전자 전송 `ETRANSMIT` | ZIP | 도면과 참조·글꼴·플롯 설정을 하나의 전달 패키지로 묶는다. | C | ☐ |
|
||||
|
||||
## 10. 공동작업·검토
|
||||
|
||||
| 그룹(패널) | 기능/명령 | 단축키 | 짧은 설명 | 반영 등급 | 반영 |
|
||||
|---|---|---|---|---|---|
|
||||
| 비교 | DWG 비교 `COMPARE` | — | 두 DWG의 추가·삭제·변경 객체를 색으로 구분한다. | C | ☐ |
|
||||
| 비교 | 외부 참조 비교 | — | 부착된 외부 참조의 변경 사항을 현재 도면과 비교한다. | C | ☐ |
|
||||
| 검토 | 추적 `TRACE` | — | 원도면을 바꾸지 않는 검토용 투명 오버레이를 만든다. | C | ☐ |
|
||||
| 검토 | 추적에서 복사 `COPYFROMTRACE` | — | 추적에 있는 객체를 원도면의 같은 위치로 복사한다. | C | ☐ |
|
||||
| 검토 | 표식 가져오기 `MARKUPIMPORT` | — | PDF·JPG·PNG 표식을 새 추적으로 가져와 원도면 위에 겹친다. | C | ☐ |
|
||||
| 검토 | 표식 도우미 `MARKUPASSIST` | — | 표식의 문자·구름·지시 등을 인식해 도면 반영을 돕는다. | C | ☐ |
|
||||
| 공유 | 뷰 공유 `SHAREDVIEWS` | — | 도면의 온라인 검토용 뷰를 생성하고 의견을 교환한다. | C | ☐ |
|
||||
| 공유 | 도면 공유 `SHARE` | — | 현재 도면 복사본을 웹·모바일에서 보거나 편집하는 링크를 만든다. | C | ☐ |
|
||||
| 이력 | 활동 정보 `ACTIVITYINSIGHTSOPEN` | AI OPEN | 도면 열기·저장·편집 등 작업 이벤트를 추적한다. | C | ☐ |
|
||||
| 버전 | 버전 이력 `DWGHISTORY` | — | 지원되는 클라우드에 저장된 도면의 이전 버전을 조회·비교한다. | C | ☐ |
|
||||
|
||||
## 11. 리본 밖의 핵심 제도 기능
|
||||
|
||||
| 영역 | 기능 | 단축키 | 짧은 설명 | 반영 등급 | 반영 |
|
||||
|---|---|---|---|---|---|
|
||||
| 선택 | 창 선택 | — | 왼쪽→오른쪽 사각형 안에 완전히 포함된 객체를 선택한다. | A | ☑ 기존 |
|
||||
| 선택 | 교차 선택 | — | 오른쪽→왼쪽 영역에 포함되거나 걸치는 객체를 선택한다. | A | ☑ 기존 |
|
||||
| 선택 | 올가미 선택 | — | 자유형 경계로 객체를 포함 또는 교차 선택한다. | B | ☐ |
|
||||
| 선택 | 선택 순환 | — | 겹친 객체 목록에서 원하는 객체를 골라 선택한다. | B | ☐ |
|
||||
| 직접 편집 | 그립 편집 | — | 선택 객체의 정점·중간점·반지름 등을 직접 끌어 수정한다. | A | ☐ |
|
||||
| 직접 편집 | 다기능 그립 | — | 폴리선 정점 추가·제거, 호 전환 등 상황별 작업을 제공한다. | B | ☐ |
|
||||
| 정확도 | 객체 스냅 `OSNAP` | OS / F3 | 끝점·중간점·중심·교차점·접점 등 정확한 점을 포착한다. | A | ☑ 기존 |
|
||||
| 정확도 | 객체 스냅 추적 `(F11)` | F11 | 포착한 점에서 임시 정렬 경로를 추적한다. | B | ☐ |
|
||||
| 정확도 | 극좌표 추적 `(F10)` | F10 | 지정 각도 증분을 따라 커서 이동을 안내한다. | B | ☐ |
|
||||
| 정확도 | 직교 모드 `(F8)` | F8 | 커서 이동을 현재 UCS의 수평·수직 방향으로 제한한다. | A | ☑ 기존 |
|
||||
| 정확도 | 그리드·스냅 | F7 / F9 | 화면 격자를 표시하고 커서 이동 간격을 제한한다. | A | ☑ 기존 |
|
||||
| 정확도 | 동적 입력 `(F12, DYNMODE 시스템 변수)` | F12 | 커서 근처에서 좌표·거리·각도 및 명령 옵션을 입력한다. | A | ☑ 기존 |
|
||||
| 정확도 | 직접 거리 입력 | — | 방향을 지정한 뒤 키보드로 정확한 거리를 입력한다. | A | ☑ 기존 |
|
||||
| 좌표 | 절대·상대·극좌표 | — | 전역 좌표, 이전 점 기준 좌표, 거리·각도로 점을 입력한다. | A | ◐ 기존 |
|
||||
| 작업 흐름 | 명령 직접 입력 | — | 명령 이름, 별칭, 옵션, 숫자와 좌표를 명령행에 연속 입력한다. | A | ☑ 기존 |
|
||||
| 작업 흐름 | 명령 자동완성 | — | 입력 중인 명령·시스템 변수·콘텐츠를 검색해 제안한다. | A | ◐ 기존 |
|
||||
| 작업 흐름 | 실행 취소·다시 실행 | Ctrl+Z / Ctrl+Y | 명령 단위로 작업 이력을 되돌리거나 복원한다. | A | ☑ 기존 |
|
||||
| 작업 흐름 | 반복·최근 명령 | — | 직전 또는 최근 사용 명령을 다시 실행한다. | B | ☐ |
|
||||
| 표시 | 선가중치 표시 | — | 실제 출력 선 굵기의 화면 표시를 전환한다. | B | ☐ |
|
||||
| 표시 | 투명도 표시 | — | 객체·도면층 투명도의 화면 표시를 전환한다. | C | ☐ |
|
||||
| 표시 | 주석 가시성 | — | 현재 축척을 지원하지 않는 주석의 표시 여부를 전환한다. | C | ☐ |
|
||||
| 공간 | 모형·배치 | — | 실제 크기 설계 공간과 용지 출력 공간을 전환한다. | B | ☐ |
|
||||
| 공간 | 뷰포트 축척 | — | 배치에서 모형 표시 축척을 정밀하게 지정한다. | B | ☐ |
|
||||
| 파일 | 새 도면·열기·저장·다른 이름 저장 | Ctrl+N / Ctrl+O / Ctrl+S / Ctrl+Shift+S | DWG 작업 파일의 기본 수명주기를 관리한다. | A | ◐ 기존 |
|
||||
| 파일 | 자동 저장·복구 | — | 비정상 종료 시 사용할 임시 저장과 복구 정보를 관리한다. | B | ☐ |
|
||||
| 파일 | 감사 `AUDIT` | — | 응용프로그램 메뉴에서 열린 도면의 오류를 검사·수정한다. | C | ☐ |
|
||||
| 파일 | 복구 `RECOVER` | — | 응용프로그램 메뉴에서 손상된 도면을 복구하면서 연다. | C | ☐ |
|
||||
| 호환 | DWG 변환 `DWGCONVERT` | — | 여러 도면을 지정 DWG 버전으로 일괄 변환한다. | C | ☐ |
|
||||
| 호환 | 단위 `UNITS` | UN | 길이·각도 형식, 정밀도 및 삽입 축척 단위를 설정한다. | B | ☐ |
|
||||
|
||||
## 12. 2D 상황별 리본 탭
|
||||
|
||||
> 해당 객체를 선택하거나 편집 명령을 실행할 때만 나타나는 리본이다.
|
||||
|
||||
| 상황별 탭 | 기능 | 단축키 | 짧은 설명 | 반영 등급 | 반영 |
|
||||
|---|---|---|---|---|---|
|
||||
| 해치 작성/편집 | 원점 설정 | — | 해치 패턴이 시작되는 기준점을 지정한다. | B | ☐ |
|
||||
| 해치 작성/편집 | 연관·주석 | — | 경계 연동과 주석 축척 적용 여부를 설정한다. | B | ☐ |
|
||||
| 해치 작성/편집 | 경계 재작성 | — | 해치에서 폴리선 또는 영역 경계를 다시 만든다. | C | ☐ |
|
||||
| 문자 편집기 | 굵게·기울임·밑줄·색상 | — | 선택 문자 범위의 서식을 변경한다. | B | ☐ |
|
||||
| 문자 편집기 | 정렬·들여쓰기·줄 간격 | — | 문단 배치와 간격을 조정한다. | B | ☐ |
|
||||
| 문자 편집기 | 열 | — | 여러 줄 문자를 동적·정적 열로 나눈다. | C | ☐ |
|
||||
| 블록 편집기 | 블록 테스트 | — | 동적 블록의 그립과 동작을 시험한다. | C | ☐ |
|
||||
| 블록 편집기 | 매개변수·동작 | — | 동적 블록에 점·선형·회전 등의 매개변수와 동작을 지정한다. | C | ☐ |
|
||||
| 블록 편집기 | 가시성 상태 | — | 하나의 블록에서 표시할 객체 조합을 관리한다. | C | ☐ |
|
||||
| 배열 편집 | 항목 수·간격 | — | 배열 항목의 개수와 행·열 또는 경로 간격을 수정한다. | B | ☐ |
|
||||
| 배열 편집 | 항목 교체·편집 | — | 배열의 원본 항목을 편집하거나 다른 객체로 바꾼다. | B | ☐ |
|
||||
| 배열 편집 | 원본 편집·재설정 | — | 원본 객체를 편집하고 배열 재지정을 초기화한다. | C | ☐ |
|
||||
| 외부 참조·언더레이 | 페이드·대비 | — | 외부 참조와 PDF·DWF·DGN 언더레이의 화면 특성을 조정한다. | C | ☐ |
|
||||
| 외부 참조 | 참조 도면층 | — | 참조에 포함된 도면층 표시를 관리한다. | C | ☐ |
|
||||
| 이미지 | 페이드·대비·밝기 | — | 래스터 이미지의 화면 특성을 조정한다. | B | ☐ |
|
||||
| PDF/DWF/DGN 언더레이 | 단색·언더레이 도면층 | — | 단색 표시와 원본 도면층 가시성을 관리한다. | C | ☐ |
|
||||
| 배치 | 명명된 뷰 삽입 | — | 저장된 모형 뷰를 배치 뷰포트로 배치한다. | C | ☐ |
|
||||
| 테이블 셀 | 행·열 삽입·삭제 | — | 선택 셀 주변의 표 구조를 편집한다. | B | ☐ |
|
||||
| 테이블 셀 | 병합·병합 해제 | — | 여러 셀을 합치거나 원래 셀로 되돌린다. | B | ☐ |
|
||||
| 테이블 셀 | 셀 형식·정렬·테두리 | — | 데이터 형식과 표시 서식을 조정한다. | B | ☐ |
|
||||
|
||||
## 13. 실제 리본 아이콘 조사 상태
|
||||
|
||||
| 확인 항목 | 현재 문서 반영 상태 | 판정 |
|
||||
|---|---|---|
|
||||
| 기능·명령명과 짧은 설명 | 반영 | B07 기능 후보 검토에 사용 가능 |
|
||||
| 작업 기준 탭·패널 분류 | 반영 | 실제 기본 리본 배치와 다를 수 있음 |
|
||||
| 리본 버튼의 실제 존재 여부 | 미검증 | 아이콘 목록으로 사용 불가 |
|
||||
| 기본 표시·확장 패널·드롭다운 구분 | 미검증 | AutoCAD 2024 실행 화면 또는 CUIx 확인 필요 |
|
||||
| 버튼 표시명·툴팁·CUI 명령 ID | 미수집 | 실제 아이콘 단위 조사 필요 |
|
||||
| 아이콘 이미지·리소스명 | 미수집 | 실제 아이콘 자산 비교 필요 |
|
||||
| 상황별 탭 표시 조건 | 일부 기능만 정리 | 객체별 실행 검증 필요 |
|
||||
|
||||
## 14. 조사 근거 및 재검증 자료
|
||||
|
||||
| 구분 | 자료 | 확인 내용 |
|
||||
|---|---|---|
|
||||
| Autodesk 도움말 | [AutoCAD 2024 한국어 도움말](https://help.autodesk.com/view/ACD/2024/KOR/) | 기본 AutoCAD 명령·작업 절차·UI 기능 |
|
||||
| Autodesk 공식 PDF | [AutoCAD 2024 Shortcuts Guide](https://damassets.autodesk.net/content/dam/autodesk/www/shortcuts/autocad/autocad-shortcut-guide-en.pdf) | 그리기·수정·주석·도면층·조회 등 주요 명령 교차 확인 |
|
||||
| Autodesk 공식 블로그 | [Essential Keyboard Shortcuts and Commands](https://www.autodesk.com/blogs/autocad/work-faster-in-autocad-essential-keyboard-shortcuts-and-commands/) | 대표 명령의 작업별 분류와 짧은 기능 설명 교차 확인 |
|
||||
| Autodesk 도움말 | [블록 삽입 정보](https://help.autodesk.com/cloudhelp/2024/KOR/AutoCAD-Core/files/GUID-BC0FD3C1-3BFC-4C5D-AB9A-BF480D5084BE.htm) | 홈 탭 블록 패널, 블록 갤러리와 팔레트 |
|
||||
| Autodesk 도움말 | [외부 참조 경로로 작업하려면](https://help.autodesk.com/cloudhelp/2024/KOR/AutoCAD-Core/files/GUID-97F90017-E17A-4209-A6BD-1E205BF892E1.htm) | 뷰 탭 팔레트 패널과 외부 참조 관리 |
|
||||
| Autodesk 도움말 | [도면 비교를 통해 작업하려면](https://help.autodesk.com/cloudhelp/2024/KOR/AutoCAD-Core/files/GUID-744D8A49-D582-4C06-80B1-6B0313E92CA2.htm) | 뷰·공동작업 탭의 DWG 비교 기능 |
|
||||
| Autodesk 도움말 | [리본 사용자화 FAQ](https://help.autodesk.com/cloudhelp/2024/KOR/AutoCAD-Core/files/GUID-7711CB8D-C865-42C1-9F21-7179FA7BBE63.htm) | 리본 탭·패널·명령의 사용자화 가능 범위 |
|
||||
| Autodesk 도움말 | [명령 매크로 정보](https://help.autodesk.com/cloudhelp/2024/KOR/AutoCAD-Core/files/GUID-6B27C0A2-6446-4C75-9853-6834F4FFFF89.htm) | 뷰·자동화 탭의 명령 매크로 팔레트 |
|
||||
| Autodesk 도움말 | [점에서 끊기](https://help.autodesk.com/cloudhelp/2024/ENU/AutoCAD-DidYouKnow/files/GUID-73B7DEA8-309E-4823-9F65-DB0A2E1FBC75.htm) | `BREAKATPOINT`의 지원 버전과 홈 탭 위치 |
|
||||
| Autodesk 도움말 | [파라메트릭 도면과 구속조건](https://help.autodesk.com/cloudhelp/2023/ENU/AutoCAD-Core/files/GUID-899E008D-B422-4DF2-AC8D-1A4F5701ED4E.htm) | 기하·치수 구속의 범위와 동작 |
|
||||
| Autodesk 도움말 | [Markup Import와 Markup Assist](https://help.autodesk.com/cloudhelp/2024/ENU/AutoCAD-Platform/files/GUID-0CD6B2CE-EAF0-478B-B39B-F4821F9DC91D.htm) | 표식 가져오기·인식·반영 기능과 제약 |
|
||||
| Autodesk 도움말 | [Activity Insights](https://help.autodesk.com/cloudhelp/2024/ENU/AutoCAD-WhatsNew/files/GUID-074F77C1-496D-49D2-8D64-FD545753CAB7.htm) | 2024 작업 이벤트 기록 기능과 리본 위치 |
|
||||
| Autodesk 도움말 | [Drawing History 팔레트](https://help.autodesk.com/cloudhelp/2024/ENU/AutoCAD-Core/files/GUID-6916CA9F-0BD1-49ED-9DA4-2FA3615998DD.htm) | 지원 클라우드 도면의 버전 조회·비교 기능 |
|
||||
| Autodesk 도움말 | [Shared Views 명령](https://help.autodesk.com/cloudhelp/2024/ENU/AutoCAD-Core/files/GUID-C9055F88-D079-441F-938B-37A7E0E390D9.htm) | 온라인 검토 뷰와 의견 교환 기능 |
|
||||
| Autodesk 도움말 | [Action Macro 기록](https://help.autodesk.com/cloudhelp/2024/ENU/AutoCAD-Customization/files/GUID-FEAD3614-CD33-4B60-BC00-4CBC98D8CBCB.htm) | 동작 기록기의 명령·입력 기록 및 재생 기능 |
|
||||
| Autodesk 도움말 | [Purge 대화상자](https://help.autodesk.com/cloudhelp/2024/ENU/AutoCAD-Core/files/GUID-C6B62BBD-C363-4DC7-AF43-036A7B287473.htm) | 미사용 명명 객체와 고아 데이터 정리 기능 |
|
||||
| Autodesk 도움말 | [AutoCAD 2024 새 명령·시스템 변수](https://help.autodesk.com/cloudhelp/2025/ENU/AutoCAD-Core/files/GUID-B93A458E-1A7F-4090-A8CF-87A31C24E404.htm) | 2024의 Activity Insights·Smart Blocks·Trace·Markup 명령 대조 |
|
||||
| Autodesk 도움말 | [AutoCAD 2024 변경 명령·시스템 변수](https://help.autodesk.com/cloudhelp/2025/ENU/AutoCAD-Core/files/GUID-4FEBA606-95E0-4DC4-A116-257ED86DCD58.htm) | `INSERT`·`COUNT`·`TRACE`·`SHARE` 변경 내용 대조 |
|
||||
| Autodesk 도움말 | [COUNT 명령](https://help.autodesk.com/cloudhelp/2024/ENU/AutoCAD-Core/files/GUID-3A0C3460-6ABC-4D13-BF1F-D2BFCD399851.htm) | 동일 객체·블록 개수 계산 기능 |
|
||||
| Autodesk 도움말 | [객체 개수로 작업](https://help.autodesk.com/cloudhelp/2022/ENU/AutoCAD-Core/files/GUID-27873872-0DB9-4EE1-97F5-43F91F9C3785.htm) | 뷰 탭 팔레트 패널의 Count 위치와 검토 기능 |
|
||||
| Autodesk 도움말 | [OVERKILL 명령](https://help.autodesk.com/cloudhelp/2022/ENU/AutoCAD-Core/files/GUID-44B9ECFC-752C-4CC5-9DA3-84DBF3B17CA6.htm) | 중복·겹침 객체 제거 및 결합 기능 |
|
||||
| Autodesk 도움말 | [Smart Blocks 교체](https://help.autodesk.com/cloudhelp/2024/ENU/AutoCAD-WhatsNew/files/GUID-4F8E11D6-917A-43F3-B596-C8F296D32519.htm) | `BREPLACE`의 제안·교체와 특성 유지 동작 |
|
||||
| Autodesk 도움말 | [Trace 업데이트](https://help.autodesk.com/cloudhelp/2024/ENU/AutoCAD-WhatsNew/files/GUID-F9C11306-0F53-4F1A-92C7-8D77EA42823A.htm) | `COPYFROMTRACE`와 추적 화면표시 설정 |
|
||||
| Autodesk 도움말 | [상태막대 빠른 참조](https://help.autodesk.com/cloudhelp/2022/ENU/AutoCAD-Core/files/GUID-E3B34B0A-EA98-45E9-937A-BF5FEF4152DB.htm) | 스냅·직교·극좌표·동적 입력 등 제도 토글의 기능 |
|
||||
| Autodesk 도움말 | [열린 도면 복구](https://help.autodesk.com/cloudhelp/2024/ENU/AutoCAD-LT/files/GUID-7AFE414B-BCC6-470C-B0E9-8228A252EE7C.htm) | 응용프로그램 메뉴의 `AUDIT` 위치와 `RECOVER` 구분 |
|
||||
Reference in New Issue
Block a user