feat(B07): CAD 명령 TOAREA 면적산출로 보내기 · 부모 요청 export-table 메시지(83-2)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015KYZ2HWWdUwZWYnn3tQz7X
This commit is contained in:
2026-10-08 12:09:29 +09:00
co-authored by Claude Opus 5.5
parent d8c75722f4
commit 4e9df0e4c9
5 changed files with 236 additions and 0 deletions
@@ -4,6 +4,7 @@ import { clearRecovery, restoreRecovery } from '../helpers/autosave';
import { exportEntitiesToJsonFile } from '../helpers/import-export-handlers/export-entities-to-json';
import { exportEntitiesToLocalStorage } from '../helpers/import-export-handlers/export-entities-to-local-storage';
import { requestDrawingExport, requestHostSave } from '../integration/aislo-drawing-bridge';
import { runSendSelectionTable } from '../integration/aislo-table-export';
import { exportEntitiesToPngFile } from '../helpers/import-export-handlers/export-entities-to-png';
import { exportEntitiesToSvgFile } from '../helpers/import-export-handlers/export-entities-to-svg';
import { isHostSave, redo, undo } from '../state';
@@ -87,6 +88,14 @@ export const FILE_COMMANDS: CadCommand[] = [
return 'PNG 내보내기';
},
},
{
id: 'TOAREA',
label: '면적산출로 보내기',
glyph: '▦',
hint: '고른 범위(표 · 선 표 · 글)를 면적산출 새 시트로 한 번 옮긴다',
needsSelection: true,
run: runSendSelectionTable,
},
{
id: 'UNDO',
label: '실행 취소',
@@ -89,6 +89,7 @@ const VIEW_ONLY_COMMAND_IDS: ReadonlySet<string> = new Set([
'EXPORTPNG',
'COPYCLIP',
'COPYBASE',
'TOAREA', // 고른 범위 → 면적산출 표(M02 83장)
]);
/** 확정한 도면에서 이 명령을 실행해도 되는가. */
@@ -0,0 +1,83 @@
/**
* CAD → 부모 화면 표 보내기(M02 83장 · 도면 → 면적산출 한 번 옮기기 · 연동 아님). 짝 = `M02_MasterTemplete_AreaDrawing.ts`.
* export-table-request 부모 [면적산출로 보내기] 단추 → 고른 범위를 표 자료로
* export-table{table} 표 자료(`helpers/table-from-drawing.ts` 꼴) → 부모가 새 시트로
* CAD 명령 TOAREA(「면적산출로 보내기」)도 같은 길. 보기 전용 도면에서도 됨(도면을 안 바꿈).
* 고른 것이 없거나 옮길 글이 없으면 부모에 안 보내고 안내만.
*/
import { toast } from 'react-toastify';
import type { DrawingEntityJson } from '../helpers/table-from-drawing.ts';
import { type DrawingTable, tableFromDrawing } from '../helpers/table-from-drawing.ts';
import { getSelectedEntities } from '../state.ts';
export const AISLO_EXPORT_TABLE_REQUEST_MESSAGE = 'aislo:b08:export-table-request';
export const AISLO_EXPORT_TABLE_MESSAGE = 'aislo:b08:export-table';
const TEXT = {
empty: '면적산출로 보낼 범위를 먼저 고르세요(누름 · 끌기).',
noText: '고른 범위에 글이 없어 표로 옮길 것이 없습니다.',
standalone: '면적산출 화면에서 연 도면에서만 보낼 수 있습니다.',
};
type Notify = (type: string, payload: Record<string, unknown>) => void;
function notifyParent(type: string, payload: Record<string, unknown>): void {
if (window.parent === window) return;
window.parent.postMessage({ type, ...payload }, window.location.origin);
}
/** 고른 범위 → 표 자료 · 고른 것이 없으면 'empty' · 글이 없으면 'no-text' */
export async function selectionTable(): Promise<DrawingTable | 'empty' | 'no-text'> {
const selected = getSelectedEntities();
if (!selected.length) return 'empty';
const json = (await Promise.all(selected.map((entity) => entity.toJson()))).filter(
(entity): entity is NonNullable<typeof entity> => Boolean(entity)
);
return tableFromDrawing(json as DrawingEntityJson[]) ?? 'no-text';
}
/** 고른 범위를 표로 만들어 부모에 보냄 — 보냈으면 true */
export async function sendSelectionTable(notify: Notify = notifyParent): Promise<boolean> {
const result = await selectionTable();
if (result === 'empty') toast.info(TEXT.empty);
else if (result === 'no-text') toast.info(TEXT.noText);
else {
notify(AISLO_EXPORT_TABLE_MESSAGE, { table: result });
return true;
}
return false;
}
/** CAD 명령 TOAREA — 부모 없이 띄운 CAD(단독)면 안내만 */
export function runSendSelectionTable(): string {
if (window.parent === window) {
toast.info(TEXT.standalone);
return '부모 화면 없음';
}
void sendSelectionTable();
return '면적산출로 보내기';
}
/** 부모 요청 메시지면 처리하고 true */
export async function handleTableExportMessage(
data: unknown,
notify: Notify = notifyParent
): Promise<boolean> {
const message = data as { type?: string } | null;
if (
!message ||
typeof message !== 'object' ||
message.type !== AISLO_EXPORT_TABLE_REQUEST_MESSAGE
)
return false;
await sendSelectionTable(notify);
return true;
}
/** 부모 [면적산출로 보내기] 요청을 받음 — 같은 출처 · 부모 창만 */
export function registerAisloTableExport(): void {
window.addEventListener('message', (event: MessageEvent<unknown>) => {
if (event.origin !== window.location.origin || event.source !== window.parent) return;
void handleTableExportMessage(event.data);
});
}
+2
View File
@@ -18,6 +18,7 @@ import { queryEntitiesNearPoint } from './helpers/spatial-index';
import { trackHoveredSnapPoint } from './helpers/track-hovered-snap-points';
import { InputController } from './inputController/input-controller.ts';
import { registerAisloDrawingBridge } from './integration/aislo-drawing-bridge.ts';
import { registerAisloTableExport } from './integration/aislo-table-export.ts';
import { registerDxfClipPaste } from './integration/dxf-clip-paste.ts';
import {
getActiveToolActor,
@@ -155,6 +156,7 @@ function initApplication() {
setLayers(layers);
setActiveLayerId(layers[0].id);
registerAisloDrawingBridge();
registerAisloTableExport(); // 고른 범위 → 면적산출 표(M02 83장)
registerDxfClipPaste();
registerCadDebugHook();
registerAutoSave();
+141
View File
@@ -0,0 +1,141 @@
/**
* CAD → 부모 표 보내기(`integration/aislo-table-export.ts` · M02 83-2) 검증.
* 실행: cd B07_DesignDetail/openwebcad && npx vitest run --dir ../../resources/tester/cad table-export
* (시험 파일은 바깥 라이브러리를 직접 부르지 않음 — 엔티티는 fromJson 으로 만듦)
*/
import { beforeEach, describe, expect, it, vi } from "vitest";
import {
getCommandById,
isViewOnlyCommand,
} from "../../../B07_DesignDetail/openwebcad/src/commands/registry.ts";
import type { JsonEntity } from "../../../B07_DesignDetail/openwebcad/src/entities/Entity.ts";
import { LineEntity } from "../../../B07_DesignDetail/openwebcad/src/entities/LineEntity.ts";
import { TextEntity } from "../../../B07_DesignDetail/openwebcad/src/entities/TextEntity.ts";
import {
AISLO_EXPORT_TABLE_MESSAGE,
AISLO_EXPORT_TABLE_REQUEST_MESSAGE,
handleTableExportMessage,
selectionTable,
} from "../../../B07_DesignDetail/openwebcad/src/integration/aislo-table-export.ts";
import {
getEntities,
setEntities,
setLayers,
setSelectedEntityIds,
} from "../../../B07_DesignDetail/openwebcad/src/state.ts";
const layer = { id: "layer-1", isLocked: false, isVisible: true, name: "작업" };
const line = (x1: number, y1: number, x2: number, y2: number) =>
LineEntity.fromJson({
id: crypto.randomUUID(),
type: "Line",
lineColor: "#fff",
lineWidth: 1,
layerId: layer.id,
shapeData: { startPoint: { x: x1, y: y1 }, endPoint: { x: x2, y: y2 } },
} as unknown as JsonEntity<never>);
const text = (label: string, x: number, y: number) =>
TextEntity.fromJson({
id: crypto.randomUUID(),
type: "Text",
lineColor: "#fff",
lineWidth: 1,
layerId: layer.id,
shapeData: {
label,
basePoint: { x, y },
options: {
textDirection: { x: 1, y: 0 },
textAlign: "left",
textColor: "#fff",
fontSize: 2.5,
fontFamily: "Noto Sans KR",
},
},
} as unknown as JsonEntity<never>);
/** 2×2 선 표 + 글 넷 + 표 밖 선 하나(고르지 않음) */
async function drawing() {
const parts = await Promise.all([
line(0, 20, 40, 20),
line(0, 10, 40, 10),
line(0, 0, 40, 0),
line(0, 0, 0, 20),
line(20, 0, 20, 20),
line(40, 0, 40, 20),
text("항목", 2, 14),
text("수량", 22, 14),
text("흙깎기", 2, 4),
text("1,500", 22, 4),
]);
const outside = await line(100, 100, 200, 100);
setEntities([...parts, outside]);
return { parts, outside };
}
beforeEach(() => {
setLayers([layer]);
setEntities([]);
setSelectedEntityIds([]);
});
describe("고른 범위 → 표 자료", () => {
it("고른 선 + 글만 표로 · 숫자 글은 숫자", async () => {
const { parts } = await drawing();
setSelectedEntityIds(parts.map((entity) => entity.id));
const table = await selectionTable();
expect(table).toMatchObject({
kind: "grid",
rows: [
["항목", "수량"],
["흙깎기", 1500],
],
colWidths: [20, 20],
});
});
it("고른 것 없음 = empty · 글 없는 선 = no-text", async () => {
const { outside } = await drawing();
expect(await selectionTable()).toBe("empty");
setSelectedEntityIds([outside.id]);
expect(await selectionTable()).toBe("no-text");
});
});
describe("부모 요청 메시지", () => {
it("export-table-request = 고른 범위를 export-table{table} 로 회신 · 도면은 그대로", async () => {
const { parts } = await drawing();
setSelectedEntityIds(parts.map((entity) => entity.id));
const before = getEntities();
const notify = vi.fn();
expect(
await handleTableExportMessage({ type: AISLO_EXPORT_TABLE_REQUEST_MESSAGE }, notify),
).toBe(true);
expect(notify).toHaveBeenCalledTimes(1);
const [type, payload] = notify.mock.calls[0];
expect(type).toBe(AISLO_EXPORT_TABLE_MESSAGE);
expect(payload.table.rows[1]).toEqual(["흙깎기", 1500]);
expect(getEntities()).toBe(before);
});
it("고른 것이 없으면 회신 안 함 · 다른 메시지는 false", async () => {
await drawing();
const notify = vi.fn();
expect(
await handleTableExportMessage({ type: AISLO_EXPORT_TABLE_REQUEST_MESSAGE }, notify),
).toBe(true);
expect(notify).not.toHaveBeenCalled();
expect(await handleTableExportMessage({ type: "aislo:b08:save-request" }, notify)).toBe(false);
expect(await handleTableExportMessage(null, notify)).toBe(false);
});
});
describe("CAD 명령 TOAREA", () => {
it("고른 것이 있어야 하고 · 확정(보기 전용) 도면에서도 됨", () => {
const command = getCommandById("TOAREA");
expect(command?.label).toBe("면적산출로 보내기");
expect(command?.needsSelection).toBe(true);
expect(command && isViewOnlyCommand(command)).toBe(true);
});
});