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:
2026-10-08 12:19:56 +09:00
co-authored by Claude Opus 5.5
parent 4e9df0e4c9
commit c7341ef484
7 changed files with 593 additions and 11 deletions
@@ -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);
})();