diff --git a/B07_DesignDetail/openwebcad/src/App.css b/B07_DesignDetail/openwebcad/src/App.css index 92bf0607..6c7cc9b3 100644 --- a/B07_DesignDetail/openwebcad/src/App.css +++ b/B07_DesignDetail/openwebcad/src/App.css @@ -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; +} diff --git a/B07_DesignDetail/openwebcad/src/App.types.ts b/B07_DesignDetail/openwebcad/src/App.types.ts index 0cbb3e57..fb521756 100644 --- a/B07_DesignDetail/openwebcad/src/App.types.ts +++ b/B07_DesignDetail/openwebcad/src/App.types.ts @@ -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 { diff --git a/B07_DesignDetail/openwebcad/src/commands/command.types.ts b/B07_DesignDetail/openwebcad/src/commands/command.types.ts new file mode 100644 index 00000000..e7d3a807 --- /dev/null +++ b/B07_DesignDetail/openwebcad/src/commands/command.types.ts @@ -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; +} diff --git a/B07_DesignDetail/openwebcad/src/commands/commands.annotate.ts b/B07_DesignDetail/openwebcad/src/commands/commands.annotate.ts new file mode 100644 index 00000000..631aadf5 --- /dev/null +++ b/B07_DesignDetail/openwebcad/src/commands/commands.annotate.ts @@ -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, + }, +]; diff --git a/B07_DesignDetail/openwebcad/src/commands/commands.draw.ts b/B07_DesignDetail/openwebcad/src/commands/commands.draw.ts new file mode 100644 index 00000000..2bd50c85 --- /dev/null +++ b/B07_DesignDetail/openwebcad/src/commands/commands.draw.ts @@ -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, + }, +]; diff --git a/B07_DesignDetail/openwebcad/src/commands/commands.file.ts b/B07_DesignDetail/openwebcad/src/commands/commands.file.ts new file mode 100644 index 00000000..71c1c3a8 --- /dev/null +++ b/B07_DesignDetail/openwebcad/src/commands/commands.file.ts @@ -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 '다시 실행'; + }, + }, +]; diff --git a/B07_DesignDetail/openwebcad/src/commands/commands.insert.ts b/B07_DesignDetail/openwebcad/src/commands/commands.insert.ts new file mode 100644 index 00000000..5e64ba67 --- /dev/null +++ b/B07_DesignDetail/openwebcad/src/commands/commands.insert.ts @@ -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, + }, +]; diff --git a/B07_DesignDetail/openwebcad/src/commands/commands.layer.ts b/B07_DesignDetail/openwebcad/src/commands/commands.layer.ts new file mode 100644 index 00000000..d5ea2b0f --- /dev/null +++ b/B07_DesignDetail/openwebcad/src/commands/commands.layer.ts @@ -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, + }, +]; diff --git a/B07_DesignDetail/openwebcad/src/commands/commands.modify.ts b/B07_DesignDetail/openwebcad/src/commands/commands.modify.ts new file mode 100644 index 00000000..14e078a6 --- /dev/null +++ b/B07_DesignDetail/openwebcad/src/commands/commands.modify.ts @@ -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, + }, +]; diff --git a/B07_DesignDetail/openwebcad/src/commands/commands.utility.ts b/B07_DesignDetail/openwebcad/src/commands/commands.utility.ts new file mode 100644 index 00000000..038e923e --- /dev/null +++ b/B07_DesignDetail/openwebcad/src/commands/commands.utility.ts @@ -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, + }, +]; diff --git a/B07_DesignDetail/openwebcad/src/commands/commands.view.ts b/B07_DesignDetail/openwebcad/src/commands/commands.view.ts new file mode 100644 index 00000000..6f1fd7ad --- /dev/null +++ b/B07_DesignDetail/openwebcad/src/commands/commands.view.ts @@ -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 '초점이동 안내'; + }, + }, +]; diff --git a/B07_DesignDetail/openwebcad/src/commands/dim-settings.ts b/B07_DesignDetail/openwebcad/src/commands/dim-settings.ts new file mode 100644 index 00000000..d8b8ef85 --- /dev/null +++ b/B07_DesignDetail/openwebcad/src/commands/dim-settings.ts @@ -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; +} diff --git a/B07_DesignDetail/openwebcad/src/commands/draw-settings.ts b/B07_DesignDetail/openwebcad/src/commands/draw-settings.ts new file mode 100644 index 00000000..f33a3e09 --- /dev/null +++ b/B07_DesignDetail/openwebcad/src/commands/draw-settings.ts @@ -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); diff --git a/B07_DesignDetail/openwebcad/src/commands/registry.ts b/B07_DesignDetail/openwebcad/src/commands/registry.ts new file mode 100644 index 00000000..9b976b78 --- /dev/null +++ b/B07_DesignDetail/openwebcad/src/commands/registry.ts @@ -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( + ALL_COMMANDS.map((command) => [command.id, command]) +); + +const COMMANDS_BY_ALIAS = new Map(); +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; + +/** 명령행 입력 해석 — 정확한 이름·별칭이 먼저, 없으면 접두사 후보의 첫 항목 */ +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}`; diff --git a/B07_DesignDetail/openwebcad/src/commands/run-command.ts b/B07_DesignDetail/openwebcad/src/commands/run-command.ts new file mode 100644 index 00000000..89f486e3 --- /dev/null +++ b/B07_DesignDetail/openwebcad/src/commands/run-command.ts @@ -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; +} diff --git a/B07_DesignDetail/openwebcad/src/components/CommandLine.tsx b/B07_DesignDetail/openwebcad/src/components/CommandLine.tsx new file mode 100644 index 00000000..8a3c73e7 --- /dev/null +++ b/B07_DesignDetail/openwebcad/src/components/CommandLine.tsx @@ -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(null); + const inputRef = useRef(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 ( +
+
+ {history.slice(-3).map((line, index) => ( + // biome-ignore lint/suspicious/noArrayIndexKey: 기록은 순서 자체가 식별자다 + {line} + ))} +
+
+ + 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} + /> + {instruction} +
+ {text.trim() && suggestions.length > 0 && ( +
+ {suggestions.map((command) => ( + + ))} +
+ )} +
+ ); +}; diff --git a/B07_DesignDetail/openwebcad/src/components/InspectorPanel.tsx b/B07_DesignDetail/openwebcad/src/components/InspectorPanel.tsx new file mode 100644 index 00000000..0eb76091 --- /dev/null +++ b/B07_DesignDetail/openwebcad/src/components/InspectorPanel.tsx @@ -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 = ({ collapsed, onToggleCollapsed }) => { + const tab = getInspectorTab(); + const layers = getLayers(); + const activeLayerId = getActiveLayerId(); + + return ( + + ); +}; diff --git a/B07_DesignDetail/openwebcad/src/components/LayerManager.tsx b/B07_DesignDetail/openwebcad/src/components/LayerManager.tsx index c0962b7b..6b2c865a 100644 --- a/B07_DesignDetail/openwebcad/src/components/LayerManager.tsx +++ b/B07_DesignDetail/openwebcad/src/components/LayerManager.tsx @@ -93,7 +93,7 @@ export const LayerManager: FC = ({ 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={ <> + ); + })} + +
+ + 현재 도면 +
+ +); diff --git a/B07_DesignDetail/openwebcad/src/components/QuickProperties.tsx b/B07_DesignDetail/openwebcad/src/components/QuickProperties.tsx new file mode 100644 index 00000000..b4f5c47f --- /dev/null +++ b/B07_DesignDetail/openwebcad/src/components/QuickProperties.tsx @@ -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 ( +
+
+ 빠른 특성 + +
+ +
+ ); +}; diff --git a/B07_DesignDetail/openwebcad/src/components/RibbonWidgets.tsx b/B07_DesignDetail/openwebcad/src/components/RibbonWidgets.tsx new file mode 100644 index 00000000..1b05d47a --- /dev/null +++ b/B07_DesignDetail/openwebcad/src/components/RibbonWidgets.tsx @@ -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 ( +
+ + + +
+ ); +}; + +export const LayersWidget: FC = () => { + const layers = getLayers(); + const activeLayerId = getActiveLayerId(); + + return ( +
+ + +
+ ); +}; + +export const TextStyleWidget: FC = () => { + const textStyle = getActiveTextStyle(); + + const handle = (patch: Partial) => { + setActiveTextStyle(patch); + applyToSelection((entity) => { + if (entity.getType() === EntityName.Text) { + (entity as TextEntity).setTextOptions(patch); + } + }); + }; + + return ( +
+ + + +
+ ); +}; diff --git a/B07_DesignDetail/openwebcad/src/components/StatusBar.tsx b/B07_DesignDetail/openwebcad/src/components/StatusBar.tsx new file mode 100644 index 00000000..8183eb9f --- /dev/null +++ b/B07_DesignDetail/openwebcad/src/components/StatusBar.tsx @@ -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 = ({ commandLineVisible, onToggleCommandLine }) => { + const snap = getSnapEnabled(); + const grid = getGridEnabled(); + const ortho = getAngleStep() === 90; + + return ( +
+ + + + + + 휠: 줌 · 휠 드래그: 초점이동 · Esc: 취소 +
+ ); +}; diff --git a/B07_DesignDetail/openwebcad/src/components/Toolbar.tsx b/B07_DesignDetail/openwebcad/src/components/Toolbar.tsx index e10d1b97..f3257f05 100644 --- a/B07_DesignDetail/openwebcad/src/components/Toolbar.tsx +++ b/B07_DesignDetail/openwebcad/src/components/Toolbar.tsx @@ -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.LINE); - const [zoom, setZoom] = useState(1); - const [layers, setLayersLocal] = useState(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) => { - 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 ( <> -
-
- Aislo CAD - B07 상세 설계 -
-
- - 현재 도면 · 저장됨 -
-
- - - - -
-
- - - - - -
- - - {Math.round(zoom * 100)}% - -
- -
-
- {commandLog} - {instruction} -
-
- - setCommand(event.target.value)} - placeholder="명령 입력 (예: LINE, MOVE, CIRCLE)" - autoComplete="off" - /> - - {COMMANDS.map((item) => ( - -
-
- -
- - - - 휠: 줌 · 휠 드래그: 팬 · Esc: 취소 -
+ + , + layers: , + textStyle: , + }} + /> + setPanelCollapsed((value) => !value)} + /> + + + {commandLineVisible && } + setCommandLineVisible((value) => !value)} + /> ); }; diff --git a/B07_DesignDetail/openwebcad/src/components/ViewControls.tsx b/B07_DesignDetail/openwebcad/src/components/ViewControls.tsx new file mode 100644 index 00000000..6b0b707a --- /dev/null +++ b/B07_DesignDetail/openwebcad/src/components/ViewControls.tsx @@ -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 ( +
+ + + {Math.round(zoom * 100)}% + +
+ ); +}; diff --git a/B07_DesignDetail/openwebcad/src/components/ui-state.ts b/B07_DesignDetail/openwebcad/src/components/ui-state.ts new file mode 100644 index 00000000..fe5f041c --- /dev/null +++ b/B07_DesignDetail/openwebcad/src/components/ui-state.ts @@ -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(); +} diff --git a/B07_DesignDetail/openwebcad/src/components/use-cad-refresh.ts b/B07_DesignDetail/openwebcad/src/components/use-cad-refresh.ts new file mode 100644 index 00000000..7b00eb30 --- /dev/null +++ b/B07_DesignDetail/openwebcad/src/components/use-cad-refresh.ts @@ -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; +} diff --git a/B07_DesignDetail/openwebcad/src/drawControllers/DrawController.ts b/B07_DesignDetail/openwebcad/src/drawControllers/DrawController.ts index 5d210b8f..11275dcb 100644 --- a/B07_DesignDetail/openwebcad/src/drawControllers/DrawController.ts +++ b/B07_DesignDetail/openwebcad/src/drawControllers/DrawController.ts @@ -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( diff --git a/B07_DesignDetail/openwebcad/src/drawControllers/screenCanvas.drawController.ts b/B07_DesignDetail/openwebcad/src/drawControllers/screenCanvas.drawController.ts index 4d3fcd71..b2532e21 100644 --- a/B07_DesignDetail/openwebcad/src/drawControllers/screenCanvas.drawController.ts +++ b/B07_DesignDetail/openwebcad/src/drawControllers/screenCanvas.drawController.ts @@ -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); } diff --git a/B07_DesignDetail/openwebcad/src/drawControllers/svg.drawController.ts b/B07_DesignDetail/openwebcad/src/drawControllers/svg.drawController.ts index 51343268..f15d3271 100644 --- a/B07_DesignDetail/openwebcad/src/drawControllers/svg.drawController.ts +++ b/B07_DesignDetail/openwebcad/src/drawControllers/svg.drawController.ts @@ -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' || diff --git a/B07_DesignDetail/openwebcad/src/entities/ArcEntity.ts b/B07_DesignDetail/openwebcad/src/entities/ArcEntity.ts index f8b42890..f5e9e49c 100644 --- a/B07_DesignDetail/openwebcad/src/entities/ArcEntity.ts +++ b/B07_DesignDetail/openwebcad/src/entities/ArcEntity.ts @@ -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; diff --git a/B07_DesignDetail/openwebcad/src/entities/ArrowHeadEntity.ts b/B07_DesignDetail/openwebcad/src/entities/ArrowHeadEntity.ts index 63a58116..a96ae6aa 100644 --- a/B07_DesignDetail/openwebcad/src/entities/ArrowHeadEntity.ts +++ b/B07_DesignDetail/openwebcad/src/entities/ArrowHeadEntity.ts @@ -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( diff --git a/B07_DesignDetail/openwebcad/src/entities/CircleEntity.ts b/B07_DesignDetail/openwebcad/src/entities/CircleEntity.ts index 9beee87b..6df5c05a 100644 --- a/B07_DesignDetail/openwebcad/src/entities/CircleEntity.ts +++ b/B07_DesignDetail/openwebcad/src/entities/CircleEntity.ts @@ -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; diff --git a/B07_DesignDetail/openwebcad/src/entities/Entity.ts b/B07_DesignDetail/openwebcad/src/entities/Entity.ts index 2a8eb6af..3404c667 100644 --- a/B07_DesignDetail/openwebcad/src/entities/Entity.ts +++ b/B07_DesignDetail/openwebcad/src/entities/Entity.ts @@ -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 { diff --git a/B07_DesignDetail/openwebcad/src/entities/HatchEntity.ts b/B07_DesignDetail/openwebcad/src/entities/HatchEntity.ts new file mode 100644 index 00000000..d15fcc23 --- /dev/null +++ b/B07_DesignDetail/openwebcad/src/entities/HatchEntity.ts @@ -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) { + 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 ``; + } + + 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 | 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): Promise { + 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; +} diff --git a/B07_DesignDetail/openwebcad/src/entities/ImageEntity.ts b/B07_DesignDetail/openwebcad/src/entities/ImageEntity.ts index ad9f5f91..058439f4 100644 --- a/B07_DesignDetail/openwebcad/src/entities/ImageEntity.ts +++ b/B07_DesignDetail/openwebcad/src/entities/ImageEntity.ts @@ -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; diff --git a/B07_DesignDetail/openwebcad/src/entities/LineEntity.ts b/B07_DesignDetail/openwebcad/src/entities/LineEntity.ts index 71ca1ace..590a5d1b 100644 --- a/B07_DesignDetail/openwebcad/src/entities/LineEntity.ts +++ b/B07_DesignDetail/openwebcad/src/entities/LineEntity.ts @@ -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; diff --git a/B07_DesignDetail/openwebcad/src/entities/MeasurementEntity.ts b/B07_DesignDetail/openwebcad/src/entities/MeasurementEntity.ts index a6a8ea13..ea3a408e 100644 --- a/B07_DesignDetail/openwebcad/src/entities/MeasurementEntity.ts +++ b/B07_DesignDetail/openwebcad/src/entities/MeasurementEntity.ts @@ -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; diff --git a/B07_DesignDetail/openwebcad/src/entities/PointEntity.ts b/B07_DesignDetail/openwebcad/src/entities/PointEntity.ts index c522343a..bab2e201 100644 --- a/B07_DesignDetail/openwebcad/src/entities/PointEntity.ts +++ b/B07_DesignDetail/openwebcad/src/entities/PointEntity.ts @@ -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; diff --git a/B07_DesignDetail/openwebcad/src/entities/PolyLineEntity.ts b/B07_DesignDetail/openwebcad/src/entities/PolyLineEntity.ts index 68284d08..34a7c035 100644 --- a/B07_DesignDetail/openwebcad/src/entities/PolyLineEntity.ts +++ b/B07_DesignDetail/openwebcad/src/entities/PolyLineEntity.ts @@ -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; } diff --git a/B07_DesignDetail/openwebcad/src/entities/RectangleEntity.ts b/B07_DesignDetail/openwebcad/src/entities/RectangleEntity.ts index 69d86bd5..70affaec 100644 --- a/B07_DesignDetail/openwebcad/src/entities/RectangleEntity.ts +++ b/B07_DesignDetail/openwebcad/src/entities/RectangleEntity.ts @@ -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; diff --git a/B07_DesignDetail/openwebcad/src/entities/TextEntity.ts b/B07_DesignDetail/openwebcad/src/entities/TextEntity.ts index 33a8fb92..63e3a9ef 100644 --- a/B07_DesignDetail/openwebcad/src/entities/TextEntity.ts +++ b/B07_DesignDetail/openwebcad/src/entities/TextEntity.ts @@ -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( diff --git a/B07_DesignDetail/openwebcad/src/helpers/cad-clipboard.ts b/B07_DesignDetail/openwebcad/src/helpers/cad-clipboard.ts new file mode 100644 index 00000000..3667eec5 --- /dev/null +++ b/B07_DesignDetail/openwebcad/src/helpers/cad-clipboard.ts @@ -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; + }); +} diff --git a/B07_DesignDetail/openwebcad/src/helpers/debug-hook.ts b/B07_DesignDetail/openwebcad/src/helpers/debug-hook.ts new file mode 100644 index 00000000..e3d4a835 --- /dev/null +++ b/B07_DesignDetail/openwebcad/src/helpers/debug-hook.ts @@ -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).__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(), + }; +} diff --git a/B07_DesignDetail/openwebcad/src/helpers/draw-functions.ts b/B07_DesignDetail/openwebcad/src/helpers/draw-functions.ts index 71ff2abe..c5f93a1e 100644 --- a/B07_DesignDetail/openwebcad/src/helpers/draw-functions.ts +++ b/B07_DesignDetail/openwebcad/src/helpers/draw-functions.ts @@ -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); } } diff --git a/B07_DesignDetail/openwebcad/src/helpers/entity-groups.ts b/B07_DesignDetail/openwebcad/src/helpers/entity-groups.ts new file mode 100644 index 00000000..ac69c0ac --- /dev/null +++ b/B07_DesignDetail/openwebcad/src/helpers/entity-groups.ts @@ -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]; +} diff --git a/B07_DesignDetail/openwebcad/src/helpers/geometry/entity-loop.ts b/B07_DesignDetail/openwebcad/src/helpers/geometry/entity-loop.ts new file mode 100644 index 00000000..6cd36aa1 --- /dev/null +++ b/B07_DesignDetail/openwebcad/src/helpers/geometry/entity-loop.ts @@ -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()]; +} diff --git a/B07_DesignDetail/openwebcad/src/helpers/geometry/hatch-lines.ts b/B07_DesignDetail/openwebcad/src/helpers/geometry/hatch-lines.ts new file mode 100644 index 00000000..07c29145 --- /dev/null +++ b/B07_DesignDetail/openwebcad/src/helpers/geometry/hatch-lines.ts @@ -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; +} diff --git a/B07_DesignDetail/openwebcad/src/helpers/geometry/sample-entity.ts b/B07_DesignDetail/openwebcad/src/helpers/geometry/sample-entity.ts new file mode 100644 index 00000000..788d4891 --- /dev/null +++ b/B07_DesignDetail/openwebcad/src/helpers/geometry/sample-entity.ts @@ -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; +} diff --git a/B07_DesignDetail/openwebcad/src/helpers/geometry/shape-points.ts b/B07_DesignDetail/openwebcad/src/helpers/geometry/shape-points.ts new file mode 100644 index 00000000..94bc2ac9 --- /dev/null +++ b/B07_DesignDetail/openwebcad/src/helpers/geometry/shape-points.ts @@ -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); +} diff --git a/B07_DesignDetail/openwebcad/src/helpers/import-export-handlers/import-entities-from-json.ts b/B07_DesignDetail/openwebcad/src/helpers/import-export-handlers/import-entities-from-json.ts index 1bae91ca..fd4519da 100644 --- a/B07_DesignDetail/openwebcad/src/helpers/import-export-handlers/import-entities-from-json.ts +++ b/B07_DesignDetail/openwebcad/src/helpers/import-export-handlers/import-entities-from-json.ts @@ -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); case EntityName.PolyLine: return PolyLineEntity.fromJson(entity as JsonEntity); + case EntityName.Hatch: + return HatchEntity.fromJson(entity as JsonEntity); default: throw new Error(`Invalid entity type: ${entity.type}`); diff --git a/B07_DesignDetail/openwebcad/src/helpers/layer-history.ts b/B07_DesignDetail/openwebcad/src/helpers/layer-history.ts new file mode 100644 index 00000000..b42befff --- /dev/null +++ b/B07_DesignDetail/openwebcad/src/helpers/layer-history.ts @@ -0,0 +1,32 @@ +/** + * 도면층 상태 기록 — 이전 상태(LAYERP)와 이름 붙인 상태(LAYERSTATE). + * 도면층 목록 자체를 통째로 복사해 둔다 (개수가 적어 비용이 무시할 만하다). + */ +import type { Layer } from '../App.types'; + +const HISTORY_LIMIT = 20; + +const history: Layer[][] = []; +const namedStates = new Map(); + +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()]; diff --git a/B07_DesignDetail/openwebcad/src/helpers/visibility.ts b/B07_DesignDetail/openwebcad/src/helpers/visibility.ts new file mode 100644 index 00000000..f70854eb --- /dev/null +++ b/B07_DesignDetail/openwebcad/src/helpers/visibility.ts @@ -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(); + +export const isEntityHidden = (entity: Entity): boolean => hiddenEntityIds.has(entity.id); +export const getHiddenEntityCount = (): number => hiddenEntityIds.size; + +function apply(ids: Set): 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()); diff --git a/B07_DesignDetail/openwebcad/src/inputController/input-controller.ts b/B07_DesignDetail/openwebcad/src/inputController/input-controller.ts index f70c8a9e..5c02726f 100644 --- a/B07_DesignDetail/openwebcad/src/inputController/input-controller.ts +++ b/B07_DesignDetail/openwebcad/src/inputController/input-controller.ts @@ -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: - 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: ', { diff --git a/B07_DesignDetail/openwebcad/src/main.tsx b/B07_DesignDetail/openwebcad/src/main.tsx index bbc02783..98f6de97 100644 --- a/B07_DesignDetail/openwebcad/src/main.tsx +++ b/B07_DesignDetail/openwebcad/src/main.tsx @@ -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); diff --git a/B07_DesignDetail/openwebcad/src/ribbon/Ribbon.tsx b/B07_DesignDetail/openwebcad/src/ribbon/Ribbon.tsx new file mode 100644 index 00000000..cbf9ad42 --- /dev/null +++ b/B07_DesignDetail/openwebcad/src/ribbon/Ribbon.tsx @@ -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>; + onCommandRun?: (command: CadCommand) => void; +} + +interface CommandButtonProps { + command: CadCommand; + size: 'big' | 'small'; + activeTool: Tool | null; + onRun: (command: CadCommand) => void; +} + +const CommandButton: FC = ({ command, size, activeTool, onRun }) => ( + +); + +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>; + onRun: (command: CadCommand) => void; +}> = ({ panel, activeTool, widgets, onRun }) => { + const [overflowOpen, setOverflowOpen] = useState(false); + const overflowCommands = resolve(panel.overflow); + + return ( +
+
+ {panel.widget ? ( + (widgets[panel.widget] ?? null) + ) : ( + <> + {resolve(panel.big).map((command) => ( + + ))} +
+ {resolve(panel.commands).map((command) => ( + + ))} +
+ + )} +
+ + {overflowOpen && overflowCommands.length > 0 && ( +
+ {overflowCommands.map((command) => ( + + ))} +
+ )} +
+ ); +}; + +export const Ribbon: FC = ({ 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 ( + + ); +}; diff --git a/B07_DesignDetail/openwebcad/src/ribbon/ribbon.config.ts b/B07_DesignDetail/openwebcad/src/ribbon/ribbon.config.ts new file mode 100644 index 00000000..ae08bffd --- /dev/null +++ b/B07_DesignDetail/openwebcad/src/ribbon/ribbon.config.ts @@ -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']; diff --git a/B07_DesignDetail/openwebcad/src/tools.ts b/B07_DesignDetail/openwebcad/src/tools.ts index f103c202..fe2d15ca 100644 --- a/B07_DesignDetail/openwebcad/src/tools.ts +++ b/B07_DesignDetail/openwebcad/src/tools.ts @@ -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' } diff --git a/B07_DesignDetail/openwebcad/src/tools/annotate/annotation.helpers.ts b/B07_DesignDetail/openwebcad/src/tools/annotate/annotation.helpers.ts new file mode 100644 index 00000000..fbdbf407 --- /dev/null +++ b/B07_DesignDetail/openwebcad/src/tools/annotate/annotation.helpers.ts @@ -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; diff --git a/B07_DesignDetail/openwebcad/src/tools/annotate/dimension-radial-tools.ts b/B07_DesignDetail/openwebcad/src/tools/annotate/dimension-radial-tools.ts new file mode 100644 index 00000000..8e094c2e --- /dev/null +++ b/B07_DesignDetail/openwebcad/src/tools/annotate/dimension-radial-tools.ts @@ -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([]); + }, +}); diff --git a/B07_DesignDetail/openwebcad/src/tools/annotate/dimension-tools.ts b/B07_DesignDetail/openwebcad/src/tools/annotate/dimension-tools.ts new file mode 100644 index 00000000..c29a53da --- /dev/null +++ b/B07_DesignDetail/openwebcad/src/tools/annotate/dimension-tools.ts @@ -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); + }, +}); diff --git a/B07_DesignDetail/openwebcad/src/tools/annotate/leader-table-tools.ts b/B07_DesignDetail/openwebcad/src/tools/annotate/leader-table-tools.ts new file mode 100644 index 00000000..d0c46cf4 --- /dev/null +++ b/B07_DesignDetail/openwebcad/src/tools/annotate/leader-table-tools.ts @@ -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); + }, +}); diff --git a/B07_DesignDetail/openwebcad/src/tools/annotate/text-tools.ts b/B07_DesignDetail/openwebcad/src/tools/annotate/text-tools.ts new file mode 100644 index 00000000..00305825 --- /dev/null +++ b/B07_DesignDetail/openwebcad/src/tools/annotate/text-tools.ts @@ -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}개 문자를 찾았습니다.`); + }, +}); diff --git a/B07_DesignDetail/openwebcad/src/tools/draw/basic-draw-tools.ts b/B07_DesignDetail/openwebcad/src/tools/draw/basic-draw-tools.ts new file mode 100644 index 00000000..69be6e07 --- /dev/null +++ b/B07_DesignDetail/openwebcad/src/tools/draw/basic-draw-tools.ts @@ -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[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; +} diff --git a/B07_DesignDetail/openwebcad/src/tools/draw/construction-tools.ts b/B07_DesignDetail/openwebcad/src/tools/draw/construction-tools.ts new file mode 100644 index 00000000..01209cce --- /dev/null +++ b/B07_DesignDetail/openwebcad/src/tools/draw/construction-tools.ts @@ -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); + }, +}); diff --git a/B07_DesignDetail/openwebcad/src/tools/draw/divide-tools.ts b/B07_DesignDetail/openwebcad/src/tools/draw/divide-tools.ts new file mode 100644 index 00000000..8199865f --- /dev/null +++ b/B07_DesignDetail/openwebcad/src/tools/draw/divide-tools.ts @@ -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); + }, +}); diff --git a/B07_DesignDetail/openwebcad/src/tools/draw/fill-tools.ts b/B07_DesignDetail/openwebcad/src/tools/draw/fill-tools.ts new file mode 100644 index 00000000..c0de937a --- /dev/null +++ b/B07_DesignDetail/openwebcad/src/tools/draw/fill-tools.ts @@ -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([]); + }, +}); diff --git a/B07_DesignDetail/openwebcad/src/tools/factories/entity-factory.ts b/B07_DesignDetail/openwebcad/src/tools/factories/entity-factory.ts new file mode 100644 index 00000000..98a352d4 --- /dev/null +++ b/B07_DesignDetail/openwebcad/src/tools/factories/entity-factory.ts @@ -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(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): HatchEntity => + styled(new HatchEntity(getActiveLayerId(), points, options)); + +export const textEntity = (label: string, basePoint: Point, options?: Partial) => + styled( + new TextEntity(getActiveLayerId(), label, basePoint, { + ...getActiveTextStyle(), + ...options, + }) + ); diff --git a/B07_DesignDetail/openwebcad/src/tools/factories/sequence-tool.ts b/B07_DesignDetail/openwebcad/src/tools/factories/sequence-tool.ts new file mode 100644 index 00000000..b7d02b93 --- /dev/null +++ b/B07_DesignDetail/openwebcad/src/tools/factories/sequence-tool.ts @@ -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 = (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(index, '점', isPointValue), + points: () => values.filter((value) => isPointValue(value)) as Point[], + number: (index: number) => expect(index, '숫자', (v) => typeof v === 'number'), + text: (index: number) => expect(index, '문자', (v) => typeof v === 'string'), + entity: (index: number) => + expect(index, '객체', (v) => !!v && typeof (v as Entity).getType === 'function'), + entities: (index: number) => expect(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 = { + [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 = { 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) }, + } + ); +} diff --git a/B07_DesignDetail/openwebcad/src/tools/measurement-tool.ts b/B07_DesignDetail/openwebcad/src/tools/measurement-tool.ts deleted file mode 100644 index 4a7fe04c..00000000 --- a/B07_DesignDetail/openwebcad/src/tools/measurement-tool.ts +++ /dev/null @@ -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); - }, - }, - } -); diff --git a/B07_DesignDetail/openwebcad/src/tools/modify/corner-tools.ts b/B07_DesignDetail/openwebcad/src/tools/modify/corner-tools.ts new file mode 100644 index 00000000..892de32a --- /dev/null +++ b/B07_DesignDetail/openwebcad/src/tools/modify/corner-tools.ts @@ -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); + }, +}); diff --git a/B07_DesignDetail/openwebcad/src/tools/modify/corner.helpers.ts b/B07_DesignDetail/openwebcad/src/tools/modify/corner.helpers.ts new file mode 100644 index 00000000..4aec33db --- /dev/null +++ b/B07_DesignDetail/openwebcad/src/tools/modify/corner.helpers.ts @@ -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; +} diff --git a/B07_DesignDetail/openwebcad/src/tools/modify/modify.helpers.ts b/B07_DesignDetail/openwebcad/src/tools/modify/modify.helpers.ts new file mode 100644 index 00000000..d551b413 --- /dev/null +++ b/B07_DesignDetail/openwebcad/src/tools/modify/modify.helpers.ts @@ -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(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(); + 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; +} diff --git a/B07_DesignDetail/openwebcad/src/tools/modify/property-tools.ts b/B07_DesignDetail/openwebcad/src/tools/modify/property-tools.ts new file mode 100644 index 00000000..e5194d35 --- /dev/null +++ b/B07_DesignDetail/openwebcad/src/tools/modify/property-tools.ts @@ -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) .', + 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); + }, +}); diff --git a/B07_DesignDetail/openwebcad/src/tools/modify/structure-tools.ts b/B07_DesignDetail/openwebcad/src/tools/modify/structure-tools.ts new file mode 100644 index 00000000..b49e1bd3 --- /dev/null +++ b/B07_DesignDetail/openwebcad/src/tools/modify/structure-tools.ts @@ -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([]); + }, +}); diff --git a/B07_DesignDetail/openwebcad/src/tools/modify/transform-tools.ts b/B07_DesignDetail/openwebcad/src/tools/modify/transform-tools.ts new file mode 100644 index 00000000..be4cc0a1 --- /dev/null +++ b/B07_DesignDetail/openwebcad/src/tools/modify/transform-tools.ts @@ -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); + }, +}); diff --git a/B07_DesignDetail/openwebcad/src/tools/select-tool.helpers.ts b/B07_DesignDetail/openwebcad/src/tools/select-tool.helpers.ts index cf076847..54b2ab9c 100644 --- a/B07_DesignDetail/openwebcad/src/tools/select-tool.helpers.ts +++ b/B07_DesignDetail/openwebcad/src/tools/select-tool.helpers.ts @@ -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) { diff --git a/B07_DesignDetail/openwebcad/src/tools/tool.consts.ts b/B07_DesignDetail/openwebcad/src/tools/tool.consts.ts deleted file mode 100644 index ed85f72a..00000000 --- a/B07_DesignDetail/openwebcad/src/tools/tool.consts.ts +++ /dev/null @@ -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, - StateMachine< - // biome-ignore lint/suspicious/noExplicitAny: - any, - // biome-ignore lint/suspicious/noExplicitAny: - any, - // biome-ignore lint/suspicious/noExplicitAny: - any, - // biome-ignore lint/suspicious/noExplicitAny: - any, - // biome-ignore lint/suspicious/noExplicitAny: - any, - // biome-ignore lint/suspicious/noExplicitAny: - any, - // biome-ignore lint/suspicious/noExplicitAny: - any, - // biome-ignore lint/suspicious/noExplicitAny: - any, - // biome-ignore lint/suspicious/noExplicitAny: - any, - // biome-ignore lint/suspicious/noExplicitAny: - any, - // biome-ignore lint/suspicious/noExplicitAny: - any, - // biome-ignore lint/suspicious/noExplicitAny: - any, - // biome-ignore lint/suspicious/noExplicitAny: - any, - // biome-ignore lint/suspicious/noExplicitAny: - 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, -}; diff --git a/B07_DesignDetail/openwebcad/src/tools/utility/clipboard-tools.ts b/B07_DesignDetail/openwebcad/src/tools/utility/clipboard-tools.ts new file mode 100644 index 00000000..b20d8ecd --- /dev/null +++ b/B07_DesignDetail/openwebcad/src/tools/utility/clipboard-tools.ts @@ -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); + }, +}); diff --git a/B07_DesignDetail/openwebcad/src/tools/utility/inquiry-tools.ts b/B07_DesignDetail/openwebcad/src/tools/utility/inquiry-tools.ts new file mode 100644 index 00000000..84706b43 --- /dev/null +++ b/B07_DesignDetail/openwebcad/src/tools/utility/inquiry-tools.ts @@ -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) .', + 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); + }); + }, +}); diff --git a/B07_DesignDetail/openwebcad/src/tools/utility/layer-tools.ts b/B07_DesignDetail/openwebcad/src/tools/utility/layer-tools.ts new file mode 100644 index 00000000..2003969b --- /dev/null +++ b/B07_DesignDetail/openwebcad/src/tools/utility/layer-tools.ts @@ -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}`; +} diff --git a/B07_DesignDetail/openwebcad/src/tools/utility/property-tools.ts b/B07_DesignDetail/openwebcad/src/tools/utility/property-tools.ts new file mode 100644 index 00000000..32b97f24 --- /dev/null +++ b/B07_DesignDetail/openwebcad/src/tools/utility/property-tools.ts @@ -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 = { + RED: '#ff0000', + YELLOW: '#ffff00', + GREEN: '#00ff00', + CYAN: '#00ffff', + BLUE: '#0000ff', + MAGENTA: '#ff00ff', + WHITE: '#ffffff', + BLACK: '#000000', + GRAY: '#808080', +}; + +const LINE_DASHES: Record = { + 실선: 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}%를 적용했습니다.`); + }, +}); diff --git a/B07_DesignDetail/openwebcad/src/tools/utility/selection-tools.ts b/B07_DesignDetail/openwebcad/src/tools/utility/selection-tools.ts new file mode 100644 index 00000000..6988f0f9 --- /dev/null +++ b/B07_DesignDetail/openwebcad/src/tools/utility/selection-tools.ts @@ -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('그룹을 해제했습니다.'); + }, +}); diff --git a/B07_DesignDetail/openwebcad/test/helpers/init-application.ts b/B07_DesignDetail/openwebcad/test/helpers/init-application.ts index b16704d5..3d99fee4 100644 --- a/B07_DesignDetail/openwebcad/test/helpers/init-application.ts +++ b/B07_DesignDetail/openwebcad/test/helpers/init-application.ts @@ -4,7 +4,7 @@ import type {ScreenCanvasDrawController} from '../../src/drawControllers/screenC import {InputController} from '../../src/inputController/input-controller'; import {setActiveToolActor, setEntities, setInputController, setScreenCanvasDrawController,} from '../../src/state'; import {Tool} from '../../src/tools'; -import {TOOL_STATE_MACHINES} from '../../src/tools/tool.consts'; +import {TOOL_STATE_MACHINES} from '../../src/commands/registry'; import {ScreenCanvasDrawController as ScreenCanvasDrawControllerMock} from '../mocks/drawControllers/screenCanvas.drawController'; import {CANVAS_HEIGHT, CANVAS_WIDTH} from './tests.consts'; diff --git a/B07_DesignDetail/openwebcad/test/helpers/replay-recording.ts b/B07_DesignDetail/openwebcad/test/helpers/replay-recording.ts index 4d929143..a6711309 100644 --- a/B07_DesignDetail/openwebcad/test/helpers/replay-recording.ts +++ b/B07_DesignDetail/openwebcad/test/helpers/replay-recording.ts @@ -12,7 +12,7 @@ const DATA_ID_TO_TOOL_NAME: Record = { 'move-button': Tool.MOVE, 'scale-button': Tool.SCALE, 'rotate-button': Tool.ROTATE, - 'measurement-button': Tool.MEASUREMENT, + 'measurement-button': Tool.DIMALIGNED, 'undo-button': null, 'redo-button': null, 'delete-segment-button': Tool.ERASER, diff --git a/B07_DesignDetail/openwebcad/test/helpers/set-active-tool.ts b/B07_DesignDetail/openwebcad/test/helpers/set-active-tool.ts index 38cbbfb3..83b5cb3f 100644 --- a/B07_DesignDetail/openwebcad/test/helpers/set-active-tool.ts +++ b/B07_DesignDetail/openwebcad/test/helpers/set-active-tool.ts @@ -1,7 +1,7 @@ import type { Tool } from '../../src/tools'; import { getActiveToolActor, setActiveToolActor } from '../../src/state'; import { Actor } from 'xstate'; -import { TOOL_STATE_MACHINES } from '../../src/tools/tool.consts'; +import { TOOL_STATE_MACHINES } from '../../src/commands/registry'; export function setActiveTool(toolName: Tool) { getActiveToolActor()?.stop(); diff --git a/B07_DesignDetail/openwebcad/vitest.tmp.config.ts b/B07_DesignDetail/openwebcad/vitest.tmp.config.ts new file mode 100644 index 00000000..125ea4bd --- /dev/null +++ b/B07_DesignDetail/openwebcad/vitest.tmp.config.ts @@ -0,0 +1,20 @@ +/** + * 저장소 tmp/tests에 있는 CAD 테스트 실행용 설정 (테스트 코드는 git 추적 제외). + * npx vitest run --config vitest.tmp.config.ts + * tmp에는 node_modules가 없어 라이브러리를 openwebcad 쪽으로 이어 준다. + */ +import { resolve } from 'node:path'; +import { defineConfig } from 'vitest/config'; + +export default defineConfig({ + root: resolve('../..'), + resolve: { + alias: { + '@flatten-js/core': resolve('node_modules/@flatten-js/core'), + 'es-toolkit': resolve('node_modules/es-toolkit'), + }, + }, + test: { + include: ['tmp/tests/cad/**/*.test.ts'], + }, +}); diff --git a/resources/임시_AutoCAD_리본_기능_조사.md b/resources/임시_AutoCAD_리본_기능_조사.md new file mode 100644 index 00000000..c2ed8bce --- /dev/null +++ b/resources/임시_AutoCAD_리본_기능_조사.md @@ -0,0 +1,383 @@ +# AutoCAD 2D 기능·명령 조사 (임시) + +- 조사 기준: AutoCAD 2024 Windows의 기본 2D 제도 기능 +- 조사일: 2026-08-29 +- 추가검증: 2026-08-29, Autodesk 공식 명령 참조·2024 신규/변경 명령·상태막대 참조와 재대조 +- 용도: B07 상세설계 웹 CAD 기능 검토용 임시 참고자료 +- 기본 입력 기준: AutoCAD 2024의 명령 별칭(`acad.pgp`)과 키 조합·기능 키(CUIx)를 `단축키` 열에 함께 기록했다. `—`는 공식 기본 입력을 확인하지 못했거나 리본·상황별 조작만 있는 기능이다. 사용자 설정에 따라 값이 달라질 수 있다. +- 자료 성격: 아래 표는 기본 2D 제도 기능과 명령을 작업 기준으로 분류한 **기능 인벤토리**이며, 실제 리본에 표시되는 아이콘의 전수 목록이 아니다. +- 아이콘 주의: 리본은 제품·작업공간·화면 폭·CUI 사용자화에 따라 탭, 패널, 버튼, 드롭다운 및 슬라이드아웃 구성이 달라진다. 실제 아이콘 목록으로 사용하려면 AutoCAD 2024 기본 `제도 및 주석` 작업공간의 CUIx 또는 실행 화면을 기준으로 버튼 단위 검증이 추가로 필요하다. +- 반영 등급: `A` B07 웹 CAD 우선 구현 · `B` 후속·조건부(엔진·구조 확장이 선행되어야 함) · `C` 미반영(DWG·데스크톱·클라우드 종속이거나 임도 설계에 불필요) +- 반영: `☑` 반영 완료 · `◐` 부분 반영 · `☐` 미반영. `기존`은 이번 AutoCAD 대응 작업 이전부터 있던 기능이다. 구현할 때마다 이 열을 갱신한다. +- 제외 범위: 3D 모델링, Architecture 등 전문화 도구 세트, Express Tools 및 타사 애드인. 같은 기능이 여러 위치에 나타나는 경우 최초 한 번만 기재했다. + +## 1. 홈 탭 — 그리기 + +| 그룹(패널) | 기능/명령 | 단축키 | 짧은 설명 | 반영 등급 | 반영 | +|---|---|---|---|---|---| +| 그리기 | 선 `LINE` | L | 두 점 사이에 직선 세그먼트를 작성한다. | A | ☑ 기존 | +| 그리기 | 폴리선 `PLINE` | PL | 연결된 선·호를 하나의 객체로 작성한다. | A | ☑ | +| 그리기 | 원 `CIRCLE` | C | 중심·반지름 등 여러 조건으로 원을 작성한다. | A | ☑ 기존 | +| 그리기 | 호 `ARC` | A | 3점, 중심, 시작·끝점 등의 조건으로 호를 작성한다. | A | ☑ | +| 그리기 | 직사각형 `RECTANG` | REC | 직사각형 형태의 닫힌 폴리선을 작성한다. | A | ☑ 기존 | +| 그리기 | 다각형 `POLYGON` | POL | 지정한 변 수의 정다각형을 작성한다. | A | ☑ | +| 그리기 | 타원 `ELLIPSE` | EL | 중심 또는 축 길이로 타원·타원호를 작성한다. | A | ◐ | +| 그리기 | 스플라인 `SPLINE` | SPL | 맞춤점 또는 조정 정점으로 부드러운 곡선을 작성한다. | B | ☑ | +| 그리기 | 다중선 `MLINE` | ML | 여러 평행선으로 구성된 다중선 객체를 작성한다. | B | ◐ | +| 그리기 | 다중선 스타일 `MLSTYLE` | — | 다중선의 요소 수·간격·선종류와 끝막음을 관리한다. | B | ☑ | +| 그리기 | 구성선 `XLINE` | XL | 양방향으로 무한한 기준선을 작성한다. | B | ◐ | +| 그리기 | 광선 `RAY` | — | 한 방향으로 무한한 기준선을 작성한다. | B | ◐ | +| 그리기 | 점 `POINT` | PO | 점 객체를 작성한다. | A | ☑ | +| 그리기 | 도넛 `DONUT` | DO | 채워진 원 또는 링 모양 폴리선을 작성한다. | C | ◐ | +| 그리기 | 등분 `DIVIDE` | DIV | 객체를 자르지 않고 동일 간격의 점 또는 블록을 지정 개수만큼 배치한다. | A | ☑ | +| 그리기 | 길이분할 `MEASURE` | ME | 객체를 자르지 않고 지정 거리마다 점 또는 블록을 배치한다. | A | ☑ | +| 그리기 | 해치 `HATCH` | H / BH | 닫힌 경계에 패턴·솔리드·그라데이션 채움을 작성한다. | A | ☑ | +| 그리기 | 그라데이션 `GRADIENT` | GD | 닫힌 영역에 색상 그라데이션을 적용한다. | C | ☑ | +| 그리기 | 경계 `BOUNDARY` | BO | 닫힌 영역에서 폴리선 또는 영역 객체를 만든다. | B | ☑ | +| 그리기 | 영역 `REGION` | REG | 닫힌 평면 객체를 영역 객체로 변환한다. | C | ◐ | +| 그리기 | 와이프아웃 `WIPEOUT` | — | 뒤쪽 객체를 가리는 마스크 영역을 작성한다. | B | ☑ | + +## 2. 홈 탭 — 수정 + +| 그룹(패널) | 기능/명령 | 단축키 | 짧은 설명 | 반영 등급 | 반영 | +|---|---|---|---|---|---| +| 수정 | 이동 `MOVE` | M | 선택 객체를 기준점에서 새 위치로 이동한다. | A | ☑ 기존 | +| 수정 | 복사 `COPY` | CO | 선택 객체를 하나 이상의 위치에 복제한다. | A | ☑ 기존 | +| 수정 | 회전 `ROTATE` | RO | 기준점을 중심으로 객체를 회전한다. | A | ☑ 기존 | +| 수정 | 축척 `SCALE` | SC | 기준점과 비율 또는 참조 길이로 크기를 변경한다. | A | ☑ 기존 | +| 수정 | 정렬 `ALIGN` | AL | 원본점과 대상점 쌍으로 객체를 이동·회전하고 선택적으로 축척한다. | B | ☑ | +| 수정 | 대칭 `MIRROR` | MI | 대칭축을 기준으로 객체를 반사 복사한다. | A | ☑ | +| 수정 | 간격띄우기 `OFFSET` | O | 평행선·동심원·등거리 곡선을 작성한다. | A | ☑ | +| 수정 | 배열 `ARRAY` | AR | 직사각형·경로·원형 패턴으로 객체를 반복 배치한다. | A | ☑ 기존 | +| 수정 | 자르기 `TRIM` | TR | 경계를 기준으로 불필요한 객체 부분을 자른다. | A | ☑ | +| 수정 | 연장 `EXTEND` | EX | 객체 끝을 지정 경계까지 연장한다. | A | ◐ | +| 수정 | 신축 `STRETCH` | S | 교차 선택한 정점과 객체 일부를 늘이거나 이동한다. | B | ◐ | +| 수정 | 모깎기 `FILLET` | F | 두 객체를 지정 반지름의 호로 연결한다. | A | ◐ | +| 수정 | 모따기 `CHAMFER` | CHA | 두 객체를 직선 모따기로 연결한다. | A | ◐ | +| 수정 | 곡선 혼합 `BLEND` | — | 두 곡선을 부드러운 스플라인으로 연결한다. | C | ☑ | +| 수정 | 끊기 `BREAK` | BR | 객체의 두 점 사이를 제거하거나 한 점에서 나눈다. | A | ☑ | +| 수정 | 점에서 끊기 `BREAKATPOINT` | — | 객체를 지정점에서 두 객체로 나눈다. | A | ☑ | +| 수정 | 결합 `JOIN` | J | 끝이 맞는 선·호·폴리선 등을 하나로 결합한다. | A | ☑ | +| 수정 | 분해 `EXPLODE` | X | 블록·폴리선 등 복합 객체를 구성요소로 분해한다. | A | ☑ | +| 수정 | 지우기 `ERASE` | E | 선택 객체를 삭제한다. | A | ☑ 기존 | +| 수정 | 길이조정 `LENGTHEN` | LEN | 선과 호의 길이 또는 끼인각을 변경한다. | B | ◐ | +| 수정 | 폴리선 편집 `PEDIT` | PE | 폴리선 결합, 폭, 정점, 곡선 맞춤 등을 편집한다. | A | ☑ 기존 | +| 수정 | 해치 편집 `HATCHEDIT` | HE | 기존 해치의 패턴, 축척, 각도, 경계를 수정한다. | B | ☑ | +| 수정 | 그리기 순서 `DRAWORDER` | DR | 객체의 앞뒤 표시 순서를 변경한다. | B | ☑ | +| 수정 | 특성 일치 `MATCHPROP` | MA | 원본 객체의 표시 특성을 다른 객체에 복사한다. | A | ☑ | +| 수정 | 중복 객체 삭제 `OVERKILL` | — | 중복·겹침 형상을 제거하고 이어진 선·호를 결합한다. | B | ☑ | +| 수정 | 방향 반전 `REVERSE` | — | 선·폴리선·스플라인 등의 시작점과 끝점 방향을 뒤집는다. | C | ☑ | +| 수정 | 특성을 ByLayer로 `SETBYLAYER` | — | 선택 객체의 특성 재지정을 ByLayer 값으로 변경한다. | B | ☑ | + +## 3. 홈 탭 — 도면층·특성·그룹·유틸리티 + +| 그룹(패널) | 기능/명령 | 단축키 | 짧은 설명 | 반영 등급 | 반영 | +|---|---|---|---|---|---| +| 도면층 | 도면층 특성 `LAYER` | LA | 도면층을 만들고 이름·색상·선종류·출력 여부를 관리한다. | A | ☑ 기존 | +| 도면층 | 현재 도면층 설정 | — | 선택한 도면층을 새 객체의 기본 도면층으로 지정한다. | A | ☑ 기존 | +| 도면층 | 켜기/끄기 | — | 도면층 객체의 표시를 켜거나 끈다. | A | ☑ 기존 | +| 도면층 | 동결/동결해제 | — | 도면층을 재생성 및 표시 대상에서 제외하거나 복원한다. | C | ☑ | +| 도면층 | 잠금/잠금해제 | — | 도면층 객체의 편집 가능 여부를 전환한다. | A | ☑ 기존 | +| 도면층 | 분리 `LAYISO` | — | 선택 객체의 도면층만 남기고 나머지를 숨기거나 잠근다. | B | ☑ | +| 도면층 | 분리 해제 `LAYUNISO` | — | 도면층 분리 이전 상태를 복원한다. | B | ☑ | +| 도면층 | 이전 상태 `LAYERP` | — | 직전 도면층 설정 변경을 복원한다. | C | ☑ | +| 도면층 | 도면층 상태 `LAYERSTATE` | LAS | 도면층 설정 조합을 이름으로 저장하고 복원한다. | B | ☑ | +| 도면층 | 도면층 일치 `LAYMCH` | — | 선택 객체를 대상 객체의 도면층으로 이동한다. | A | ☑ | +| 도면층 | 현재 도면층으로 `LAYCUR` | — | 선택 객체를 현재 도면층으로 이동한다. | A | ☑ | +| 도면층 | 도면층 병합 `LAYMRG` | — | 한 도면층의 객체를 다른 도면층으로 옮기고 원래 도면층을 제거한다. | B | ☑ | +| 도면층 | 도면층 삭제 `LAYDEL` | — | 선택한 도면층과 그 객체를 삭제한다. | B | ☑ | +| 도면층 | 도면층 탐색 `LAYWALK` | — | 도면층을 선택적으로 표시하며 포함 객체를 확인한다. | C | ☑ | +| 특성 | 색상 `COLOR` | COL | 객체 색상을 직접 또는 `ByLayer`·`ByBlock`으로 지정한다. | A | ☑ | +| 특성 | 선종류 `LINETYPE` | LT | 실선·점선·중심선 등 객체 선종류를 지정한다. | A | ☑ | +| 특성 | 선가중치 `LWEIGHT` | LW | 화면 및 출력에 사용할 선 굵기를 지정한다. | A | ☑ | +| 특성 | 투명도 | — | 객체 또는 도면층의 투명도를 지정한다. | B | ☑ | +| 특성 | 특성 팔레트 `PROPERTIES` | Ctrl+1 / CH / MO / PR | 선택 객체의 형상·표시·데이터 특성을 조회하고 수정한다. | A | ☑ | +| 특성 | 빠른 특성 `QUICKPROPERTIES` | QP | 선택 객체 주변에 주요 특성만 간단히 표시한다. | B | ☑ | +| 그룹 | 그룹 `GROUP` | G | 여러 객체를 함께 선택할 수 있는 명명 그룹으로 묶는다. | B | ☑ | +| 그룹 | 그룹 해제 `UNGROUP` | — | 객체 그룹을 해제한다. | B | ☑ | +| 유틸리티 | 거리 `DIST` | DI | 두 점 사이의 거리와 각도 차이를 측정한다. | A | ☑ | +| 유틸리티 | 반지름 | — | 원 또는 호의 반지름을 측정한다. | A | ☑ | +| 유틸리티 | 각도 | — | 선·호 또는 지정점 사이의 각도를 측정한다. | A | ☑ | +| 유틸리티 | 면적 `AREA` | AA | 객체 또는 지정 경계의 면적과 둘레를 계산한다. | A | ☑ | +| 유틸리티 | 빠른 측정 `MEASUREGEOM` | MEA | 커서 주변의 치수·거리·각도와 닫힌 영역의 면적을 동적으로 표시한다. | B | ☑ | +| 유틸리티 | 점 좌표 `ID` | ID | 지정점의 X·Y·Z 좌표를 표시한다. | A | ☑ | +| 유틸리티 | 리스트 `LIST` | LI | 선택 객체의 상세 데이터와 기하 정보를 표시한다. | B | ☑ | +| 유틸리티 | 빠른 선택 `QSELECT` | — | 객체 유형과 특성 조건으로 선택 집합을 만든다. | B | ☑ | +| 유틸리티 | 계산기 `QUICKCALC` | QC / Ctrl+8 | 수식, 단위 변환 및 도면 값을 계산한다. | C | ☑ | +| 유틸리티 | 유사 선택 `SELECTSIMILAR` | — | 선택 객체와 지정 특성이 같은 객체를 모두 선택한다. | B | ☑ | +| 유틸리티 | 객체 분리 `ISOLATEOBJECTS` | — | 선택 객체만 표시하고 나머지 객체를 숨긴다. | B | ☑ | +| 유틸리티 | 객체 숨기기 `HIDEOBJECTS` | — | 선택 객체를 일시적으로 숨긴다. | B | ☑ | +| 유틸리티 | 객체 분리 종료 `UNISOLATEOBJECTS` | UNHIDE / UNISOLATE | 숨기거나 분리한 객체를 다시 표시한다. | B | ☑ | +| 클립보드 | 잘라내기·복사·붙여넣기 | Ctrl+X / Ctrl+C / Ctrl+V | 객체를 시스템 클립보드로 이동·복제한다. | A | ☑ | +| 클립보드 | 기준점과 함께 복사 | Ctrl+Shift+C | 지정 기준점을 포함해 객체를 클립보드에 복사한다. | B | ☑ | +| 클립보드 | 원래 좌표로 붙여넣기 | — | 복사한 객체를 원본 도면 좌표에 배치한다. | B | ☑ | +| 클립보드 | 블록으로 붙여넣기 | Ctrl+Shift+V | 클립보드 객체를 새 블록으로 삽입한다. | C | ◐ | + +## 4. 삽입 탭 — 블록·참조·데이터 + +| 그룹(패널) | 기능/명령 | 단축키 | 짧은 설명 | 반영 등급 | 반영 | +|---|---|---|---|---|---| +| 블록 | 블록 삽입 `INSERT` | I | 현재 도면·최근 항목·라이브러리의 블록을 배치한다. | B | ☐ | +| 블록 | 스마트 블록 배치 | — | 블록 팔레트로 삽입할 때 기존 동일 블록을 바탕으로 위치·회전·축척을 제안한다. | C | ☐ | +| 블록 | 블록 교체 `BREPLACE` | — | 선택 블록을 제안 목록 또는 지정한 다른 블록으로 교체한다. | C | ☐ | +| 블록 정의 | 블록 작성 `BLOCK` | B | 선택 객체와 기준점으로 블록 정의를 만든다. | B | ☐ | +| 블록 정의 | 블록 편집기 `BEDIT` | BE | 블록 정의와 동적 블록 동작을 편집한다. | C | ☐ | +| 블록 정의 | 블록 쓰기 `WBLOCK` | W | 선택 객체 또는 블록을 별도 DWG로 저장한다. | C | ☐ | +| 블록 정의 | 속성 정의 `ATTDEF` | ATT | 블록에 포함할 문자 데이터 필드를 정의한다. | C | ☐ | +| 블록 정의 | 속성 관리 `BATTMAN` | — | 블록 속성의 순서·표시·기본값을 관리한다. | C | ☐ | +| 블록 정의 | 속성 동기화 `ATTSYNC` | — | 변경된 속성 정의를 기존 블록 참조에 반영한다. | C | ☐ | +| 블록 정의 | 향상된 속성 편집 `EATTEDIT` | — | 삽입된 블록 참조의 속성 값과 문자·표시 특성을 편집한다. | C | ☐ | +| 참조 | DWG 부착 `XATTACH` | XA | 다른 DWG를 외부 참조로 연결한다. | C | ☐ | +| 참조 | 이미지 부착 `IMAGEATTACH` | IAT | 래스터 이미지를 참조로 연결한다. | A | ☑ 기존 | +| 참조 | PDF 부착 `PDFATTACH` | — | PDF 페이지를 언더레이로 연결한다. | C | ☐ | +| 참조 | DWF/DGN 부착 | — | DWF 또는 DGN을 언더레이로 연결한다. | C | ☐ | +| 참조 | 점 구름 부착 | — | 스캔된 점 구름 데이터를 도면에 연결한다. | C | ☐ | +| 참조 | 외부 참조 팔레트 `XREF` | XR | 참조의 로드·언로드·재로드·경로·결합을 관리한다. | C | ☐ | +| 참조 | 참조 내부 편집 `REFEDIT` | — | 블록 또는 외부 참조를 현재 도면 안에서 직접 편집한다. | C | ☐ | +| 참조 | 외부 참조 자르기 `XCLIP` | XC | DWG 외부 참조 또는 블록의 표시 경계를 지정한다. | C | ☐ | +| 참조 | 이미지 자르기 `IMAGECLIP` | ICL | 부착 이미지의 표시 경계를 지정한다. | B | ☐ | +| 참조 | PDF 자르기 `PDFCLIP` | — | PDF 언더레이의 표시 경계를 지정한다. | C | ☐ | +| 가져오기 | PDF 가져오기 `PDFIMPORT` | — | PDF의 벡터 형상·문자·채움을 도면 객체로 변환한다. | C | ☐ | +| 가져오기 | 가져오기 `IMPORT` | IMP | 지원되는 다른 형식의 데이터를 현재 도면으로 가져온다. | B | ☐ | +| 링크 및 추출 | 데이터 링크 `DATALINK` | DL | Excel 등 외부 표 데이터와 도면 테이블을 연결한다. | C | ☐ | +| 링크 및 추출 | 데이터 추출 `DATAEXTRACTION` | DX | 객체·블록·속성 정보를 표 또는 외부 파일로 추출한다. | B | ☐ | +| 링크 및 추출 | 하이퍼링크 `HYPERLINK` | — | 객체에 웹·파일·도면 뷰 링크를 연결한다. | C | ☐ | +| 링크 및 추출 | 필드 `FIELD` | — | 날짜·도면·객체 특성 등 갱신 가능한 문자 값을 삽입한다. | C | ☐ | +| 위치 | 지리적 위치 `GEOGRAPHICLOCATION` | GEO / NORTH | 좌표계와 지도상의 도면 위치를 지정한다. | C | ☐ | + +## 5. 주석 탭 — 문자·치수·지시선·표·표식 + +| 그룹(패널) | 기능/명령 | 단축키 | 짧은 설명 | 반영 등급 | 반영 | +|---|---|---|---|---|---| +| 문자 | 여러 줄 문자 `MTEXT` | T / MT | 서식과 줄바꿈을 지원하는 문단 문자를 작성한다. | A | ☑ | +| 문자 | 단일 행 문자 `TEXT` | DT | 각 행이 독립 객체인 간단한 문자를 작성한다. | A | ☑ | +| 문자 | 문자 스타일 `STYLE` | ST | 글꼴·높이·폭 비율·기울기 등의 스타일을 관리한다. | B | ◐ | +| 문자 | 문자 편집 `TEXTEDIT` | TEDIT | 기존 문자·치수 문자·속성 문자를 수정한다. | A | ☑ | +| 문자 | 찾기·대치 `FIND` | — | 도면의 문자 내용을 검색하고 필요한 문자열을 대치한다. | B | ☑ | +| 문자 | 맞춤법 검사 `SPELL` | SP | 도면 문자와 속성 값의 철자를 검사한다. | C | ☐ | +| 치수 | 빠른 치수 `QDIM` | — | 선택 객체에 여러 치수를 한 번에 작성한다. | B | ☑ | +| 치수 | 치수 `DIM` | — | 선택한 객체와 지정점에 적합한 치수 유형을 작성한다. | A | ☑ | +| 치수 | 선형 치수 `DIMLINEAR` | — | 수평·수직·회전 선형 치수를 작성한다. | A | ☑ | +| 치수 | 정렬 치수 `DIMALIGNED` | — | 두 점과 평행한 실제 길이 치수를 작성한다. | A | ☑ | +| 치수 | 각도 치수 `DIMANGULAR` | DAN | 두 선 또는 호의 각도를 기입한다. | A | ☑ | +| 치수 | 호 길이 치수 `DIMARC` | DAR | 호의 곡선 길이를 기입한다. | B | ☑ | +| 치수 | 반지름 `DIMRADIUS`·지름 `DIMDIAMETER` 치수 | DRA / DDI | 원과 호의 반지름 또는 지름을 기입한다. | A | ☑ | +| 치수 | 꺾기 치수 `DIMJOGGED` | DJO / JOG | 큰 반지름의 중심 위치를 축약해 표시한다. | C | ☐ | +| 치수 | 세로좌표 치수 `DIMORDINATE` | DOR | 기준 원점에 대한 X 또는 Y 좌표를 기입한다. | B | ☑ | +| 치수 | 기준선 `DIMBASELINE`·연속 `DIMCONTINUE` 치수 | DBA / DCO | 공통 기준 또는 앞 치수 끝점에서 연속 기입한다. | A | ☑ | +| 치수 | 중심 표식 `CENTERMARK` | — | 원 또는 호 중심에 연관 중심 표식을 작성한다. | B | ☑ | +| 치수 | 중심선 `CENTERLINE` | — | 두 선 또는 폴리선 세그먼트 사이에 연관 중심선을 작성한다. | B | ☑ | +| 치수 | 치수 스타일 `DIMSTYLE` | D | 치수선·화살표·문자·단위·공차 형식을 관리한다. | A | ☑ | +| 치수 | 치수 업데이트 | — | 변경한 치수 스타일을 기존 치수에 적용한다. | B | ☑ | +| 치수 | 치수 끊기 `DIMBREAK` | — | 교차 객체나 지정점에서 치수선·연장선을 끊어 표시한다. | C | ☐ | +| 치수 | 치수 간격 `DIMSPACE` | — | 평행 또는 동심 치수 사이의 간격을 조정한다. | B | ☑ | +| 치수 | 치수 재연관 `DIMREASSOCIATE` | DRE | 끊어진 치수를 실제 형상점과 다시 연관시킨다. | C | ☐ | +| 치수 | 검사 치수 `DIMINSPECT` | — | 검사 빈도·값 정보를 포함한 검사 치수를 작성한다. | C | ☐ | +| 기호 | 기하공차 `TOLERANCE` | TOL | 형상·자세·위치 공차 기호 프레임을 작성한다. | C | ☐ | +| 지시선 | 다중 지시선 `MLEADER` | MLD | 화살표와 문자 또는 블록 주석을 연결한다. | A | ☑ | +| 지시선 | 다중 지시선 스타일 `MLEADERSTYLE` | MLS | 다중 지시선의 형식과 기본 동작을 작성·수정한다. | B | ☑ | +| 지시선 | 지시선 추가·제거·정렬 | — | 기존 다중 지시선의 가지와 배치를 편집한다. | B | ◐ | +| 지시선 | 지시선 수집 `MLEADERCOLLECT` | MLC | 블록 내용의 여러 다중 지시선을 행 또는 열로 정렬한다. | C | ☐ | +| 표 | 테이블 `TABLE` | TB | 행·열과 셀 서식을 가진 도면 표를 작성한다. | A | ◐ | +| 표 | 테이블 스타일 `TABLESTYLE` | TS | 표의 제목·머리글·데이터 셀 형식을 관리한다. | B | ☑ | +| 표식 | 구름형 리비전 `REVCLOUD` | — | 변경 또는 검토 범위를 구름형 선으로 표시한다. | B | ☑ | +| 주석 축척 | 주석 객체·축척 | — | 여러 뷰포트 축척에서 일정한 출력 크기를 유지한다. | B | ☑ | +| 주석 축척 | 축척 리스트 편집 `SCALELISTEDIT` | — | 도면에서 사용할 주석·뷰포트 축척 목록을 관리한다. | C | ☐ | + +## 6. 파라메트릭 탭 — 구속조건 + +| 그룹(패널) | 기능/명령 | 단축키 | 짧은 설명 | 반영 등급 | 반영 | +|---|---|---|---|---|---| +| 기하 구속 | 자동 구속 `AUTOCONSTRAIN` | — | 객체 관계를 분석해 여러 기하 구속을 자동 적용한다. | C | ☐ | +| 기하 구속 | 일치 | — | 두 점 또는 점과 객체를 같은 위치에 구속한다. | C | ☐ | +| 기하 구속 | 동일선상 | — | 객체들이 같은 무한선 위에 있도록 구속한다. | C | ☐ | +| 기하 구속 | 동심 | — | 원·호·타원이 같은 중심을 갖도록 구속한다. | C | ☐ | +| 기하 구속 | 고정 | — | 점 또는 객체의 위치·방향을 고정한다. | C | ☐ | +| 기하 구속 | 평행·직교 | — | 두 선을 평행 또는 직각으로 유지한다. | C | ☐ | +| 기하 구속 | 수평·수직 | — | 선 또는 두 점을 수평·수직으로 유지한다. | C | ☐ | +| 기하 구속 | 접선 | — | 두 곡선이 서로 접하도록 구속한다. | C | ☐ | +| 기하 구속 | 매끄럽게 | — | 스플라인과 다른 곡선을 부드럽게 연결한다. | C | ☐ | +| 기하 구속 | 대칭 | — | 객체를 지정 대칭축에 대해 대칭으로 유지한다. | C | ☐ | +| 기하 구속 | 같음 | — | 객체의 길이 또는 반지름을 같게 유지한다. | C | ☐ | +| 치수 구속 | 선형·정렬·수평·수직 | — | 거리 값을 매개변수로 지정해 형상을 제어한다. | C | ☐ | +| 치수 구속 | 반지름·지름·각도 | — | 크기와 각도를 매개변수로 지정한다. | C | ☐ | +| 치수 구속 | 변환 | — | 일반 치수를 연관 치수 구속으로 변환한다. | C | ☐ | +| 관리 | 매개변수 관리자 `PARAMETERS` | PAR | 이름 있는 변수, 값 및 수식을 관리한다. | C | ☐ | +| 관리 | 구속 표시·숨기기·삭제 | — | 구속 막대와 치수 구속의 표시 및 제거를 관리한다. | C | ☐ | + +## 7. 뷰 탭 — 탐색·뷰·뷰포트·팔레트 + +| 그룹(패널) | 기능/명령 | 단축키 | 짧은 설명 | 반영 등급 | 반영 | +|---|---|---|---|---|---| +| 탐색 | 줌 `ZOOM` | Z | 확대·축소, 범위 전체, 윈도우 등의 방법으로 뷰를 변경한다. | A | ☑ 기존 | +| 탐색 | 초점이동 `PAN` | P | 배율을 유지한 채 화면의 관찰 위치를 이동한다. | A | ☑ 기존 | +| 탐색 | 탐색 막대 | — | 줌·초점이동 등 화면 탐색 도구를 모아 제공한다. | A | ☑ 기존 | +| 뷰 | 명명된 뷰 `VIEW` | V | 현재 화면·도면층·UCS 상태를 이름으로 저장하고 복원한다. | B | ☐ | +| 뷰 | 새 명명 뷰 `NEWVIEW` | NVIEW | 현재 화면 또는 지정 영역을 새 이름의 뷰로 저장한다. | B | ☐ | +| 뷰 | 재생성 `REGEN` | RE | 현재 뷰포트의 객체 표시를 다시 계산한다. | B | ☐ | +| 뷰 | 전체 재생성 `REGENALL` | REA | 모든 뷰포트의 객체 표시를 다시 계산한다. | C | ☐ | +| 뷰포트 | 모형 공간 뷰포트 | — | 도면 영역을 여러 독립 관찰 창으로 나눈다. | C | ☐ | +| 뷰포트 | 배치 뷰포트 `MVIEW` | MV | 종이 공간에 모형을 표시하는 출력 창을 작성한다. | B | ☐ | +| 뷰포트 | 뷰포트 잠금 | — | 배치 뷰포트의 축척과 관찰 상태 변경을 막는다. | C | ☐ | +| 좌표 | UCS `UCS` | — | 사용자 좌표계의 원점과 축 방향을 설정한다. | B | ☐ | +| 좌표 | 월드 UCS | — | 좌표계를 기본 WCS로 복원한다. | B | ☐ | +| 팔레트 | 도구 팔레트 `TOOLPALETTES` | TP / Ctrl+3 | 자주 쓰는 블록·해치·명령 도구 모음을 표시한다. | C | ☐ | +| 팔레트 | 개수 `COUNT` | — | 선택 객체·블록의 인스턴스를 세고 결과를 강조·검토한다. | B | ☐ | +| 팔레트 | DesignCenter `ADCENTER` | ADC / Ctrl+2 | 다른 도면의 블록·도면층·스타일을 탐색해 가져온다. | C | ☐ | +| 팔레트 | 시트 세트 관리자 | Ctrl+4 / SSM | 여러 도면 시트의 집합·번호·출력을 관리한다. | C | ☐ | +| 팔레트 | 명령행 | Ctrl+9 / CLI | 명령 입력, 옵션 선택 및 작업 기록을 표시한다. | A | ☑ 기존 | +| 팔레트 | 명령 매크로 | — | 반복 명령 시퀀스를 검토·저장·실행한다. | C | ☐ | +| 인터페이스 | 파일 탭 | Ctrl+Tab | 열려 있는 여러 도면 사이를 전환한다. | B | ☐ | +| 인터페이스 | 타일·계단식 배열 | — | 여러 열린 도면 창의 배치를 조정한다. | C | ☐ | +| 인터페이스 | 전체 화면 정리 `CLEANSCREENON` | Ctrl+0 | 도구막대와 팔레트를 숨겨 도면 영역을 최대화한다. | B | ☐ | + +## 8. 관리 탭 — 표준·사용자화·자동화 + +| 그룹(패널) | 기능/명령 | 단축키 | 짧은 설명 | 반영 등급 | 반영 | +|---|---|---|---|---|---| +| 작업 기록 | 동작 기록기 `ACTRECORD` | ARR | 사용자 명령과 입력을 기록해 반복 재생한다. | C | ☐ | +| 사용자화 | 사용자 인터페이스 `CUI` | — | 리본·도구막대·메뉴·단축키·마우스 동작을 사용자화한다. | C | ☐ | +| 사용자화 | 별칭 편집 | — | 명령행에서 쓰는 짧은 명령 별칭을 관리한다. | C | ☐ | +| 사용자화 | 작업공간 | — | 리본·팔레트·도구막대 배치를 저장하고 전환한다. | C | ☐ | +| CAD 표준 | 표준 구성 `STANDARDS` | STA | 도면층·문자·치수·선종류 표준 파일을 연결한다. | C | ☐ | +| CAD 표준 | 표준 검사 `CHECKSTANDARDS` | CHK | 현재 도면이 연결된 CAD 표준과 다른 항목을 찾는다. | C | ☐ | +| CAD 표준 | 도면층 변환 `LAYTRANS` | — | 현재 도면층을 지정 표준 도면층 이름과 특성으로 매핑한다. | C | ☐ | +| 응용프로그램 | 응용프로그램 로드 `APPLOAD` | AP | AutoLISP·ObjectARX 등 확장 프로그램을 로드한다. | C | ☐ | +| 응용프로그램 | Visual LISP | — | AutoLISP 코드를 작성·검사하는 개발 환경을 연다. | C | ☐ | +| 스크립트 | 스크립트 실행 `SCRIPT` | SCR | 텍스트 파일에 기록된 명령 시퀀스를 실행한다. | C | ☐ | +| 정리 | 소거 `PURGE` | PU | 사용하지 않는 블록·도면층·스타일 정의를 제거한다. | B | ☐ | + +## 9. 출력 탭 — 배치·플롯·게시·내보내기 + +| 그룹(패널) | 기능/명령 | 단축키 | 짧은 설명 | 반영 등급 | 반영 | +|---|---|---|---|---|---| +| 플롯 | 플롯 `PLOT` | PRINT / Ctrl+P | 프린터·용지·영역·축척·스타일을 설정해 출력한다. | A | ☐ | +| 플롯 | 미리보기 `PREVIEW` | PRE | 실제 출력 전 용지 결과를 확인한다. | A | ☐ | +| 플롯 | 페이지 설정 `PAGESETUP` | — | 배치별 출력 장치와 용지 설정을 저장한다. | B | ☐ | +| 플롯 | 플롯 스타일 | — | 색상·선가중치 등의 CTB/STB 출력 규칙을 관리한다. | C | ☐ | +| 플롯 | 플로터 관리자 `PLOTTERMANAGER` | — | 플로터 구성 파일과 장치 설정을 관리한다. | C | ☐ | +| 플롯 | 플롯 스타일 관리자 `STYLESMANAGER` | — | CTB·STB 플롯 스타일 테이블 파일을 관리한다. | C | ☐ | +| 게시 | 게시 `PUBLISH` | — | 여러 도면·배치를 한 번에 DWF·PDF·프린터로 출력한다. | B | ☐ | +| 게시 | 배치 플롯 | — | 시트 목록을 구성해 일괄 출력한다. | B | ☐ | +| 내보내기 | PDF 내보내기 `EXPORTPDF` | EPDF | 도면 또는 배치를 PDF 파일로 만든다. | A | ☐ | +| 내보내기 | DWF/DWFx 내보내기 | — | 검토·배포용 Autodesk 형식으로 내보낸다. | C | ☐ | +| 내보내기 | 기타 형식 `EXPORT` | EXP | 지원되는 다른 교환 파일 형식으로 저장한다. | A | ☑ 기존 | +| 전송 | 전자 전송 `ETRANSMIT` | ZIP | 도면과 참조·글꼴·플롯 설정을 하나의 전달 패키지로 묶는다. | C | ☐ | + +## 10. 공동작업·검토 + +| 그룹(패널) | 기능/명령 | 단축키 | 짧은 설명 | 반영 등급 | 반영 | +|---|---|---|---|---|---| +| 비교 | DWG 비교 `COMPARE` | — | 두 DWG의 추가·삭제·변경 객체를 색으로 구분한다. | C | ☐ | +| 비교 | 외부 참조 비교 | — | 부착된 외부 참조의 변경 사항을 현재 도면과 비교한다. | C | ☐ | +| 검토 | 추적 `TRACE` | — | 원도면을 바꾸지 않는 검토용 투명 오버레이를 만든다. | C | ☐ | +| 검토 | 추적에서 복사 `COPYFROMTRACE` | — | 추적에 있는 객체를 원도면의 같은 위치로 복사한다. | C | ☐ | +| 검토 | 표식 가져오기 `MARKUPIMPORT` | — | PDF·JPG·PNG 표식을 새 추적으로 가져와 원도면 위에 겹친다. | C | ☐ | +| 검토 | 표식 도우미 `MARKUPASSIST` | — | 표식의 문자·구름·지시 등을 인식해 도면 반영을 돕는다. | C | ☐ | +| 공유 | 뷰 공유 `SHAREDVIEWS` | — | 도면의 온라인 검토용 뷰를 생성하고 의견을 교환한다. | C | ☐ | +| 공유 | 도면 공유 `SHARE` | — | 현재 도면 복사본을 웹·모바일에서 보거나 편집하는 링크를 만든다. | C | ☐ | +| 이력 | 활동 정보 `ACTIVITYINSIGHTSOPEN` | AI OPEN | 도면 열기·저장·편집 등 작업 이벤트를 추적한다. | C | ☐ | +| 버전 | 버전 이력 `DWGHISTORY` | — | 지원되는 클라우드에 저장된 도면의 이전 버전을 조회·비교한다. | C | ☐ | + +## 11. 리본 밖의 핵심 제도 기능 + +| 영역 | 기능 | 단축키 | 짧은 설명 | 반영 등급 | 반영 | +|---|---|---|---|---|---| +| 선택 | 창 선택 | — | 왼쪽→오른쪽 사각형 안에 완전히 포함된 객체를 선택한다. | A | ☑ 기존 | +| 선택 | 교차 선택 | — | 오른쪽→왼쪽 영역에 포함되거나 걸치는 객체를 선택한다. | A | ☑ 기존 | +| 선택 | 올가미 선택 | — | 자유형 경계로 객체를 포함 또는 교차 선택한다. | B | ☐ | +| 선택 | 선택 순환 | — | 겹친 객체 목록에서 원하는 객체를 골라 선택한다. | B | ☐ | +| 직접 편집 | 그립 편집 | — | 선택 객체의 정점·중간점·반지름 등을 직접 끌어 수정한다. | A | ☐ | +| 직접 편집 | 다기능 그립 | — | 폴리선 정점 추가·제거, 호 전환 등 상황별 작업을 제공한다. | B | ☐ | +| 정확도 | 객체 스냅 `OSNAP` | OS / F3 | 끝점·중간점·중심·교차점·접점 등 정확한 점을 포착한다. | A | ☑ 기존 | +| 정확도 | 객체 스냅 추적 `(F11)` | F11 | 포착한 점에서 임시 정렬 경로를 추적한다. | B | ☐ | +| 정확도 | 극좌표 추적 `(F10)` | F10 | 지정 각도 증분을 따라 커서 이동을 안내한다. | B | ☐ | +| 정확도 | 직교 모드 `(F8)` | F8 | 커서 이동을 현재 UCS의 수평·수직 방향으로 제한한다. | A | ☑ 기존 | +| 정확도 | 그리드·스냅 | F7 / F9 | 화면 격자를 표시하고 커서 이동 간격을 제한한다. | A | ☑ 기존 | +| 정확도 | 동적 입력 `(F12, DYNMODE 시스템 변수)` | F12 | 커서 근처에서 좌표·거리·각도 및 명령 옵션을 입력한다. | A | ☑ 기존 | +| 정확도 | 직접 거리 입력 | — | 방향을 지정한 뒤 키보드로 정확한 거리를 입력한다. | A | ☑ 기존 | +| 좌표 | 절대·상대·극좌표 | — | 전역 좌표, 이전 점 기준 좌표, 거리·각도로 점을 입력한다. | A | ◐ 기존 | +| 작업 흐름 | 명령 직접 입력 | — | 명령 이름, 별칭, 옵션, 숫자와 좌표를 명령행에 연속 입력한다. | A | ☑ 기존 | +| 작업 흐름 | 명령 자동완성 | — | 입력 중인 명령·시스템 변수·콘텐츠를 검색해 제안한다. | A | ◐ 기존 | +| 작업 흐름 | 실행 취소·다시 실행 | Ctrl+Z / Ctrl+Y | 명령 단위로 작업 이력을 되돌리거나 복원한다. | A | ☑ 기존 | +| 작업 흐름 | 반복·최근 명령 | — | 직전 또는 최근 사용 명령을 다시 실행한다. | B | ☐ | +| 표시 | 선가중치 표시 | — | 실제 출력 선 굵기의 화면 표시를 전환한다. | B | ☐ | +| 표시 | 투명도 표시 | — | 객체·도면층 투명도의 화면 표시를 전환한다. | C | ☐ | +| 표시 | 주석 가시성 | — | 현재 축척을 지원하지 않는 주석의 표시 여부를 전환한다. | C | ☐ | +| 공간 | 모형·배치 | — | 실제 크기 설계 공간과 용지 출력 공간을 전환한다. | B | ☐ | +| 공간 | 뷰포트 축척 | — | 배치에서 모형 표시 축척을 정밀하게 지정한다. | B | ☐ | +| 파일 | 새 도면·열기·저장·다른 이름 저장 | Ctrl+N / Ctrl+O / Ctrl+S / Ctrl+Shift+S | DWG 작업 파일의 기본 수명주기를 관리한다. | A | ◐ 기존 | +| 파일 | 자동 저장·복구 | — | 비정상 종료 시 사용할 임시 저장과 복구 정보를 관리한다. | B | ☐ | +| 파일 | 감사 `AUDIT` | — | 응용프로그램 메뉴에서 열린 도면의 오류를 검사·수정한다. | C | ☐ | +| 파일 | 복구 `RECOVER` | — | 응용프로그램 메뉴에서 손상된 도면을 복구하면서 연다. | C | ☐ | +| 호환 | DWG 변환 `DWGCONVERT` | — | 여러 도면을 지정 DWG 버전으로 일괄 변환한다. | C | ☐ | +| 호환 | 단위 `UNITS` | UN | 길이·각도 형식, 정밀도 및 삽입 축척 단위를 설정한다. | B | ☐ | + +## 12. 2D 상황별 리본 탭 + +> 해당 객체를 선택하거나 편집 명령을 실행할 때만 나타나는 리본이다. + +| 상황별 탭 | 기능 | 단축키 | 짧은 설명 | 반영 등급 | 반영 | +|---|---|---|---|---|---| +| 해치 작성/편집 | 원점 설정 | — | 해치 패턴이 시작되는 기준점을 지정한다. | B | ☐ | +| 해치 작성/편집 | 연관·주석 | — | 경계 연동과 주석 축척 적용 여부를 설정한다. | B | ☐ | +| 해치 작성/편집 | 경계 재작성 | — | 해치에서 폴리선 또는 영역 경계를 다시 만든다. | C | ☐ | +| 문자 편집기 | 굵게·기울임·밑줄·색상 | — | 선택 문자 범위의 서식을 변경한다. | B | ☐ | +| 문자 편집기 | 정렬·들여쓰기·줄 간격 | — | 문단 배치와 간격을 조정한다. | B | ☐ | +| 문자 편집기 | 열 | — | 여러 줄 문자를 동적·정적 열로 나눈다. | C | ☐ | +| 블록 편집기 | 블록 테스트 | — | 동적 블록의 그립과 동작을 시험한다. | C | ☐ | +| 블록 편집기 | 매개변수·동작 | — | 동적 블록에 점·선형·회전 등의 매개변수와 동작을 지정한다. | C | ☐ | +| 블록 편집기 | 가시성 상태 | — | 하나의 블록에서 표시할 객체 조합을 관리한다. | C | ☐ | +| 배열 편집 | 항목 수·간격 | — | 배열 항목의 개수와 행·열 또는 경로 간격을 수정한다. | B | ☐ | +| 배열 편집 | 항목 교체·편집 | — | 배열의 원본 항목을 편집하거나 다른 객체로 바꾼다. | B | ☐ | +| 배열 편집 | 원본 편집·재설정 | — | 원본 객체를 편집하고 배열 재지정을 초기화한다. | C | ☐ | +| 외부 참조·언더레이 | 페이드·대비 | — | 외부 참조와 PDF·DWF·DGN 언더레이의 화면 특성을 조정한다. | C | ☐ | +| 외부 참조 | 참조 도면층 | — | 참조에 포함된 도면층 표시를 관리한다. | C | ☐ | +| 이미지 | 페이드·대비·밝기 | — | 래스터 이미지의 화면 특성을 조정한다. | B | ☐ | +| PDF/DWF/DGN 언더레이 | 단색·언더레이 도면층 | — | 단색 표시와 원본 도면층 가시성을 관리한다. | C | ☐ | +| 배치 | 명명된 뷰 삽입 | — | 저장된 모형 뷰를 배치 뷰포트로 배치한다. | C | ☐ | +| 테이블 셀 | 행·열 삽입·삭제 | — | 선택 셀 주변의 표 구조를 편집한다. | B | ☐ | +| 테이블 셀 | 병합·병합 해제 | — | 여러 셀을 합치거나 원래 셀로 되돌린다. | B | ☐ | +| 테이블 셀 | 셀 형식·정렬·테두리 | — | 데이터 형식과 표시 서식을 조정한다. | B | ☐ | + +## 13. 실제 리본 아이콘 조사 상태 + +| 확인 항목 | 현재 문서 반영 상태 | 판정 | +|---|---|---| +| 기능·명령명과 짧은 설명 | 반영 | B07 기능 후보 검토에 사용 가능 | +| 작업 기준 탭·패널 분류 | 반영 | 실제 기본 리본 배치와 다를 수 있음 | +| 리본 버튼의 실제 존재 여부 | 미검증 | 아이콘 목록으로 사용 불가 | +| 기본 표시·확장 패널·드롭다운 구분 | 미검증 | AutoCAD 2024 실행 화면 또는 CUIx 확인 필요 | +| 버튼 표시명·툴팁·CUI 명령 ID | 미수집 | 실제 아이콘 단위 조사 필요 | +| 아이콘 이미지·리소스명 | 미수집 | 실제 아이콘 자산 비교 필요 | +| 상황별 탭 표시 조건 | 일부 기능만 정리 | 객체별 실행 검증 필요 | + +## 14. 조사 근거 및 재검증 자료 + +| 구분 | 자료 | 확인 내용 | +|---|---|---| +| Autodesk 도움말 | [AutoCAD 2024 한국어 도움말](https://help.autodesk.com/view/ACD/2024/KOR/) | 기본 AutoCAD 명령·작업 절차·UI 기능 | +| Autodesk 공식 PDF | [AutoCAD 2024 Shortcuts Guide](https://damassets.autodesk.net/content/dam/autodesk/www/shortcuts/autocad/autocad-shortcut-guide-en.pdf) | 그리기·수정·주석·도면층·조회 등 주요 명령 교차 확인 | +| Autodesk 공식 블로그 | [Essential Keyboard Shortcuts and Commands](https://www.autodesk.com/blogs/autocad/work-faster-in-autocad-essential-keyboard-shortcuts-and-commands/) | 대표 명령의 작업별 분류와 짧은 기능 설명 교차 확인 | +| Autodesk 도움말 | [블록 삽입 정보](https://help.autodesk.com/cloudhelp/2024/KOR/AutoCAD-Core/files/GUID-BC0FD3C1-3BFC-4C5D-AB9A-BF480D5084BE.htm) | 홈 탭 블록 패널, 블록 갤러리와 팔레트 | +| Autodesk 도움말 | [외부 참조 경로로 작업하려면](https://help.autodesk.com/cloudhelp/2024/KOR/AutoCAD-Core/files/GUID-97F90017-E17A-4209-A6BD-1E205BF892E1.htm) | 뷰 탭 팔레트 패널과 외부 참조 관리 | +| Autodesk 도움말 | [도면 비교를 통해 작업하려면](https://help.autodesk.com/cloudhelp/2024/KOR/AutoCAD-Core/files/GUID-744D8A49-D582-4C06-80B1-6B0313E92CA2.htm) | 뷰·공동작업 탭의 DWG 비교 기능 | +| Autodesk 도움말 | [리본 사용자화 FAQ](https://help.autodesk.com/cloudhelp/2024/KOR/AutoCAD-Core/files/GUID-7711CB8D-C865-42C1-9F21-7179FA7BBE63.htm) | 리본 탭·패널·명령의 사용자화 가능 범위 | +| Autodesk 도움말 | [명령 매크로 정보](https://help.autodesk.com/cloudhelp/2024/KOR/AutoCAD-Core/files/GUID-6B27C0A2-6446-4C75-9853-6834F4FFFF89.htm) | 뷰·자동화 탭의 명령 매크로 팔레트 | +| Autodesk 도움말 | [점에서 끊기](https://help.autodesk.com/cloudhelp/2024/ENU/AutoCAD-DidYouKnow/files/GUID-73B7DEA8-309E-4823-9F65-DB0A2E1FBC75.htm) | `BREAKATPOINT`의 지원 버전과 홈 탭 위치 | +| Autodesk 도움말 | [파라메트릭 도면과 구속조건](https://help.autodesk.com/cloudhelp/2023/ENU/AutoCAD-Core/files/GUID-899E008D-B422-4DF2-AC8D-1A4F5701ED4E.htm) | 기하·치수 구속의 범위와 동작 | +| Autodesk 도움말 | [Markup Import와 Markup Assist](https://help.autodesk.com/cloudhelp/2024/ENU/AutoCAD-Platform/files/GUID-0CD6B2CE-EAF0-478B-B39B-F4821F9DC91D.htm) | 표식 가져오기·인식·반영 기능과 제약 | +| Autodesk 도움말 | [Activity Insights](https://help.autodesk.com/cloudhelp/2024/ENU/AutoCAD-WhatsNew/files/GUID-074F77C1-496D-49D2-8D64-FD545753CAB7.htm) | 2024 작업 이벤트 기록 기능과 리본 위치 | +| Autodesk 도움말 | [Drawing History 팔레트](https://help.autodesk.com/cloudhelp/2024/ENU/AutoCAD-Core/files/GUID-6916CA9F-0BD1-49ED-9DA4-2FA3615998DD.htm) | 지원 클라우드 도면의 버전 조회·비교 기능 | +| Autodesk 도움말 | [Shared Views 명령](https://help.autodesk.com/cloudhelp/2024/ENU/AutoCAD-Core/files/GUID-C9055F88-D079-441F-938B-37A7E0E390D9.htm) | 온라인 검토 뷰와 의견 교환 기능 | +| Autodesk 도움말 | [Action Macro 기록](https://help.autodesk.com/cloudhelp/2024/ENU/AutoCAD-Customization/files/GUID-FEAD3614-CD33-4B60-BC00-4CBC98D8CBCB.htm) | 동작 기록기의 명령·입력 기록 및 재생 기능 | +| Autodesk 도움말 | [Purge 대화상자](https://help.autodesk.com/cloudhelp/2024/ENU/AutoCAD-Core/files/GUID-C6B62BBD-C363-4DC7-AF43-036A7B287473.htm) | 미사용 명명 객체와 고아 데이터 정리 기능 | +| Autodesk 도움말 | [AutoCAD 2024 새 명령·시스템 변수](https://help.autodesk.com/cloudhelp/2025/ENU/AutoCAD-Core/files/GUID-B93A458E-1A7F-4090-A8CF-87A31C24E404.htm) | 2024의 Activity Insights·Smart Blocks·Trace·Markup 명령 대조 | +| Autodesk 도움말 | [AutoCAD 2024 변경 명령·시스템 변수](https://help.autodesk.com/cloudhelp/2025/ENU/AutoCAD-Core/files/GUID-4FEBA606-95E0-4DC4-A116-257ED86DCD58.htm) | `INSERT`·`COUNT`·`TRACE`·`SHARE` 변경 내용 대조 | +| Autodesk 도움말 | [COUNT 명령](https://help.autodesk.com/cloudhelp/2024/ENU/AutoCAD-Core/files/GUID-3A0C3460-6ABC-4D13-BF1F-D2BFCD399851.htm) | 동일 객체·블록 개수 계산 기능 | +| Autodesk 도움말 | [객체 개수로 작업](https://help.autodesk.com/cloudhelp/2022/ENU/AutoCAD-Core/files/GUID-27873872-0DB9-4EE1-97F5-43F91F9C3785.htm) | 뷰 탭 팔레트 패널의 Count 위치와 검토 기능 | +| Autodesk 도움말 | [OVERKILL 명령](https://help.autodesk.com/cloudhelp/2022/ENU/AutoCAD-Core/files/GUID-44B9ECFC-752C-4CC5-9DA3-84DBF3B17CA6.htm) | 중복·겹침 객체 제거 및 결합 기능 | +| Autodesk 도움말 | [Smart Blocks 교체](https://help.autodesk.com/cloudhelp/2024/ENU/AutoCAD-WhatsNew/files/GUID-4F8E11D6-917A-43F3-B596-C8F296D32519.htm) | `BREPLACE`의 제안·교체와 특성 유지 동작 | +| Autodesk 도움말 | [Trace 업데이트](https://help.autodesk.com/cloudhelp/2024/ENU/AutoCAD-WhatsNew/files/GUID-F9C11306-0F53-4F1A-92C7-8D77EA42823A.htm) | `COPYFROMTRACE`와 추적 화면표시 설정 | +| Autodesk 도움말 | [상태막대 빠른 참조](https://help.autodesk.com/cloudhelp/2022/ENU/AutoCAD-Core/files/GUID-E3B34B0A-EA98-45E9-937A-BF5FEF4152DB.htm) | 스냅·직교·극좌표·동적 입력 등 제도 토글의 기능 | +| Autodesk 도움말 | [열린 도면 복구](https://help.autodesk.com/cloudhelp/2024/ENU/AutoCAD-LT/files/GUID-7AFE414B-BCC6-470C-B0E9-8228A252EE7C.htm) | 응용프로그램 메뉴의 `AUDIT` 위치와 `RECOVER` 구분 |