feat(M02): 면적 도면 패널 고르기 · [면적산출로 보내기] · 표 자료 → 새 시트 자료 변환 · 시트 2장 이상 탭 줄(83-3 앞부분 · addSheet 손잡이 대기)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_015KYZ2HWWdUwZWYnn3tQz7X
This commit is contained in:
@@ -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();
|
||||
},
|
||||
|
||||
@@ -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<Workbook, "시트">): 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<Workbook, "시트">): 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<Workbook, "시트">): Sheet {
|
||||
const 칸: Record<string, Cell> = {};
|
||||
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.이름;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -287,7 +287,8 @@ export function createMain(
|
||||
doc as Parameters<typeof createSpreadsheet>[1],
|
||||
{
|
||||
readOnly,
|
||||
tabs: false, // 시트 하나 · 탭 줄 없음
|
||||
// 시트 하나 = 탭 줄 없음 · 도면에서 옮긴 시트가 있으면(2장 이상 · 83-3) 탭 줄
|
||||
tabs: ((doc as { 시트?: unknown[] }).시트?.length ?? 0) > 1,
|
||||
onChange: () => {
|
||||
dirty = !readOnly;
|
||||
apanel?.refresh(); // 칸 값 · 연결이 바뀜 — 반영 금액을 바로 다시 곱함
|
||||
|
||||
@@ -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
|
||||
@@ -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);
|
||||
})();
|
||||
Reference in New Issue
Block a user