From cc63e49ab301eea1d3bb80634bc2a306c7eb1334 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Sun, 4 Oct 2026 22:48:34 +0900 Subject: [PATCH] =?UTF-8?q?feat(B07):=20DXF=20=EB=82=B4=EB=B3=B4=EB=82=B4?= =?UTF-8?q?=EA=B8=B0=20=ED=91=9C=20=3D=20=EC=84=A0=20+=20=EA=B8=80=20?= =?UTF-8?q?=E2=80=94=20=EC=B9=B8=20=EC=A0=95=EB=A0=AC=20=C2=B7=20=EB=B3=91?= =?UTF-8?q?=ED=95=A9=20=EC=9E=90=EB=A6=AC=20=EB=81=8A=EA=B9=80=20=C2=B7=20?= =?UTF-8?q?B07=20=EC=88=98=EB=9F=89=ED=91=9C=EB=8F=84=20=EB=82=98=EC=98=B4?= =?UTF-8?q?=20(41-2)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Frame_Export: Table 갈래 table_parts(경계선 · 글 자리) · 정렬 MIDDLE_LEFT · CENTER · RIGHT · 높이 = 칸 → 표 글자 크기 · 전에는 Table 이 조용히 빠짐 - 짝 = openwebcad table-geometry.ts(두 파일 머리) · 거울 시험 test_b07_frame_export_table.py + helper_b07_table_geometry.cjs Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01UoSr2sqvYtBf1dtQdus88s --- .../B07_DesignDetail_Engine_Frame_Export.py | 105 ++++++++++++++++ .../openwebcad/src/helpers/table-geometry.ts | 3 + .../tester/helper_b07_table_geometry.cjs | 41 ++++++ .../tester/test_b07_frame_export_table.py | 119 ++++++++++++++++++ 4 files changed, 268 insertions(+) create mode 100644 resources/tester/helper_b07_table_geometry.cjs create mode 100644 resources/tester/test_b07_frame_export_table.py diff --git a/B07_DesignDetail/B07_DesignDetail_Engine_Frame_Export.py b/B07_DesignDetail/B07_DesignDetail_Engine_Frame_Export.py index 535fdaf22..e95c3b958 100644 --- a/B07_DesignDetail/B07_DesignDetail_Engine_Frame_Export.py +++ b/B07_DesignDetail/B07_DesignDetail_Engine_Frame_Export.py @@ -6,6 +6,10 @@ DWG 가 필요하면 LibreDWG 의 `dxf2dwg` 를 **별도 실행 파일로** 불 그림(Image)은 DXF 에 그대로 담을 수 없어(외부 파일 참조 방식) 내보내지 않는다. 대신 몇 개를 건너뛰었는지 세어 화면이 알릴 수 있게 돌려준다. + +표(Table · B07 수량표 · M02 41장 면적산출 표)는 선 + 글로 풀어 쓴다. 짝(CLAUDE.md 5장 ①): +`table_parts` ↔ openwebcad `src/helpers/table-geometry.ts`(`tableBorders` · `cellRect` · +`cellTextAnchor`) — 거울 시험 `resources/tester/test_b07_frame_export_table.py`. """ from __future__ import annotations @@ -31,6 +35,14 @@ logger = logging.getLogger(__name__) _DXF_VERSION = "R2018" _DWG_SOURCE_VERSION = "R2004" _DEFAULT_TEXT_MM = 3.0 +#: 표 기본 글자 크기 · 여백 — openwebcad `DEFAULT_TABLE_STYLE` 과 같게 +_TABLE_FONT = 2.2 +_TABLE_PADDING = 1.0 +_ALIGN = { + "left": TextEntityAlignment.MIDDLE_LEFT, + "right": TextEntityAlignment.MIDDLE_RIGHT, + "center": TextEntityAlignment.MIDDLE_CENTER, +} def _xy(point: Any) -> tuple[float, float] | None: @@ -47,6 +59,96 @@ def _layer_name(raw: Any) -> str: return name[:255] or "0" +def _span(value: Any) -> int: + """병합 칸 수 — TS `Math.max(1, Math.round(v ?? 1))` 와 같게(반올림은 .5 올림).""" + number = float(value) if isinstance(value, (int, float)) else 1.0 + return max(1, math.floor(number + 0.5)) + + +def _owners(cells: list, rows: int, columns: int) -> list[list[str | None]]: + owners: list[list[str | None]] = [[None] * columns for _ in range(rows)] + for row in range(rows): + for column in range(columns): + cell = cells[row][column] if row < len(cells) and column < len(cells[row]) else None + if not isinstance(cell, dict): + continue + for r in range(row, min(rows, row + _span(cell.get("rowSpan")))): + for c in range(column, min(columns, column + _span(cell.get("colSpan")))): + owners[r][c] = f"{row}:{column}" + return owners + + +def table_parts(shape: dict[str, Any]) -> tuple[list[tuple], list[dict[str, Any]]]: + """표 → (경계선 [(x1, y1, x2, y2)], 글 [{text, x, y, align, height}]) — 원점 = 왼쪽 위 · 아래로. + + 바깥 테두리는 늘 · 안쪽 선은 양옆(위아래)이 같은 병합 칸이면 끊음 · 글은 칸 가운데 높이 · + 정렬에 따라 여백만큼 안쪽.""" + origin = _xy(shape.get("origin")) or (0.0, 0.0) + widths = [float(w) for w in shape.get("columnWidths") or []] + heights = [float(h) for h in shape.get("rowHeights") or []] + cells = shape.get("cells") or [] + style = shape.get("style") or {} + rows, columns = len(heights), len(widths) + if not rows or not columns: + return [], [] + xs, ys = [origin[0]], [origin[1]] + for width in widths: + xs.append(xs[-1] + width) + for height in heights: + ys.append(ys[-1] - height) + owners = _owners(cells, rows, columns) + lines = [ + (xs[0], ys[0], xs[columns], ys[0]), + (xs[0], ys[rows], xs[columns], ys[rows]), + (xs[0], ys[0], xs[0], ys[rows]), + (xs[columns], ys[0], xs[columns], ys[rows]), + ] + for column in range(1, columns): + for row in range(rows): + left, right = owners[row][column - 1], owners[row][column] + if left is None or left != right: + lines.append((xs[column], ys[row], xs[column], ys[row + 1])) + for row in range(1, rows): + for column in range(columns): + above, below = owners[row - 1][column], owners[row][column] + if above is None or above != below: + lines.append((xs[column], ys[row], xs[column + 1], ys[row])) + raw = style.get("padding") + padding = float(raw) if isinstance(raw, (int, float)) else _TABLE_PADDING + texts = [] + for row in range(rows): + for column in range(columns): + cell = cells[row][column] if row < len(cells) and column < len(cells[row]) else None + if not isinstance(cell, dict) or not cell.get("text"): + continue + right = xs[min(columns, column + _span(cell.get("colSpan")))] + bottom = ys[min(rows, row + _span(cell.get("rowSpan")))] + align = cell.get("align") if cell.get("align") in _ALIGN else "center" + x = {"left": xs[column] + padding, "right": right - padding}.get( + align, (xs[column] + right) / 2 + ) + height = cell.get("fontSize") or style.get("fontSize") or _TABLE_FONT + texts.append( + { + "text": str(cell["text"]), + "x": x, + "y": (ys[row] + bottom) / 2, + "align": align, + "height": float(height), + } + ) + return lines, texts + + +def _add_table(space: Any, shape: dict[str, Any], attribs: dict[str, Any]) -> None: + lines, texts = table_parts(shape) + for x1, y1, x2, y2 in lines: + space.add_line((x1, y1), (x2, y2), dxfattribs=attribs) + for one in texts: + text = space.add_text(one["text"], dxfattribs={**attribs, "height": one["height"]}) + text.set_placement((one["x"], one["y"]), align=_ALIGN[one["align"]]) + + def _add_entity( space: Any, entity: dict[str, Any], layers: set[str], skipped: dict[str, int] ) -> None: @@ -65,6 +167,9 @@ def _add_entity( # 그림은 DXF 가 외부 파일을 가리키는 방식이라 그대로 옮기지 못한다. skipped["Image"] = skipped.get("Image", 0) + 1 return + if kind == "Table": # 표 = 선 + 글(칸 정렬 반영) + _add_table(space, shape, attribs) + return start, end = _xy(shape.get("startPoint")), _xy(shape.get("endPoint")) if start and end: diff --git a/B07_DesignDetail/openwebcad/src/helpers/table-geometry.ts b/B07_DesignDetail/openwebcad/src/helpers/table-geometry.ts index aa74e65fd..8b416674f 100644 --- a/B07_DesignDetail/openwebcad/src/helpers/table-geometry.ts +++ b/B07_DesignDetail/openwebcad/src/helpers/table-geometry.ts @@ -4,6 +4,9 @@ * * 표는 셀 격자와 셀 문자만 책임진다. 표 위에 얹는 그림(종단표의 종단곡선 표시선 같은 것)은 * 별도 엔티티로 겹쳐 그린다 — 그래야 나중에 종단표도 이 표 객체로 옮길 수 있다. + * + * 짝(CLAUDE.md 5장 ①): 서버 DXF 내보내기 `B07_DesignDetail_Engine_Frame_Export.table_parts` + * (`tableBorders` · `cellRect` · `cellTextAnchor`) — 거울 시험 `test_b07_frame_export_table.py`. */ export interface TableCell { diff --git a/resources/tester/helper_b07_table_geometry.cjs b/resources/tester/helper_b07_table_geometry.cjs new file mode 100644 index 000000000..a6eeab295 --- /dev/null +++ b/resources/tester/helper_b07_table_geometry.cjs @@ -0,0 +1,41 @@ +/* 표 기하(`openwebcad/src/helpers/table-geometry.ts`) 거울 헬퍼 — 서버 DXF `table_parts` 와 대조. + * stdin JSON [표 shapeData] → stdout JSON [{lines, texts}] (TS 규칙 그대로). */ +const fs = require("fs"); +const path = require("path"); +const ts = require(path.join(__dirname, "..", "..", "config", "node_modules", "typescript")); + +const file = path.join( + __dirname, + "..", + "..", + "B07_DesignDetail", + "openwebcad", + "src", + "helpers", + "table-geometry.ts", +); +const js = ts.transpileModule(fs.readFileSync(file, "utf8"), { + compilerOptions: { module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ES2020 }, +}).outputText; +const box = { exports: {} }; +new Function("exports", "module", "require", js)(box.exports, box, require); +const g = box.exports; + +const shapes = JSON.parse(fs.readFileSync(0, "utf8")); +const out = shapes.map((s) => { + const lines = g + .tableBorders(s.origin, s.columnWidths, s.rowHeights, s.cells) + .map((b) => [b.x1, b.y1, b.x2, b.y2]); + const padding = s.style?.padding ?? 1; + const texts = []; + for (let row = 0; row < s.rowHeights.length; row += 1) + for (let column = 0; column < s.columnWidths.length; column += 1) { + const cell = s.cells[row]?.[column]; + if (!cell?.text) continue; + const rect = g.cellRect(s.origin, s.columnWidths, s.rowHeights, row, column, cell); + const at = g.cellTextAnchor(rect, cell.align, padding); + texts.push({ text: cell.text, x: at.x, y: at.y, align: cell.align ?? "center" }); + } + return { lines, texts }; +}); +process.stdout.write(JSON.stringify(out)); diff --git a/resources/tester/test_b07_frame_export_table.py b/resources/tester/test_b07_frame_export_table.py new file mode 100644 index 000000000..ebf2dc9b5 --- /dev/null +++ b/resources/tester/test_b07_frame_export_table.py @@ -0,0 +1,119 @@ +"""DXF 내보내기 표 = 선 + 글(PLAN 41-2) — `B07_DesignDetail_Engine_Frame_Export`. + +- 거울: `table_parts` 경계선 · 글 자리 = openwebcad `table-geometry.ts`(Node 헬퍼) — + 병합 · 정렬 · 여백 +- DXF: M02 면적산출 표 · B07 수량표(`table_entity`) 모두 LINE · TEXT 로 나옴(전에는 조용히 빠짐) · + 글 정렬(MIDDLE_LEFT · CENTER · RIGHT) · 높이 = 칸 글자 크기 → 표 글자 크기 +""" + +from __future__ import annotations + +import json +import os +import subprocess + +import ezdxf +import pytest + +from B07_DesignDetail import B07_DesignDetail_Engine_Frame_Export as export +from B07_DesignDetail.B07_DesignDetail_Engine_Cad import table_entity + +HERE = os.path.dirname(os.path.abspath(__file__)) + +AREA_TABLE = { + "origin": {"x": 10, "y": 50}, + "columnWidths": [30, 15, 15], + "rowHeights": [5, 5, 5], + "cells": [ + [{"text": "항목", "colSpan": 3}, None, None], + [ + {"text": "면적", "rowSpan": 2, "align": "left"}, + {"text": "12.5", "align": "right"}, + {"text": "㎡"}, + ], + [None, {"text": "1,250", "align": "right", "fontSize": 3}, {"text": ""}], + ], + "style": {"fontSize": 2.5, "padding": 1, "연결": "T1"}, +} +PLAIN = { + "origin": {"x": 0, "y": 0}, + "columnWidths": [10, 10], + "rowHeights": [4], + "cells": [[{"text": "a"}, {"text": "b", "align": "left"}]], + "style": {"padding": 0}, +} + + +def _mirror(shapes: list[dict]) -> list[dict]: + proc = subprocess.run( + ["node", os.path.join(HERE, "helper_b07_table_geometry.cjs")], + input=json.dumps(shapes, ensure_ascii=False), + capture_output=True, + text=True, + encoding="utf-8", + timeout=60, + ) + assert proc.returncode == 0, proc.stderr + return json.loads(proc.stdout) + + +def test_거울_경계선_글_자리가_TS_와_같음(): + shapes = [AREA_TABLE, PLAIN] + for shape, ts in zip(shapes, _mirror(shapes)): + lines, texts = export.table_parts(shape) + flat = [value for line in lines for value in line] + assert flat == pytest.approx([value for line in ts["lines"] for value in line]) + assert len(lines) == len(ts["lines"]) + assert [(t["text"], t["x"], t["y"], t["align"]) for t in texts] == [ + (t["text"], pytest.approx(t["x"]), pytest.approx(t["y"]), t["align"]) + for t in ts["texts"] + ] + + +def test_병합_자리는_선을_끊음(): + lines, _ = export.table_parts(AREA_TABLE) + # 바깥 4 · 안쪽 세로 4(1행 병합 자리 끊김) · 가로 5(「면적」 세로 병합 자리 끊김) + vertical = [line for line in lines[4:] if line[0] == line[2]] + horizontal = [line for line in lines[4:] if line[1] == line[3]] + assert len(vertical) == 4 and len(horizontal) == 5 + + +def _dxf(entities: list[dict], tmp_path) -> ezdxf.document.Drawing: + raw, skipped = export.drawing_to_dxf({"entities": entities, "layers": []}) + assert skipped == {} + path = tmp_path / "out.dxf" + path.write_bytes(raw) + return ezdxf.readfile(path) + + +def test_면적산출_표가_LINE_TEXT_로_나옴(tmp_path): + doc = _dxf([{"type": "Table", "layerId": "기본", "shapeData": AREA_TABLE}], tmp_path) + lines, texts = export.table_parts(AREA_TABLE) + space = doc.modelspace() + assert len(space.query("LINE")) == len(lines) == 13 + found = {t.dxf.text: t for t in space.query("TEXT")} + assert set(found) == {"항목", "면적", "12.5", "㎡", "1,250"} and len(texts) == 5 + assert found["면적"].get_placement()[0] == ezdxf.enums.TextEntityAlignment.MIDDLE_LEFT + assert found["12.5"].get_placement()[0] == ezdxf.enums.TextEntityAlignment.MIDDLE_RIGHT + assert found["항목"].get_placement()[0] == ezdxf.enums.TextEntityAlignment.MIDDLE_CENTER + assert found["1,250"].dxf.height == 3 and found["항목"].dxf.height == 2.5 + + +def test_B07_수량표도_같이_나옴(tmp_path): + qtable = table_entity( + "t:qtable", + (0, 0), + [20, 20], + [5, 5], + [ + [{"text": "토공", "colSpan": 2}, None], + [{"text": "절토"}, {"text": "12.30", "align": "right"}], + ], + "b08-cross-table", + "#ffffff", + 2.0, + "#ffffff", + ) + space = _dxf([qtable], tmp_path).modelspace() + assert len(space.query("LINE")) == 7 # 바깥 4 · 세로 1(1행 병합 자리 끊김) · 가로 2 + assert sorted(t.dxf.text for t in space.query("TEXT")) == ["12.30", "절토", "토공"]