diff --git a/B07_DesignDetail/openwebcad/src/commands/commands.file.ts b/B07_DesignDetail/openwebcad/src/commands/commands.file.ts index bfc01319b..f5978a9bd 100644 --- a/B07_DesignDetail/openwebcad/src/commands/commands.file.ts +++ b/B07_DesignDetail/openwebcad/src/commands/commands.file.ts @@ -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: '실행 취소', diff --git a/B07_DesignDetail/openwebcad/src/commands/registry.ts b/B07_DesignDetail/openwebcad/src/commands/registry.ts index 1409ee81d..f6974dcb2 100644 --- a/B07_DesignDetail/openwebcad/src/commands/registry.ts +++ b/B07_DesignDetail/openwebcad/src/commands/registry.ts @@ -89,6 +89,7 @@ const VIEW_ONLY_COMMAND_IDS: ReadonlySet = new Set([ 'EXPORTPNG', 'COPYCLIP', 'COPYBASE', + 'TOAREA', // 고른 범위 → 면적산출 표(M02 83장) ]); /** 확정한 도면에서 이 명령을 실행해도 되는가. */ diff --git a/B07_DesignDetail/openwebcad/src/integration/aislo-table-export.ts b/B07_DesignDetail/openwebcad/src/integration/aislo-table-export.ts new file mode 100644 index 000000000..20f8da719 --- /dev/null +++ b/B07_DesignDetail/openwebcad/src/integration/aislo-table-export.ts @@ -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) => void; + +function notifyParent(type: string, payload: Record): void { + if (window.parent === window) return; + window.parent.postMessage({ type, ...payload }, window.location.origin); +} + +/** 고른 범위 → 표 자료 · 고른 것이 없으면 'empty' · 글이 없으면 'no-text' */ +export async function selectionTable(): Promise { + const selected = getSelectedEntities(); + if (!selected.length) return 'empty'; + const json = (await Promise.all(selected.map((entity) => entity.toJson()))).filter( + (entity): entity is NonNullable => Boolean(entity) + ); + return tableFromDrawing(json as DrawingEntityJson[]) ?? 'no-text'; +} + +/** 고른 범위를 표로 만들어 부모에 보냄 — 보냈으면 true */ +export async function sendSelectionTable(notify: Notify = notifyParent): Promise { + 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 { + 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) => { + if (event.origin !== window.location.origin || event.source !== window.parent) return; + void handleTableExportMessage(event.data); + }); +} diff --git a/B07_DesignDetail/openwebcad/src/main.tsx b/B07_DesignDetail/openwebcad/src/main.tsx index 9a42948d5..043965ab2 100644 --- a/B07_DesignDetail/openwebcad/src/main.tsx +++ b/B07_DesignDetail/openwebcad/src/main.tsx @@ -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(); diff --git a/resources/tester/cad/table-export.test.ts b/resources/tester/cad/table-export.test.ts new file mode 100644 index 000000000..318a99d04 --- /dev/null +++ b/resources/tester/cad/table-export.test.ts @@ -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); +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); + +/** 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); + }); +});