feat(B07): 그린 것을 블록으로 저장해 다시 놓고, 사진 넣기를 되살린다

조사표 4·7절 검토 결과를 반영한다.

- 블록 라이브러리: 선택한 객체를 이름 붙여 저장(BLOCK)하고 팔레트에서 골라
  클릭 한 번으로 놓는다(INSERT). 정의-참조 링크 없는 클론 방식이라 새 엔티티
  타입 없이 기존 직렬화·groupId를 그대로 쓴다. 기준점은 선택 영역 중심.
  라이브러리는 도면과 분리해 브라우저에 두고, 유실 대비로 파일 내보내기·
  가져오기를 붙였다.
- 사진 넣기: IMAGEATTACH가 파일 선택창을 띄우지 않아 도구가 FILE_SELECTED를
  영원히 기다리고 있었다. 도구형 명령도 run()을 함께 부르게 하고 파일 선택을
  붙여 되살렸다.
- 조사표 4·7절의 반영 등급·반영 열을 실제 코드에 맞춰 정정하고, 반영하지
  않기로 한 항목의 사유를 절마다 남겼다. 7절은 신규 구현 없음.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-08-30 14:00:42 +09:00
co-authored by Claude Opus 5
parent eb4303253a
commit 3fccf8278b
13 changed files with 500 additions and 40 deletions
+63
View File
@@ -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;
}
@@ -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<void> {
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<Entity[]> {
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');
}
@@ -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 '블록 라이브러리';
},
},
];
@@ -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;
}
@@ -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 (
<div className="cad-block-library controls">
<div className="cad-quick-properties__header">
<span> </span>
<button type="button" onClick={() => setBlockLibraryVisible(false)} title="닫기">
</button>
</div>
{blocks.length === 0 ? (
<p className="cad-block-library__empty"> BLOCK .</p>
) : (
<ul className="cad-block-library__list">
{blocks.map((block) => (
<li key={block.name}>
<button
type="button"
className="cad-block-library__name"
title={`객체 ${block.entities.length}개 — 클릭하면 삽입`}
onClick={() => {
setBlockLibraryVisible(false);
void startBlockInsert(block);
}}
>
{block.name}
</button>
<button
type="button"
title="삭제"
onClick={() => {
if (window.confirm(`블록 '${block.name}'을 삭제하시겠습니까?`)) {
deleteBlock(block.name);
}
}}
>
</button>
</li>
))}
</ul>
)}
<div className="cad-block-library__actions">
<button type="button" onClick={() => exportBlocksToFile()}>
</button>
<button
type="button"
onClick={() => pickFile('application/json', (file) => importBlocksFromFile(file))}
>
</button>
</div>
</div>
);
};
@@ -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 = () => {
/>
<ViewControls />
<QuickProperties />
<BlockLibraryPanel />
{commandLineVisible && <CommandLine />}
<StatusBar
commandLineVisible={commandLineVisible}
@@ -10,6 +10,7 @@ let inspectorTab: InspectorTab = 'layers';
let inspectorCollapsed = false;
let quickPropertiesVisible = false;
let activeRibbonTab = 'home';
let blockLibraryVisible = false;
const notify = () => 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();
}
@@ -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();
}
@@ -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',
+1
View File
@@ -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',
@@ -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<void> {
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));
},
},
}
);
@@ -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'),
},
},
@@ -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. 관리 탭 — 표준·사용자화·자동화