diff --git a/M02_MasterTemplete/M02_MasterTemplete_AreaDrawing.ts b/M02_MasterTemplete/M02_MasterTemplete_AreaDrawing.ts index bcd413cca..491ddcbf6 100644 --- a/M02_MasterTemplete/M02_MasterTemplete_AreaDrawing.ts +++ b/M02_MasterTemplete/M02_MasterTemplete_AreaDrawing.ts @@ -1,19 +1,51 @@ /* ============================================================================= * M02_MasterTemplete_AreaDrawing.ts - * 면적산출 상세 오른쪽 「도면」 패널 몸(PLAN 47-6) — 그 열 구조물 도면을 보기만(확대 · 이동). - * 그리기는 구조물 조합 도면 카드 미리보기와 같은 길(`DrawingCard.mountDrawingView`). + * 면적산출 상세 오른쪽 「도면」 패널 몸(PLAN 47-6) — 그 열 구조물 도면을 보기 · 고르기(고치기 없음). * 처음 펼 때 읽고 그림 — 접힌 채 iframe 을 띄우지 않음. + * 83-3 — 머리 [면적산출로 보내기] = CAD 에 고른 범위 요청(`aislo:b08:export-table-request`) → 받은 표 자료 + * (`aislo:b08:export-table` · CAD `integration/aislo-table-export.ts`)를 `onTable` 로(새 시트 만들기는 부른 쪽). + * CAD 명령 TOAREA 로 보낸 것도 같은 길. 고칠 수 없는 면적(읽기 층)이면 단추 없음. * ========================================================================== */ -import { el } from "@ui/ui_template_elements"; +import { createButton, el } from "@ui/ui_template_elements"; import { readTemplate, type Layer, type StructureDoc } from "./M02_MasterTemplete_Api_Fetch"; -import { mountDrawingView } from "./M02_MasterTemplete_DrawingCard"; +import { mountDrawingTemplate, type DrawingTemplateDoc } from "./M02_MasterTemplete_Drawing"; +import type { DrawingTable } from "./M02_MasterTemplete_AreaFromDrawing"; const TEXT = { loading: "도면 불러오는 중…", none: "이 열은 구조물 도면이 없음", + send: "면적산출로 보내기", + sendTip: + "도면에서 고른 범위(표 · 선으로 그린 표 · 글)를 면적산출 새 시트로 한 번 옮김 — 연동 아님 · 누름 · 끌기로 고름", }; +export const EXPORT_TABLE_REQUEST = "aislo:b08:export-table-request"; +export const EXPORT_TABLE = "aislo:b08:export-table"; + +/** 고르기만 — 제목줄 · 리본 · 패널 · 상태줄 숨김(카드 고치기 갈래와 같은 숨김 · 명령 안내 줄은 남김) */ +const HIDE = + ".cad-titlebar, .cad-ribbon, .cad-inspector, .cad-statusbar, .cad-quick-access" + + " { display: none !important; }"; + +function hideChrome(frame: HTMLIFrameElement): void { + const apply = (): void => { + const inner = frame.contentDocument; + if (!inner?.head || inner.getElementById("m02-area-hide")) return; + const style = inner.createElement("style"); + style.id = "m02-area-hide"; + style.textContent = HIDE; + inner.head.append(style); + }; + frame.addEventListener("load", apply); + apply(); +} + +export interface AreaDrawingOptions { + /** 받은 표 자료 — 없으면(읽기 층) 보내기 단추 없음 */ + onTable?: (table: DrawingTable) => void; +} + export interface AreaDrawing { root: HTMLElement; /** 패널이 펼쳐짐 — 처음 한 번만 읽고 그림 */ @@ -21,13 +53,37 @@ export interface AreaDrawing { destroy: () => void; } -export function createAreaDrawing(layer: Layer, column: string): AreaDrawing { +export function createAreaDrawing( + layer: Layer, + column: string, + opts: AreaDrawingOptions = {}, +): AreaDrawing { const root = el("div", { className: "m02-apanel__drawing" }); + const stage = el("div", { className: "m02-apanel__drawstage" }); let view: { destroy: () => void } | null = null; + let frame: HTMLIFrameElement | null = null; let started = false; let dead = false; const note = (text: string): void => - root.replaceChildren(el("p", { className: "m02-apanel__note", text })); + stage.replaceChildren(el("p", { className: "m02-apanel__note", text })); + + const send = createButton({ label: TEXT.send, size: "sm", variant: "ghost" }); + send.title = TEXT.sendTip; + send.dataset.areaFromDrawing = ""; + send.disabled = true; // 도면이 서면 켬 + send.addEventListener("click", () => + frame?.contentWindow?.postMessage({ type: EXPORT_TABLE_REQUEST }, window.location.origin), + ); + if (opts.onTable) root.append(el("div", { className: "m02-apanel__drawbar", children: [send] })); + root.append(stage); + + const onMessage = (event: MessageEvent): void => { + if (!frame || event.source !== frame.contentWindow) return; + if (event.origin !== window.location.origin) return; + const data = event.data as { type?: string; table?: DrawingTable } | null; + if (data?.type === EXPORT_TABLE && data.table && opts.onTable) opts.onTable(data.table); + }; + window.addEventListener("message", onMessage); const show = (): void => { if (started) return; @@ -38,8 +94,21 @@ export function createAreaDrawing(layer: Layer, column: string): AreaDrawing { if (dead) return; const doc = got.문서 as StructureDoc | null; if (!doc?.도면) return note(TEXT.none); - root.replaceChildren(); - view = mountDrawingView(root, doc, { layer, projectId: null }, column); + stage.replaceChildren(); + // 보기 전용 + 고르기(누름 · 끌기) — 미리보기(끌기 = 화면 옮기기)는 고르기가 꺼져 있어 안 씀 + view = mountDrawingTemplate(stage, doc.도면 as unknown as DrawingTemplateDoc, { + readOnly: true, + name: `preview:${column}`, + layer, + panelsCollapsed: true, + originMarker: true, + noArea: true, + frame: true, + noImport: true, + }); + frame = stage.querySelector("iframe"); + if (frame) hideChrome(frame); + send.disabled = !frame; }) .catch(() => !dead && note(TEXT.none)); }; @@ -49,6 +118,7 @@ export function createAreaDrawing(layer: Layer, column: string): AreaDrawing { show, destroy: () => { dead = true; + window.removeEventListener("message", onMessage); view?.destroy(); root.remove(); }, diff --git a/M02_MasterTemplete/M02_MasterTemplete_AreaFromDrawing.ts b/M02_MasterTemplete/M02_MasterTemplete_AreaFromDrawing.ts new file mode 100644 index 000000000..60786eb97 --- /dev/null +++ b/M02_MasterTemplete/M02_MasterTemplete_AreaFromDrawing.ts @@ -0,0 +1,111 @@ +/* ============================================================================= + * M02_MasterTemplete_AreaFromDrawing.ts + * 도면 → 면적산출 한 번 옮기기(PLAN 83-3 · 연동 아님) — 오른쪽 도면 패널(`AreaDrawing`)이 받은 표 자료를 + * 면적 통합문서의 새 시트(「도면에서 n」)로 만듦. 기존 시트는 안 덮음 · 병합 · 열 폭 · 행 높이 반영. + * 표 자료 꼴 = CAD `helpers/table-from-drawing.ts` 의 `DrawingTable`(rows · merges · colWidths · rowHeights · 도면 mm). + * 스프레드시트 부품은 안 고침 — 시트 자료만 만들고 싣기는 공개 손잡이 `addSheet`(되돌리기 한 번 · onChange → 화면 고침 표시). + * ========================================================================== */ + +import { showToast } from "@ui/ui_template_elements"; +import type { Cell, Sheet, Workbook } from "../A00_Common/spreadsheet/spreadsheet_types"; + +const TEXT = { + done: (name: string) => + `도면 표를 「${name}」 시트로 옮김 — [저장]을 눌러야 저장 · 되돌리기 Ctrl+Z`, + loose: "표 선을 못 찾아 글 자리로 줄 · 칸을 나눔 — 칸이 맞는지 확인", + noHandle: "스프레드시트가 시트 더하기를 아직 못 함 — 옮기지 않음", + noSheet: "면적산출 시트가 열려 있지 않음", +}; + +/** CAD 가 보낸 표 자료(`DrawingTable`) */ +export interface DrawingTable { + kind: "table" | "grid" | "text"; + rows: (string | number)[][]; + merges: { row: number; col: number; rowSpan: number; colSpan: number }[]; + colWidths: number[]; + rowHeights: number[]; +} + +export const SHEET_PREFIX = "도면에서"; + +/** 도면 mm → 엑셀 열 폭(글자 단위 · 1글자 ≈ 7 px ≈ 1.85 mm) · 행 높이(pt = mm × 72 / 25.4) */ +const CHAR_PER_MM = 0.54; +const PT_PER_MM = 72 / 25.4; +const clamp = (v: number, lo: number, hi: number): number => Math.min(hi, Math.max(lo, v)); +const round1 = (v: number): number => Math.round(v * 10) / 10; + +/** 0 → `A` · 27 → `AB`(`spreadsheet_address.colName` 과 같은 규칙) */ +function colName(c: number): string { + let s = ""; + for (let n = c + 1; n > 0; n = Math.floor((n - 1) / 26)) + s = String.fromCharCode(65 + ((n - 1) % 26)) + s; + return s; +} +const a1 = (r: number, c: number): string => `${colName(c)}${r + 1}`; + +/** 「도면에서 n」 — 안 쓴 가장 작은 n */ +export function nextSheetName(book: Pick): string { + const used = new Set(book.시트.map((s) => s.이름)); + let n = 1; + while (used.has(`${SHEET_PREFIX} ${n}`)) n += 1; + return `${SHEET_PREFIX} ${n}`; +} + +/** 안 쓴 시트 id(`s1` …) */ +function nextSheetId(book: Pick): string { + const used = new Set(book.시트.map((s) => s.id)); + let n = book.시트.length + 1; + while (used.has(`s${n}`)) n += 1; + return `s${n}`; +} + +/** 표 자료 → 새 시트(이름 · id 는 문서에서 안 쓴 것) */ +export function tableToSheet(table: DrawingTable, book: Pick): Sheet { + const 칸: Record = {}; + table.rows.forEach((line, r) => + line.forEach((value, c) => { + if (value === "" || value === null || value === undefined) return; + 칸[a1(r, c)] = { 값: value }; + }), + ); + const 병합 = table.merges + .filter((m) => m.rowSpan > 1 || m.colSpan > 1) + .map((m) => `${a1(m.row, m.col)}:${a1(m.row + m.rowSpan - 1, m.col + m.colSpan - 1)}`); + const sheet: Sheet = { id: nextSheetId(book), 이름: nextSheetName(book), 칸 }; + if (병합.length) sheet.병합 = 병합; + if (table.colWidths.length) { + sheet.열 = {}; + table.colWidths.forEach((w, c) => { + if (w > 0) sheet.열![colName(c)] = { 폭: round1(clamp(w * CHAR_PER_MM, 2, 60)) }; + }); + } + if (table.rowHeights.length) { + sheet.행 = {}; + table.rowHeights.forEach((h, r) => { + if (h > 0) sheet.행![String(r + 1)] = { 높이: round1(clamp(h * PT_PER_MM, 12, 100)) }; + }); + } + return sheet; +} + +/** 스프레드시트 공개 손잡이 중 쓰는 것 — 시트 더하기(되돌리기 한 번 · onChange · 탭 줄은 부품 몫) */ +export interface AddSheetHandle { + getDoc(): Workbook; + addSheet?: (sheet: Sheet, opts?: { activate?: boolean }) => unknown; +} + +/** 받은 표 → 면적 새 시트(활성) · 알림 — 옮긴 시트 이름 · 못 하면 null */ +export function importDrawingTable( + handle: AddSheetHandle | null, + table: DrawingTable, +): string | null { + if (!handle?.addSheet) { + showToast(handle ? TEXT.noHandle : TEXT.noSheet, "error"); + return null; + } + const sheet = tableToSheet(table, handle.getDoc()); + handle.addSheet(sheet, { activate: true }); + showToast(TEXT.done(sheet.이름), "success"); + if (table.kind === "text") showToast(TEXT.loose, "info"); + return sheet.이름; +} diff --git a/M02_MasterTemplete/M02_MasterTemplete_AreaPanel.css b/M02_MasterTemplete/M02_MasterTemplete_AreaPanel.css index c1a2de4c4..169333d9a 100644 --- a/M02_MasterTemplete/M02_MasterTemplete_AreaPanel.css +++ b/M02_MasterTemplete/M02_MasterTemplete_AreaPanel.css @@ -62,3 +62,27 @@ height: min(60vh, 520px); min-height: 240px; } + +/* 도면 패널 머리 [면적산출로 보내기](83-3) — 아래 도면 칸이 남은 높이를 다 씀 */ +.m02-apanel__drawing:has(> .m02-apanel__drawstage) { + flex-direction: column; +} + +.m02-apanel__drawbar { + display: flex; + justify-content: flex-end; + gap: var(--spacing-8); + padding: var(--spacing-4) var(--spacing-8); + border-bottom: 1px solid var(--color-border); +} + +.m02-apanel__drawstage { + display: flex; + flex: 1 1 0; + min-width: 0; + min-height: 0; +} + +.m02-apanel__drawstage .cad-host { + min-height: 0; +} diff --git a/M02_MasterTemplete/M02_MasterTemplete_AreaPanel.ts b/M02_MasterTemplete/M02_MasterTemplete_AreaPanel.ts index 9d806e4e9..684d8e6b6 100644 --- a/M02_MasterTemplete/M02_MasterTemplete_AreaPanel.ts +++ b/M02_MasterTemplete/M02_MasterTemplete_AreaPanel.ts @@ -2,7 +2,7 @@ * M02_MasterTemplete_AreaPanel.ts * 면적산출 상세 오른쪽 패널(PLAN 39-4 D2 · D6 · 47-6) — 틀 = 공용 오른쪽 접는 패널(하나만 열림 · 폭 공유) * · 「일위대가」 = 그 열 일위대가의 담은 로직 + 자재목록(기준 · 반영) · 몸 = 일위대가 보기 갈래(`Labor_View`) - * · 「도면」 = 그 열 구조물 도면 보기만(`AreaDrawing` · 처음 펼 때 그림) + * · 「도면」 = 그 열 구조물 도면 보기 · 고르기(`AreaDrawing` · 처음 펼 때 그림) · [면적산출로 보내기] = 새 시트(83-3) * · 줄 누름 = 칸 지정 모달(`AreaLink` · 그 줄 알약 키를 면적 칸에 잇기 · 풀기) * · 시트 칸 값 · 연결이 바뀌면 `refresh` — 받은 기준에 바로 곱함 · 칸 주소도 넘김(서버 안 부름 · `Labor_View.setQty`) * ========================================================================== */ @@ -12,6 +12,7 @@ import { createSidePanels } from "@ui/ui_template_side_panels"; import type { LinkItem, SpreadsheetHandle } from "../A00_Common/spreadsheet/spreadsheet"; import type { Layer } from "./M02_MasterTemplete_Api_Fetch"; import { createAreaDrawing } from "./M02_MasterTemplete_AreaDrawing"; +import { importDrawingTable } from "./M02_MasterTemplete_AreaFromDrawing"; import { openAreaLink } from "./M02_MasterTemplete_AreaLink"; import { createLaborView, type LaborViewRow } from "./M02_MasterTemplete_Labor_View"; import "./M02_MasterTemplete_AreaPanel.css"; @@ -95,7 +96,10 @@ export function buildAreaPanel(opts: AreaPanelOptions): AreaPanel { }); }; const view = createLaborView(openLink); - const drawing = createAreaDrawing(opts.layer, opts.column); + // 도면 → 면적 새 시트(83-3 · 한 번 · 연동 아님) — 고칠 수 없는 면적이면 보내기 단추 없음 + const drawing = createAreaDrawing(opts.layer, opts.column, { + onTable: opts.readOnly ? undefined : (table) => void importDrawingTable(opts.sheet(), table), + }); const panels = createSidePanels({ prefKey: "m02-area-panels", defaultWidth: DEFAULT_WIDTH, diff --git a/M02_MasterTemplete/M02_MasterTemplete_UI_Main.ts b/M02_MasterTemplete/M02_MasterTemplete_UI_Main.ts index 8bdf22c42..4c4b9a079 100644 --- a/M02_MasterTemplete/M02_MasterTemplete_UI_Main.ts +++ b/M02_MasterTemplete/M02_MasterTemplete_UI_Main.ts @@ -287,7 +287,8 @@ export function createMain( doc as Parameters[1], { readOnly, - tabs: false, // 시트 하나 · 탭 줄 없음 + // 시트 하나 = 탭 줄 없음 · 도면에서 옮긴 시트가 있으면(2장 이상 · 83-3) 탭 줄 + tabs: ((doc as { 시트?: unknown[] }).시트?.length ?? 0) > 1, onChange: () => { dirty = !readOnly; apanel?.refresh(); // 칸 값 · 연결이 바뀜 — 반영 금액을 바로 다시 곱함 diff --git a/resources/tester/test_m02_area_from_drawing.py b/resources/tester/test_m02_area_from_drawing.py new file mode 100644 index 000000000..226c1edeb --- /dev/null +++ b/resources/tester/test_m02_area_from_drawing.py @@ -0,0 +1,134 @@ +"""도면 → 면적산출 한 번 옮기기(PLAN 83-3 · `M02_MasterTemplete_AreaFromDrawing.ts`) — Node 로 TS 를 돌려 판정. + +못박는 것 + - 표 자료(CAD `table-from-drawing.ts` 꼴) → 새 시트 「도면에서 n」(안 쓴 가장 작은 n · 안 쓴 id) · 기존 시트 안 덮음. + - 숫자 칸 = 수 · 빈 칸 안 적음 · 병합 = A1 범위 · 열 폭(글자) · 행 높이(pt) = 도면 mm 환산 · 폴백(text) = 폭 · 높이 없음. + - 싣기 = 공개 손잡이 addSheet(시트, {activate}) 한 번 · 손잡이 없으면 안 옮기고 알림. + - 패널 메시지 이름 = CAD `aislo-table-export.ts` 와 같음 · 읽기 면적은 보내기 단추 없음 · 시트 2장 이상이면 탭 줄. +""" + +import json +import re +import shutil +import subprocess +from pathlib import Path + +import pytest + +ROOT = Path(__file__).resolve().parents[2] +M02 = ROOT / "M02_MasterTemplete" +SCRIPT = r""" +const fs = require("fs"); +const ts = require(process.argv[1] + "/config/node_modules/typescript"); +const src = fs.readFileSync(process.argv[1] + "/M02_MasterTemplete/M02_MasterTemplete_AreaFromDrawing.ts", "utf8"); +const js = ts.transpileModule(src, { + compilerOptions: { module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ES2020 }, +}).outputText; +const toasts = []; +const stubs = { "@ui/ui_template_elements": { showToast: (m, k) => toasts.push([k, m]) } }; +const box = { exports: {} }; +new Function("exports", "module", "require", js)(box.exports, box, (n) => stubs[n] ?? require(n)); +const { tableToSheet, nextSheetName, importDrawingTable } = box.exports; + +const book = { 시트: [{ id: "s1", 이름: "면적" }, { id: "s2", 이름: "도면에서 1" }] }; +const grid = { + kind: "grid", + rows: [["구분", "수량", ""], ["흙깎기", 1500, "m3"], ["", 2.5, ""]], + merges: [{ row: 0, col: 1, rowSpan: 1, colSpan: 2 }, { row: 1, col: 0, rowSpan: 2, colSpan: 1 }], + colWidths: [40, 30, 300], + rowHeights: [10, 7, 1], +}; +const text = { kind: "text", rows: [["합계", 3000], ["소계", ""]], merges: [], colWidths: [], rowHeights: [] }; + +const out = {}; +out.gridSheet = tableToSheet(grid, book); +out.textSheet = tableToSheet(text, { 시트: [] }); +out.names = [nextSheetName({ 시트: [] }), nextSheetName(book), nextSheetName({ 시트: [{ 이름: "도면에서 2" }] })]; + +const added = []; +const handle = { getDoc: () => book, addSheet: (s, o) => added.push([s, o]) }; +out.imported = importDrawingTable(handle, text); +out.added = added; +out.noHandle = importDrawingTable({ getDoc: () => book }, grid); +out.noSheet = importDrawingTable(null, grid); +out.toasts = toasts; +console.log(JSON.stringify(out)); +""" + + +@pytest.fixture(scope="module") +def run(): + node = shutil.which("node") + if not node: + pytest.skip("node 없음") + done = subprocess.run( + [node, "-e", SCRIPT, str(ROOT)], + capture_output=True, + text=True, + encoding="utf-8", + check=True, + ) + return json.loads(done.stdout) + + +def test_grid_table_becomes_new_sheet(run): + sheet = run["gridSheet"] + assert sheet["이름"] == "도면에서 2" # 「도면에서 1」 이 이미 있음 + assert sheet["id"] == "s3" + assert sheet["칸"] == { + "A1": {"값": "구분"}, + "B1": {"값": "수량"}, + "A2": {"값": "흙깎기"}, + "B2": {"값": 1500}, + "C2": {"값": "m3"}, + "B3": {"값": 2.5}, + } + assert sheet["병합"] == ["B1:C1", "A2:A3"] + # 40 mm → 21.6 글자 · 300 mm → 60(위 끝) · 10 mm → 28.3 pt · 1 mm → 12(아래 끝) + assert sheet["열"] == {"A": {"폭": 21.6}, "B": {"폭": 16.2}, "C": {"폭": 60}} + assert sheet["행"] == {"1": {"높이": 28.3}, "2": {"높이": 19.8}, "3": {"높이": 12}} + + +def test_text_fallback_has_no_sizes(run): + sheet = run["textSheet"] + assert sheet["이름"] == "도면에서 1" and sheet["id"] == "s1" + assert sheet["칸"] == {"A1": {"값": "합계"}, "B1": {"값": 3000}, "A2": {"값": "소계"}} + assert "병합" not in sheet and "열" not in sheet and "행" not in sheet + + +def test_sheet_names(run): + assert run["names"] == ["도면에서 1", "도면에서 2", "도면에서 1"] + + +def test_import_uses_add_sheet_once(run): + assert run["imported"] == "도면에서 2" + assert len(run["added"]) == 1 + sheet, opts = run["added"][0] + assert sheet["이름"] == "도면에서 2" and opts == {"activate": True} + assert run["noHandle"] is None and run["noSheet"] is None + kinds = [kind for kind, _ in run["toasts"]] + assert kinds == [ + "success", + "info", + "error", + "error", + ] # 옮김 · 글 폴백 확인 · 손잡이 없음 · 시트 없음 + assert "「도면에서 2」" in run["toasts"][0][1] + + +def test_panel_wiring_matches_cad(): + cad = (ROOT / "B07_DesignDetail/openwebcad/src/integration/aislo-table-export.ts").read_text( + encoding="utf-8" + ) + panel = (M02 / "M02_MasterTemplete_AreaDrawing.ts").read_text(encoding="utf-8") + + def const(text, name): + return re.search(rf"{name} = ['\"]([^'\"]+)['\"]", text).group(1) + + assert const(cad, "AISLO_EXPORT_TABLE_REQUEST_MESSAGE") == const(panel, "EXPORT_TABLE_REQUEST") + assert const(cad, "AISLO_EXPORT_TABLE_MESSAGE") == const(panel, "EXPORT_TABLE") + assert "readOnly: true" in panel and "preview: true" not in panel # 보기 전용 + 고르기 + apanel = (M02 / "M02_MasterTemplete_AreaPanel.ts").read_text(encoding="utf-8") + assert "onTable: opts.readOnly ? undefined" in apanel # 읽기 면적 = 단추 없음 + main = (M02 / "M02_MasterTemplete_UI_Main.ts").read_text(encoding="utf-8") + assert "tabs: ((doc as { 시트?: unknown[] }).시트?.length ?? 0) > 1" in main diff --git a/resources/tester/ui_checks/83-3_도면_면적.js b/resources/tester/ui_checks/83-3_도면_면적.js new file mode 100644 index 000000000..d8f9990ba --- /dev/null +++ b/resources/tester/ui_checks/83-3_도면_면적.js @@ -0,0 +1,238 @@ +/* 83-3 — 면적산출 상세 오른쪽 「도면」 패널 [면적산출로 보내기] · 고른 범위 → 표 자료 → 새 시트 + * 시작 화면: M02 마스터 템플릿 · 면적산출 상세(47-6 스크립트로 연 뒤) · 「도면」 패널 펼침 + * 부르는 법: orca eval --expression "$(cat 이 파일)" (async 식 · JSON 글을 돌려줌) + * 하는 일: 패널 CAD 에 견본 도면(표 · 선 표 · 글만 · 글 없는 선 · 회전된 표)을 보기 전용으로 싣고(저장 안 됨) + * 범위마다 끌어 고른 뒤 단추를 눌러 부모가 받은 표 자료(export-table)와 알림을 모음 · 빈 범위도. + * 기대 수치: + * - table = kind table · rows [["구분","수량",""],["",1500,"m2"]] · merges 2 + * - grid = kind grid · rows [["구분","수량",""],["흙깎기",1500,"m3"],["돌쌓기",2.5,"m2"]] · merges 1 + * - text = kind text · rows [["합계",3000,"비고"],["소계",1000,""]] + * - empty · lines = 받은 것 없음(알림만) · rotated = kind text(글 둘) + * - 끝에 원래 도면을 다시 실음(restored) + */ +(async () => { + const W = (ms) => new Promise((r) => setTimeout(r, ms)); + const $ = (s, root = document) => root.querySelector(s); + const until = async (fn, ms = 8000) => { + for (let t = 0; t < ms; t += 100) { + const v = fn(); + if (v) return v; + await W(100); + } + return null; + }; + const panel = $('.ui-side-panels__panel[data-panel="drawing"]'); + const frame = panel?.querySelector("iframe"); + const btn = $("[data-area-from-drawing]"); + if (!frame || !btn) return JSON.stringify({ error: "도면 패널 · 단추 없음" }); + const win = frame.contentWindow; + const cad = win.__aisloCad; + const L = "L83"; + let n = 0; + const id = () => `t83-${(n += 1)}`; + const base = { lineColor: "#fff", lineWidth: 1, layerId: L }; + const line = (x1, y1, x2, y2) => ({ + id: id(), + type: "Line", + ...base, + shapeData: { startPoint: { x: x1, y: y1 }, endPoint: { x: x2, y: y2 } }, + }); + const text = (label, x, y) => ({ + id: id(), + type: "Text", + ...base, + shapeData: { + label, + basePoint: { x, y }, + options: { + textDirection: { x: 1, y: 0 }, + textAlign: "left", + textColor: "#fff", + fontSize: 2.5, + fontFamily: "Noto Sans KR", + }, + }, + }); + const rot = (x, y, ox, oy) => { + const a = Math.PI / 6; + return [ox + x * Math.cos(a) - y * Math.sin(a), oy + x * Math.sin(a) + y * Math.cos(a)]; + }; + const groups = { + table: [ + { + id: id(), + type: "Table", + ...base, + shapeData: { + origin: { x: 0, y: 100 }, + columnWidths: [30, 20, 25], + rowHeights: [8, 6], + cells: [ + [{ text: "구분", rowSpan: 2 }, { text: "수량", colSpan: 2 }, null], + [null, { text: "1,500" }, { text: "m2" }], + ], + style: { fontSize: 2.5 }, + }, + }, + ], + grid: [ + line(100, 60, 200, 60), + line(100, 30, 200, 30), + line(100, 60, 100, 30), + line(200, 60, 200, 30), + line(100, 50, 155, 50), + line(150, 50, 200, 50), + line(100, 40, 200, 40), + line(140, 60, 140, 30), + line(170, 50, 170, 30), + text("구분", 102, 53), + text("수량", 145, 53), + text("흙깎기", 102, 43), + text("1,500", 142, 43), + text("m3", 172, 43), + text("돌쌓기", 102, 33), + text("2.5", 142, 33), + text("m2", 172, 33), + ], + text: [ + text("합계", 250, 60), + text("3,000", 290, 60.3), + text("비고", 330, 59.8), + text("소계", 250, 50), + text("1,000", 290, 50), + ], + lines: [ + line(0, 0, 40, 0), + line(0, 20, 40, 20), + line(0, 0, 0, 20), + line(40, 0, 40, 20), + line(20, 0, 20, 20), + ], + rotated: [ + line(...rot(0, 0, 250, 0), ...rot(40, 0, 250, 0)), + line(...rot(0, 10, 250, 0), ...rot(40, 10, 250, 0)), + line(...rot(0, 0, 250, 0), ...rot(0, 10, 250, 0)), + line(...rot(40, 0, 250, 0), ...rot(40, 10, 250, 0)), + line(...rot(20, 0, 250, 0), ...rot(20, 10, 250, 0)), + text("A", ...rot(2, 4, 250, 0)), + text("B", ...rot(22, 4, 250, 0)), + ], + }; + const all = Object.values(groups).flat(); + // 원래 도면 — 끝에 다시 실음 + const original = await new Promise((resolve) => { + const on = (e) => { + if (e.source !== win || e.data?.type !== "aislo:b08:save-response") return; + window.removeEventListener("message", on); + resolve(e.data.drawing); + }; + window.addEventListener("message", on); + win.postMessage({ type: "aislo:b08:save-request" }, location.origin); + }); + const load = async (drawing) => { + const done = new Promise((resolve) => { + const on = (e) => { + if (e.source !== win || e.data?.type !== "aislo:b08:drawing-loaded") return; + window.removeEventListener("message", on); + resolve(true); + }; + window.addEventListener("message", on); + }); + win.postMessage( + { + type: "aislo:b08:load-drawing", + drawing, + readOnly: true, + panelsCollapsed: true, + recoveryScope: "verify83", + }, + location.origin, + ); + return Promise.race([done, W(5000)]); + }; + await load({ + format: 6, + entities: all, + layers: [{ id: L, name: "검증83", isVisible: true, isLocked: false }], + }); + await W(400); + + // 받은 표 자료 · 알림 모음 + const got = []; + const onMsg = (e) => + e.source === win && e.data?.type === "aislo:b08:export-table" && got.push(e.data.table); + window.addEventListener("message", onMsg); + const toasts = () => + [...document.querySelectorAll(".ui-toast, .ui-toast__message, [class*=toast]")] + .map((t) => t.innerText.trim()) + .filter(Boolean); + const canvas = frame.contentDocument.querySelector("canvas"); + const box = (list) => { + const xs = [], + ys = []; + for (const e of list) { + const s = e.shapeData; + if (e.type === "Line") + (xs.push(s.startPoint.x, s.endPoint.x), ys.push(s.startPoint.y, s.endPoint.y)); + if (e.type === "Text") + (xs.push(s.basePoint.x, s.basePoint.x + 6), ys.push(s.basePoint.y, s.basePoint.y + 3)); + if (e.type === "Table") { + xs.push(s.origin.x, s.origin.x + s.columnWidths.reduce((a, b) => a + b, 0)); + ys.push(s.origin.y, s.origin.y - s.rowHeights.reduce((a, b) => a + b, 0)); + } + } + return [Math.min(...xs) - 5, Math.min(...ys) - 5, Math.max(...xs) + 5, Math.max(...ys) + 5]; + }; + const fire = (type, p) => { + const r = canvas.getBoundingClientRect(); + const ev = new win.MouseEvent(type, { + bubbles: true, + clientX: r.left + p.x, + clientY: r.bottom - p.y, + button: 0, + buttons: type === "mouseup" ? 0 : 1, + }); + canvas.dispatchEvent(ev); + }; + const drag = async ([x0, y0, x1, y1]) => { + frame.contentDocument.dispatchEvent( + new win.KeyboardEvent("keydown", { key: "Escape", bubbles: true }), + ); + await W(150); + const a = cad.screen(x0, y0), + b = cad.screen(x1, y1); + fire("mousemove", a); + await W(60); + fire("mousedown", a); + await W(60); + fire("mousemove", { x: (a.x + b.x) / 2, y: (a.y + b.y) / 2 }); + await W(60); + fire("mousemove", b); + await W(60); + fire("mouseup", b); + await W(250); + }; + const out = {}; + for (const [name, list] of Object.entries(groups)) { + got.length = 0; + await drag(box(list)); + out[name] = { selected: cad.selection().length }; + btn.click(); + await W(500); + out[name].table = got[0] ?? null; + out[name].toasts = toasts().slice(-2); + } + // 빈 범위 — 아무것도 없는 자리를 끌어 고름 + got.length = 0; + await drag([210, 70, 240, 90]); + btn.click(); + await W(500); + out.empty = { + selected: cad.selection().length, + table: got[0] ?? null, + toasts: toasts().slice(-1), + }; + window.removeEventListener("message", onMsg); + out.restored = await load(original); + return JSON.stringify(out); +})();