diff --git a/B07_DesignDetail/openwebcad/src/App.css b/B07_DesignDetail/openwebcad/src/App.css index 6c7cc9b3..ef48df68 100644 --- a/B07_DesignDetail/openwebcad/src/App.css +++ b/B07_DesignDetail/openwebcad/src/App.css @@ -853,3 +853,66 @@ body > canvas[data-id="canvas"] { color: var(--cad-text-muted); font-size: 12px; } + +/* 블록 라이브러리 팔레트 (INSERT/BLOCK) */ +.cad-block-library { + 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-block-library__empty { + margin: 0; + color: var(--cad-text-muted); + font-size: 11px; + line-height: 1.5; +} +.cad-block-library__list { + max-height: 220px; + margin: 0; + padding: 0; + overflow-y: auto; + list-style: none; +} +.cad-block-library__list li { + display: flex; + align-items: center; + gap: 4px; +} +.cad-block-library__list li button { + border: 0; + background: transparent; + color: var(--cad-text); + font-size: 12px; +} +.cad-block-library__name { + flex: 1; + overflow: hidden; + padding: 3px 4px; + text-align: left; + text-overflow: ellipsis; + white-space: nowrap; +} +.cad-block-library__name:hover { + background: var(--cad-chrome-sunken); +} +.cad-block-library__actions { + display: flex; + gap: 6px; + margin-top: 8px; +} +.cad-block-library__actions button { + flex: 1; + padding: 4px 0; + border: 1px solid var(--cad-line); + border-radius: 3px; + background: transparent; + color: var(--cad-text-dim); + font-size: 11px; +} diff --git a/B07_DesignDetail/openwebcad/src/blocks/block-library.ts b/B07_DesignDetail/openwebcad/src/blocks/block-library.ts new file mode 100644 index 00000000..f820f638 --- /dev/null +++ b/B07_DesignDetail/openwebcad/src/blocks/block-library.ts @@ -0,0 +1,123 @@ +/** + * 블록 라이브러리 — 사용자가 그린 객체 묶음을 이름으로 저장해 두고 다른 도면에서도 삽입한다. + * 도면 JSON과 분리해 브라우저에 두므로 도면을 바꿔도 남는다. + * ponytail: 정의-참조 링크 없는 클론 방식. 정의를 고쳐도 이미 삽입한 객체는 그대로다. + * 일괄 갱신이 필요해지면 BlockEntity(참조 엔티티)로 올린다. + */ +import { compact } from 'es-toolkit'; +import { saveAs } from 'file-saver'; +import { toast } from 'react-toastify'; +import { HtmlEvent } from '../App.types'; +import type { Entity, JsonEntity } from '../entities/Entity'; +import { getBoundingBoxOfMultipleEntities } from '../helpers/get-bounding-box-of-multiple-entities'; +import { getEntitiesAndLayersFromJsonObject } from '../helpers/import-export-handlers/import-entities-from-json'; +import { getActiveLayerId } from '../state'; + +const STORAGE_KEY = 'aislo-cad-block-library'; + +export interface BlockDefinition { + name: string; + /** 삽입 기준점 (선택 영역 바운딩박스 중심) */ + basePoint: { x: number; y: number }; + entities: JsonEntity[]; +} + +let blocks: BlockDefinition[] | null = null; + +const notify = () => window.dispatchEvent(new CustomEvent(HtmlEvent.UPDATE_STATE)); + +function load(): BlockDefinition[] { + if (blocks) return blocks; + try { + const raw = localStorage.getItem(STORAGE_KEY); + blocks = raw ? (JSON.parse(raw) as BlockDefinition[]) : []; + } catch { + blocks = []; + } + return blocks; +} + +function persist(): void { + try { + localStorage.setItem(STORAGE_KEY, JSON.stringify(blocks ?? [])); + } catch { + toast.error('블록 라이브러리를 저장하지 못했습니다. 파일로 내보내 보관하십시오.'); + } + notify(); +} + +export const getBlocks = (): BlockDefinition[] => load(); + +export const getBlockByName = (name: string): BlockDefinition | undefined => + load().find((block) => block.name === name); + +/** 선택 객체를 이름 붙여 라이브러리에 넣는다. 같은 이름이면 덮어쓴다. */ +export async function saveBlockFromEntities(name: string, entities: Entity[]): Promise { + const jsonEntities = compact(await Promise.all(entities.map((entity) => entity.toJson()))); + if (!jsonEntities.length) { + toast.error('저장할 객체가 없습니다.'); + return; + } + const box = getBoundingBoxOfMultipleEntities(entities); + const definition: BlockDefinition = { + name, + basePoint: { x: (box.minX + box.maxX) / 2, y: (box.minY + box.maxY) / 2 }, + entities: jsonEntities, + }; + const library = load(); + const index = library.findIndex((block) => block.name === name); + if (index >= 0) library[index] = definition; + else library.push(definition); + persist(); +} + +export function deleteBlock(name: string): void { + blocks = load().filter((block) => block.name !== name); + persist(); +} + +/** + * 블록 정의를 실제 객체로 푼다. 정의 좌표 그대로 돌려주므로 삽입 도구가 커서까지 옮긴다. + * 새 id·현재 도면층·공통 groupId를 줘서 한 덩어리로 다뤄진다. + */ +export async function deserializeBlock(block: BlockDefinition): Promise { + const { entities } = await getEntitiesAndLayersFromJsonObject({ + entities: block.entities, + layers: [], + }); + const groupId = crypto.randomUUID(); + const layerId = getActiveLayerId(); + for (const entity of entities) { + entity.id = crypto.randomUUID(); + entity.layerId = layerId; + entity.groupId = groupId; + } + return entities; +} + +export function exportBlocksToFile(): void { + const blob = new Blob([JSON.stringify(load(), null, 2)], { type: 'text/json;charset=utf-8' }); + saveAs(blob, 'aislo-cad-blocks.json'); +} + +export function importBlocksFromFile(file: File): void { + const reader = new FileReader(); + reader.addEventListener('load', () => { + try { + const imported = JSON.parse(reader.result as string) as BlockDefinition[]; + if (!Array.isArray(imported)) throw new Error('블록 목록이 아닙니다.'); + const library = load(); + for (const block of imported) { + if (!block?.name || !Array.isArray(block.entities)) continue; + const index = library.findIndex((existing) => existing.name === block.name); + if (index >= 0) library[index] = block; + else library.push(block); + } + persist(); + toast.success(`블록 ${imported.length}개를 가져왔습니다.`); + } catch { + toast.error('블록 파일을 읽지 못했습니다.'); + } + }); + reader.readAsText(file, 'utf-8'); +} diff --git a/B07_DesignDetail/openwebcad/src/commands/commands.insert.ts b/B07_DesignDetail/openwebcad/src/commands/commands.insert.ts index 5e64ba67..6bdeb8eb 100644 --- a/B07_DesignDetail/openwebcad/src/commands/commands.insert.ts +++ b/B07_DesignDetail/openwebcad/src/commands/commands.insert.ts @@ -1,7 +1,14 @@ /** 삽입 탭 명령 (조사표 4절 중 구현분) */ -import type { CadCommand } from './command.types'; +import { toast } from 'react-toastify'; +import { saveBlockFromEntities } from '../blocks/block-library'; +import { setBlockLibraryVisible } from '../components/ui-state'; +import { importImageFromFile } from '../helpers/import-export-handlers/import-image-from-file'; +import { pickFile } from '../helpers/pick-file'; +import { getActiveToolActor, getSelectedEntities } from '../state'; import { Tool } from '../tools'; import { imageImportToolStateMachine } from '../tools/image-import-tool'; +import { ActorEvent, type FileSelectedEvent } from '../tools/tool.types'; +import type { CadCommand } from './command.types'; export const INSERT_COMMANDS: CadCommand[] = [ { @@ -12,5 +19,43 @@ export const INSERT_COMMANDS: CadCommand[] = [ hint: '래스터 이미지를 도면에 배치한다', tool: Tool.IMAGE_IMPORT, machine: imageImportToolStateMachine, + // 도구가 FILE_SELECTED를 기다리므로 파일 선택창을 띄워 이미지를 넣어 준다 + run: () => { + pickFile('image/*', async (file) => { + const image = await importImageFromFile(file); + const event: FileSelectedEvent = { type: ActorEvent.FILE_SELECTED, image }; + getActiveToolActor()?.send(event); + }); + return '이미지 파일 선택'; + }, + }, + { + id: 'BLOCK', + label: '블록 작성', + aliases: ['B'], + glyph: '🧩', + hint: '선택한 객체를 이름 붙여 블록 라이브러리에 저장한다', + needsSelection: true, + run: () => { + const entities = getSelectedEntities(); + const name = window.prompt('블록 이름', '')?.trim(); + if (!name) return '블록 작성 취소'; + void saveBlockFromEntities(name, entities).then(() => { + toast.success(`블록 '${name}' 저장 (객체 ${entities.length}개)`); + setBlockLibraryVisible(true); + }); + return `블록 '${name}' 저장`; + }, + }, + { + id: 'INSERT', + label: '블록 삽입', + aliases: ['I'], + glyph: '📥', + hint: '라이브러리에 저장한 블록을 도면에 놓는다', + run: () => { + setBlockLibraryVisible(true); + return '블록 라이브러리'; + }, }, ]; diff --git a/B07_DesignDetail/openwebcad/src/commands/run-command.ts b/B07_DesignDetail/openwebcad/src/commands/run-command.ts index 89f486e3..64b21f42 100644 --- a/B07_DesignDetail/openwebcad/src/commands/run-command.ts +++ b/B07_DesignDetail/openwebcad/src/commands/run-command.ts @@ -1,10 +1,10 @@ /** 명령 실행기 — 리본 버튼·명령행·단축키가 모두 이 경로로 들어온다. */ 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'; +import type { CadCommand } from './command.types'; +import { getCommandById, resolveCommandInput } from './registry'; const COMMAND_HISTORY_LIMIT = 200; @@ -34,6 +34,8 @@ export function runCommand(command: CadCommand): string { if (command.machine) { setActiveToolActor(new Actor(command.machine)); + // 도구를 켠 뒤 준비 동작이 필요한 명령(파일 선택 등)은 run()도 함께 부른다 + command.run?.(); log(`${command.id} ${command.label}`); return command.id; } diff --git a/B07_DesignDetail/openwebcad/src/components/BlockLibraryPanel.tsx b/B07_DesignDetail/openwebcad/src/components/BlockLibraryPanel.tsx new file mode 100644 index 00000000..3fc99300 --- /dev/null +++ b/B07_DesignDetail/openwebcad/src/components/BlockLibraryPanel.tsx @@ -0,0 +1,73 @@ +/** 블록 라이브러리 팔레트 — 저장한 블록을 고르면 삽입 도구가 켜진다 (INSERT/BLOCK) */ +import type { FC } from 'react'; +import { + deleteBlock, + exportBlocksToFile, + getBlocks, + importBlocksFromFile, +} from '../blocks/block-library'; +import { pickFile } from '../helpers/pick-file'; +import { startBlockInsert } from '../tools/insert-block-tool'; +import { isBlockLibraryVisible, setBlockLibraryVisible } from './ui-state'; + +export const BlockLibraryPanel: FC = () => { + if (!isBlockLibraryVisible()) return null; + + const blocks = getBlocks(); + + return ( +
+
+ 블록 라이브러리 + +
+ + {blocks.length === 0 ? ( +

객체를 선택하고 BLOCK 명령으로 저장하십시오.

+ ) : ( +
    + {blocks.map((block) => ( +
  • + + +
  • + ))} +
+ )} + +
+ + +
+
+ ); +}; diff --git a/B07_DesignDetail/openwebcad/src/components/Toolbar.tsx b/B07_DesignDetail/openwebcad/src/components/Toolbar.tsx index f3257f05..75eb81cd 100644 --- a/B07_DesignDetail/openwebcad/src/components/Toolbar.tsx +++ b/B07_DesignDetail/openwebcad/src/components/Toolbar.tsx @@ -6,6 +6,7 @@ import { type FC, useEffect, useState } from 'react'; import { CommandLine } from './CommandLine'; import { InspectorPanel } from './InspectorPanel'; import { QuickProperties } from './QuickProperties'; +import { BlockLibraryPanel } from './BlockLibraryPanel'; import { LayersWidget, PropertiesWidget, TextStyleWidget } from './RibbonWidgets'; import { QuickAccessBar } from './QuickAccessBar'; import { Ribbon } from '../ribbon/Ribbon'; @@ -51,6 +52,7 @@ export const Toolbar: FC = () => { /> + {commandLineVisible && } window.dispatchEvent(new CustomEvent(HtmlEvent.UPDATE_STATE)); @@ -17,6 +18,7 @@ export const getInspectorTab = () => inspectorTab; export const isInspectorCollapsed = () => inspectorCollapsed; export const isQuickPropertiesVisible = () => quickPropertiesVisible; export const getActiveRibbonTab = () => activeRibbonTab; +export const isBlockLibraryVisible = () => blockLibraryVisible; export function setActiveRibbonTab(tabId: string): void { activeRibbonTab = tabId; @@ -38,3 +40,8 @@ export function setQuickPropertiesVisible(visible: boolean): void { quickPropertiesVisible = visible; notify(); } + +export function setBlockLibraryVisible(visible: boolean): void { + blockLibraryVisible = visible; + notify(); +} diff --git a/B07_DesignDetail/openwebcad/src/helpers/pick-file.ts b/B07_DesignDetail/openwebcad/src/helpers/pick-file.ts new file mode 100644 index 00000000..efbb0e23 --- /dev/null +++ b/B07_DesignDetail/openwebcad/src/helpers/pick-file.ts @@ -0,0 +1,11 @@ +/** 파일 선택창을 띄우고 고른 파일 하나를 돌려준다. 취소하면 아무 일도 없다. */ +export function pickFile(accept: string, onPicked: (file: File) => void): void { + const input = document.createElement('input'); + input.type = 'file'; + input.accept = accept; + input.addEventListener('change', () => { + const file = input.files?.[0]; + if (file) onPicked(file); + }); + input.click(); +} diff --git a/B07_DesignDetail/openwebcad/src/ribbon/ribbon.config.ts b/B07_DesignDetail/openwebcad/src/ribbon/ribbon.config.ts index ae08bffd..bf8e4b3d 100644 --- a/B07_DesignDetail/openwebcad/src/ribbon/ribbon.config.ts +++ b/B07_DesignDetail/openwebcad/src/ribbon/ribbon.config.ts @@ -143,7 +143,10 @@ export const RIBBON_TABS: RibbonTab[] = [ { id: 'insert', label: '삽입', - panels: [{ label: '참조', commands: ['IMAGEATTACH'] }], + panels: [ + { label: '블록', big: ['INSERT'], commands: ['BLOCK'] }, + { label: '참조', commands: ['IMAGEATTACH'] }, + ], }, { id: 'annotate', @@ -182,9 +185,7 @@ export const RIBBON_TABS: RibbonTab[] = [ { id: 'view', label: '뷰', - panels: [ - { label: '탐색', big: ['ZOOM'], commands: ['ZOOMIN', 'ZOOMOUT', 'PAN', 'REGEN'] }, - ], + panels: [{ label: '탐색', big: ['ZOOM'], commands: ['ZOOMIN', 'ZOOMOUT', 'PAN', 'REGEN'] }], }, { id: 'output', diff --git a/B07_DesignDetail/openwebcad/src/tools.ts b/B07_DesignDetail/openwebcad/src/tools.ts index fe2d15ca..4b38c923 100644 --- a/B07_DesignDetail/openwebcad/src/tools.ts +++ b/B07_DesignDetail/openwebcad/src/tools.ts @@ -8,6 +8,7 @@ export enum Tool { SCALE = 'SCALE', ERASER = 'ERASER', IMAGE_IMPORT = 'IMAGE_IMPORT', + INSERT_BLOCK = 'INSERT_BLOCK', ROTATE = 'ROTATE', ALIGN_LEFT = 'ALIGN_LEFT', ALIGN_RIGHT = 'ALIGN_RIGHT', diff --git a/B07_DesignDetail/openwebcad/src/tools/insert-block-tool.ts b/B07_DesignDetail/openwebcad/src/tools/insert-block-tool.ts new file mode 100644 index 00000000..2ed74559 --- /dev/null +++ b/B07_DesignDetail/openwebcad/src/tools/insert-block-tool.ts @@ -0,0 +1,118 @@ +/** + * 블록 삽입 도구 — 라이브러리에서 고른 블록을 커서에 달고 다니다 클릭 한 번으로 놓는다. + * ponytail: 1:1·0도로만 놓는다. 회전·축척은 기존 ROTATE·SCALE 명령으로 한다. + */ +import type { Point } from '@flatten-js/core'; +import { toast } from 'react-toastify'; +import { Actor, assign, createMachine } from 'xstate'; +import { type BlockDefinition, deserializeBlock } from '../blocks/block-library'; +import type { Entity } from '../entities/Entity'; +import { getPointFromEvent } from '../helpers/get-point-from-event'; +import { + addEntities, + setActiveToolActor, + setAngleGuideOriginPoint, + setGhostHelperEntities, + setSelectedEntityIds, + setShouldDrawHelpers, +} from '../state'; +import { Tool } from '../tools'; +import { selectToolStateMachine } from './select-tool'; +import { ActorEvent, type PointInputEvent, type StateEvent, type ToolContext } from './tool.types'; + +/** 삽입 대기 중인 객체와 그 객체들이 지금 놓여 있는 기준점 */ +let pendingEntities: Entity[] = []; +let anchor: { x: number; y: number } = { x: 0, y: 0 }; + +function movePendingTo(x: number, y: number): void { + for (const entity of pendingEntities) entity.move(x - anchor.x, y - anchor.y); + anchor = { x, y }; +} + +/** 라이브러리 팔레트에서 블록을 고르면 부른다. 객체를 풀어 두고 삽입 도구를 켠다. */ +export async function startBlockInsert(block: BlockDefinition): Promise { + try { + pendingEntities = await deserializeBlock(block); + } catch { + toast.error(`블록 '${block.name}'을 풀지 못했습니다.`); + return; + } + if (!pendingEntities.length) { + toast.error(`블록 '${block.name}'에 객체가 없습니다.`); + return; + } + anchor = { ...block.basePoint }; + setActiveToolActor(new Actor(insertBlockToolStateMachine)); +} + +export enum InsertBlockState { + INIT = 'INIT', + WAITING_FOR_INSERTION_POINT = 'WAITING_FOR_INSERTION_POINT', +} + +export enum InsertBlockAction { + INIT_INSERT_BLOCK_TOOL = 'INIT_INSERT_BLOCK_TOOL', + DRAW_TEMP_BLOCK = 'DRAW_TEMP_BLOCK', + PLACE_BLOCK = 'PLACE_BLOCK', + SWITCH_TO_SELECT_TOOL = 'SWITCH_TO_SELECT_TOOL', +} + +export const insertBlockToolStateMachine = createMachine( + { + types: {} as { context: ToolContext; events: StateEvent }, + context: { type: Tool.INSERT_BLOCK }, + initial: InsertBlockState.INIT, + states: { + [InsertBlockState.INIT]: { + always: { + actions: InsertBlockAction.INIT_INSERT_BLOCK_TOOL, + target: InsertBlockState.WAITING_FOR_INSERTION_POINT, + }, + }, + [InsertBlockState.WAITING_FOR_INSERTION_POINT]: { + description: '블록을 놓을 위치를 지정한다', + meta: { instructions: '블록을 놓을 위치를 지정하십시오' }, + on: { + [ActorEvent.DRAW]: { actions: InsertBlockAction.DRAW_TEMP_BLOCK }, + [ActorEvent.MOUSE_CLICK]: { + actions: [InsertBlockAction.PLACE_BLOCK, InsertBlockAction.SWITCH_TO_SELECT_TOOL], + }, + [ActorEvent.ABSOLUTE_POINT_INPUT]: { + actions: [InsertBlockAction.PLACE_BLOCK, InsertBlockAction.SWITCH_TO_SELECT_TOOL], + }, + [ActorEvent.ESC]: { actions: InsertBlockAction.SWITCH_TO_SELECT_TOOL }, + }, + }, + }, + }, + { + actions: { + [InsertBlockAction.INIT_INSERT_BLOCK_TOOL]: assign(() => { + setShouldDrawHelpers(true); + setGhostHelperEntities([]); + setSelectedEntityIds([]); + setAngleGuideOriginPoint(null); + return {}; + }), + [InsertBlockAction.DRAW_TEMP_BLOCK]: ({ event }) => { + if (!pendingEntities.length) return; + const point = getPointFromEvent(null, event as PointInputEvent); + movePendingTo(point.x, point.y); + setGhostHelperEntities([...pendingEntities]); + }, + [InsertBlockAction.PLACE_BLOCK]: ({ event }) => { + if (!pendingEntities.length) return; + const point: Point = getPointFromEvent(null, event as PointInputEvent); + movePendingTo(point.x, point.y); + addEntities(pendingEntities, true); + pendingEntities = []; + setGhostHelperEntities([]); + }, + [InsertBlockAction.SWITCH_TO_SELECT_TOOL]: () => { + pendingEntities = []; + setGhostHelperEntities([]); + setActiveToolActor(new Actor(selectToolStateMachine)); + }, + }, + } +); diff --git a/B07_DesignDetail/openwebcad/vitest.tmp.config.ts b/B07_DesignDetail/openwebcad/vitest.tmp.config.ts index 125ea4bd..07da1a27 100644 --- a/B07_DesignDetail/openwebcad/vitest.tmp.config.ts +++ b/B07_DesignDetail/openwebcad/vitest.tmp.config.ts @@ -11,6 +11,7 @@ export default defineConfig({ resolve: { alias: { '@flatten-js/core': resolve('node_modules/@flatten-js/core'), + 'es-toolkit/compat': resolve('node_modules/es-toolkit/dist/compat/index.mjs'), 'es-toolkit': resolve('node_modules/es-toolkit'), }, }, diff --git a/B07_DesignDetail/openwebcad/임시_AutoCAD_리본_기능_조사.md b/B07_DesignDetail/openwebcad/임시_AutoCAD_리본_기능_조사.md index 4f4c6c4e..3c9143a7 100644 --- a/B07_DesignDetail/openwebcad/임시_AutoCAD_리본_기능_조사.md +++ b/B07_DesignDetail/openwebcad/임시_AutoCAD_리본_기능_조사.md @@ -117,33 +117,41 @@ | 그룹(패널) | 기능/명령 | 단축키 | 짧은 설명 | 반영 등급 | 반영 | | ------------ | --------------------------------- | ----------- | ------------------------------------------------------------------------------ | --------- | ------- | -| 블록 | 블록 삽입`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 | ☐ | +| 블록 | 블록 삽입`INSERT` | I | 현재 도면·최근 항목·라이브러리의 블록을 배치한다. | A | ☑ | +| 블록 | 스마트 블록 배치 | — | 블록 팔레트로 삽입할 때 기존 동일 블록을 바탕으로 위치·회전·축척을 제안한다. | C | X | +| 블록 | 블록 교체`BREPLACE` | — | 선택 블록을 제안 목록 또는 지정한 다른 블록으로 교체한다. | C | X | +| 블록 정의 | 블록 작성`BLOCK` | B | 선택 객체와 기준점으로 블록 정의를 만든다. | A | ☑ | +| 블록 정의 | 블록 편집기`BEDIT` | BE | 블록 정의와 동적 블록 동작을 편집한다. | C | X | +| 블록 정의 | 블록 쓰기`WBLOCK` | W | 선택 객체 또는 블록을 별도 DWG로 저장한다. | C | X | +| 블록 정의 | 속성 정의`ATTDEF` | ATT | 블록에 포함할 문자 데이터 필드를 정의한다. | C | X | +| 블록 정의 | 속성 관리`BATTMAN` | — | 블록 속성의 순서·표시·기본값을 관리한다. | C | X | +| 블록 정의 | 속성 동기화`ATTSYNC` | — | 변경된 속성 정의를 기존 블록 참조에 반영한다. | C | X | +| 블록 정의 | 향상된 속성 편집`EATTEDIT` | — | 삽입된 블록 참조의 속성 값과 문자·표시 특성을 편집한다. | C | X | | 참조 | DWG 부착`XATTACH` | XA | 다른 DWG를 외부 참조로 연결한다. | C | X | -| 참조 | 이미지 부착`IMAGEATTACH` | IAT | 래스터 이미지를 참조로 연결한다. | A | ☑ 기존 | +| 참조 | 이미지 부착`IMAGEATTACH` | IAT | 래스터 이미지를 참조로 연결한다. | A | ☑ | | 참조 | PDF 부착`PDFATTACH` | — | PDF 페이지를 언더레이로 연결한다. | C | X | | 참조 | DWF/DGN 부착 | — | DWF 또는 DGN을 언더레이로 연결한다. | C | X | | 참조 | 점 구름 부착 | — | 스캔된 점 구름 데이터를 도면에 연결한다. | C | X | -| 참조 | 외부 참조 팔레트`XREF` | XR | 참조의 로드·언로드·재로드·경로·결합을 관리한다. | C | ☐ | +| 참조 | 외부 참조 팔레트`XREF` | XR | 참조의 로드·언로드·재로드·경로·결합을 관리한다. | C | X | | 참조 | 참조 내부 편집`REFEDIT` | — | 블록 또는 외부 참조를 현재 도면 안에서 직접 편집한다. | C | X | | 참조 | 외부 참조 자르기`XCLIP` | XC | DWG 외부 참조 또는 블록의 표시 경계를 지정한다. | C | X | -| 참조 | 이미지 자르기`IMAGECLIP` | ICL | 부착 이미지의 표시 경계를 지정한다. | B | ☐ | +| 참조 | 이미지 자르기`IMAGECLIP` | ICL | 부착 이미지의 표시 경계를 지정한다. | C | X | | 참조 | PDF 자르기`PDFCLIP` | — | PDF 언더레이의 표시 경계를 지정한다. | C | X | | 가져오기 | PDF 가져오기`PDFIMPORT` | — | PDF의 벡터 형상·문자·채움을 도면 객체로 변환한다. | C | X | -| 가져오기 | 가져오기`IMPORT` | IMP | 지원되는 다른 형식의 데이터를 현재 도면으로 가져온다. | B | X | +| 가져오기 | 가져오기`IMPORT` | IMP | 지원되는 다른 형식의 데이터를 현재 도면으로 가져온다. | C | X | | 링크 및 추출 | 데이터 링크`DATALINK` | DL | Excel 등 외부 표 데이터와 도면 테이블을 연결한다. | C | X | -| 링크 및 추출 | 데이터 추출`DATAEXTRACTION` | DX | 객체·블록·속성 정보를 표 또는 외부 파일로 추출한다. | B | X | +| 링크 및 추출 | 데이터 추출`DATAEXTRACTION` | DX | 객체·블록·속성 정보를 표 또는 외부 파일로 추출한다. | C | X | | 링크 및 추출 | 하이퍼링크`HYPERLINK` | — | 객체에 웹·파일·도면 뷰 링크를 연결한다. | C | X | | 링크 및 추출 | 필드`FIELD` | — | 날짜·도면·객체 특성 등 갱신 가능한 문자 값을 삽입한다. | C | X | -| 위치 | 지리적 위치`GEOGRAPHICLOCATION` | GEO / NORTH | 좌표계와 지도상의 도면 위치를 지정한다. | C | ☐ | +| 위치 | 지리적 위치`GEOGRAPHICLOCATION` | GEO / NORTH | 좌표계와 지도상의 도면 위치를 지정한다. | C | X | + +**4절 결정(2026-08-30)**: 사용자가 그린 객체를 이름 붙여 저장·재삽입하는 블록(`BLOCK`/`INSERT`)과 +사진 넣기(`IMAGEATTACH`)만 반영한다. 블록은 정의-참조 링크 없는 클론 방식이라 정의를 고쳐도 이미 +삽입한 객체는 그대로다. 라이브러리는 브라우저에 저장하고 파일로 내보내기·가져오기를 둔다. +나머지는 DWG·데스크톱 종속이거나(외부 참조·PDF·DGN·점 구름·속성·데이터 링크·필드) 대체 수단이 +있어(`IMPORT`는 수요 없음, `DATAEXTRACTION`은 수량표 패널) 반영하지 않는다. +`IMAGEATTACH`는 표에 반영됨으로 적혀 있었으나 파일 선택 경로가 없어 실제로는 동작하지 않았고, +이번에 배선을 복구했다. ## 5. 주석 탭 — 문자·치수·지시선·표·표식 @@ -212,24 +220,29 @@ | 탐색 | 줌`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 | ☐ | +| 뷰 | 명명된 뷰`VIEW` | V | 현재 화면·도면층·UCS 상태를 이름으로 저장하고 복원한다. | C | X | +| 뷰 | 새 명명 뷰`NEWVIEW` | NVIEW | 현재 화면 또는 지정 영역을 새 이름의 뷰로 저장한다. | C | X | +| 뷰 | 재생성`REGEN` | RE | 현재 뷰포트의 객체 표시를 다시 계산한다. | A | ☑ | +| 뷰 | 전체 재생성`REGENALL` | REA | 모든 뷰포트의 객체 표시를 다시 계산한다. | C | X | +| 뷰포트 | 모형 공간 뷰포트 | — | 도면 영역을 여러 독립 관찰 창으로 나눈다. | C | X | +| 뷰포트 | 배치 뷰포트`MVIEW` | MV | 종이 공간에 모형을 표시하는 출력 창을 작성한다. | C | X | +| 뷰포트 | 뷰포트 잠금 | — | 배치 뷰포트의 축척과 관찰 상태 변경을 막는다. | C | X | +| 좌표 | UCS`UCS` | — | 사용자 좌표계의 원점과 축 방향을 설정한다. | C | X | +| 좌표 | 월드 UCS | — | 좌표계를 기본 WCS로 복원한다. | C | X | +| 팔레트 | 도구 팔레트`TOOLPALETTES` | TP / Ctrl+3 | 자주 쓰는 블록·해치·명령 도구 모음을 표시한다. | C | X | +| 팔레트 | 개수`COUNT` | — | 선택 객체·블록의 인스턴스를 세고 결과를 강조·검토한다. | C | X | +| 팔레트 | DesignCenter`ADCENTER` | ADC / Ctrl+2 | 다른 도면의 블록·도면층·스타일을 탐색해 가져온다. | C | X | +| 팔레트 | 시트 세트 관리자 | Ctrl+4 / SSM | 여러 도면 시트의 집합·번호·출력을 관리한다. | C | X | | 팔레트 | 명령행 | Ctrl+9 / CLI | 명령 입력, 옵션 선택 및 작업 기록을 표시한다. | A | ☑ 기존 | -| 팔레트 | 명령 매크로 | — | 반복 명령 시퀀스를 검토·저장·실행한다. | C | ☐ | -| 인터페이스 | 파일 탭 | Ctrl+Tab | 열려 있는 여러 도면 사이를 전환한다. | B | ☐ | -| 인터페이스 | 타일·계단식 배열 | — | 여러 열린 도면 창의 배치를 조정한다. | C | ☐ | -| 인터페이스 | 전체 화면 정리`CLEANSCREENON` | Ctrl+0 | 도구막대와 팔레트를 숨겨 도면 영역을 최대화한다. | B | ☐ | +| 팔레트 | 명령 매크로 | — | 반복 명령 시퀀스를 검토·저장·실행한다. | C | X | +| 인터페이스 | 파일 탭 | Ctrl+Tab | 열려 있는 여러 도면 사이를 전환한다. | C | X | +| 인터페이스 | 타일·계단식 배열 | — | 여러 열린 도면 창의 배치를 조정한다. | C | X | +| 인터페이스 | 전체 화면 정리`CLEANSCREENON` | Ctrl+0 | 도구막대와 팔레트를 숨겨 도면 영역을 최대화한다. | C | X | + +**7절 결정(2026-08-30)**: 신규 구현 없음. `ZOOM`·`PAN`·탐색 막대·명령행·`REGEN`으로 충분하다. +`파일 탭`은 호스트 화면이 이미 도면을 넘긴다(`requestDrawingNavigation`). `배치 뷰포트`·`모형 공간 +뷰포트`는 종이공간 개념 자체가 없어 실질 C다. 명명된 뷰·UCS·`COUNT`·전체 화면 정리는 만들 수는 +있으나 쓸 자리가 확인되지 않아 보류했다 — 필요해지면 그때 등급을 올린다. ## 8. 관리 탭 — 표준·사용자화·자동화