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('그룹을 해제했습니다.');
|
||||
},
|
||||
});
|
||||
Reference in New Issue
Block a user