- 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 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01UoSr2sqvYtBf1dtQdus88s
276 lines
12 KiB
Python
276 lines
12 KiB
Python
"""B07 도면 내보내기 — 캐드 도면(JSON)을 DXF·DWG 파일로 바꾼다 (2026-09-06 사용자 지시).
|
|
|
|
불러오기(`..._Engine_Frame_Import`)의 반대 방향이다. 도형은 ezdxf 로 DXF 를 쓰고,
|
|
DWG 가 필요하면 LibreDWG 의 `dxf2dwg` 를 **별도 실행 파일로** 불러 바꾼다 — 라이브러리로
|
|
끌어안으면 GPL 이 이 프로그램까지 번진다.
|
|
|
|
그림(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
|
|
|
|
import logging
|
|
import math
|
|
import os
|
|
import shutil
|
|
import subprocess
|
|
import tempfile
|
|
from pathlib import Path
|
|
from typing import Any
|
|
|
|
import ezdxf
|
|
from ezdxf.enums import TextEntityAlignment
|
|
|
|
from B07_DesignDetail.B07_DesignDetail_Engine_Frame_Import import bundled_tool
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
# 파일로 내려보내는 DXF 형식. DWG 로 갈 때는 LibreDWG 가 쓰는 R2004 로 맞춘다 —
|
|
# 더 새 형식을 주면 한글이 깨진 채로 DWG 에 박힌다(2026-09-06 실측).
|
|
_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:
|
|
if isinstance(point, dict) and isinstance(point.get("x"), (int, float)):
|
|
return float(point["x"]), float(point["y"])
|
|
return None
|
|
|
|
|
|
def _layer_name(raw: Any) -> str:
|
|
"""DXF 도면층 이름 규칙에 맞춘다 — 빈 이름과 금지 문자를 걸러 낸다."""
|
|
name = str(raw or "0").strip()
|
|
for bad in '<>/\\":;?*|=`':
|
|
name = name.replace(bad, "_")
|
|
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:
|
|
"""도형 하나를 DXF 에 적는다. 자식이 있으면 자식까지 따라 내려간다."""
|
|
if not isinstance(entity, dict):
|
|
return
|
|
layer = _layer_name(entity.get("layerId"))
|
|
if layer not in layers:
|
|
space.doc.layers.add(layer)
|
|
layers.add(layer)
|
|
attribs = {"layer": layer}
|
|
shape = entity.get("shapeData") or {}
|
|
kind = entity.get("type")
|
|
|
|
if kind == "Image":
|
|
# 그림은 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:
|
|
space.add_line(start, end, dxfattribs=attribs)
|
|
elif (base := _xy(shape.get("basePoint"))) and isinstance(shape.get("label"), str):
|
|
options = shape.get("options") or {}
|
|
height = float(options.get("fontSize") or _DEFAULT_TEXT_MM)
|
|
direction = _xy(options.get("textDirection")) or (1.0, 0.0)
|
|
rotation = math.degrees(math.atan2(direction[1], direction[0]))
|
|
text = space.add_text(
|
|
shape["label"],
|
|
dxfattribs={**attribs, "height": height, "rotation": rotation},
|
|
)
|
|
# 자리표는 칸 한가운데에 선다 — 내보낸 파일에서도 같은 자리에 오게 가운데 맞춤.
|
|
if options.get("boxWidth") and options.get("boxHeight"):
|
|
text.set_placement(base, align=TextEntityAlignment.MIDDLE_CENTER)
|
|
else:
|
|
text.set_placement(base)
|
|
elif point := _xy(shape.get("point")):
|
|
space.add_point(point, dxfattribs=attribs)
|
|
elif (center := _xy(shape.get("center"))) and isinstance(shape.get("radius"), (int, float)):
|
|
radius = float(shape["radius"])
|
|
start_angle = shape.get("startAngle")
|
|
end_angle = shape.get("endAngle")
|
|
if isinstance(start_angle, (int, float)) and isinstance(end_angle, (int, float)):
|
|
space.add_arc(
|
|
center,
|
|
radius,
|
|
math.degrees(float(start_angle)),
|
|
math.degrees(float(end_angle)),
|
|
dxfattribs=attribs,
|
|
)
|
|
else:
|
|
space.add_circle(center, radius, dxfattribs=attribs)
|
|
else:
|
|
vertices = [xy for vertex in shape.get("points") or [] if (xy := _xy(vertex))]
|
|
if len(vertices) >= 2:
|
|
space.add_lwpolyline(vertices, close=True, dxfattribs=attribs)
|
|
|
|
for child in entity.get("children") or []:
|
|
_add_entity(space, child, layers, skipped)
|
|
|
|
|
|
def drawing_to_dxf(
|
|
drawing: dict[str, Any], version: str = _DXF_VERSION
|
|
) -> tuple[bytes, dict[str, int]]:
|
|
"""캐드 도면(JSON)을 DXF 바이트로. 건너뛴 도형 수를 함께 돌려준다."""
|
|
entities = drawing.get("entities")
|
|
if not isinstance(entities, list) or not entities:
|
|
raise ValueError("내보낼 도형이 없습니다.")
|
|
document = ezdxf.new(version)
|
|
space = document.modelspace()
|
|
layers: set[str] = {layer.dxf.name for layer in document.layers}
|
|
skipped: dict[str, int] = {}
|
|
for entity in entities:
|
|
_add_entity(space, entity, layers, skipped)
|
|
with tempfile.TemporaryDirectory(prefix="aislo-export-") as temporary:
|
|
path = Path(temporary) / "drawing.dxf"
|
|
document.saveas(path)
|
|
return path.read_bytes(), skipped
|
|
|
|
|
|
def _dxf2dwg_path() -> str | None:
|
|
"""LibreDWG 의 DWG 쓰기 도구 — `.env` 값 → 동봉본 → PATH 순으로 찾는다."""
|
|
configured = os.getenv("LIBREDWG_DXF2DWG_PATH", "").strip()
|
|
if configured:
|
|
return configured if Path(configured).is_file() else None
|
|
return bundled_tool("dxf2dwg.exe") or shutil.which("dxf2dwg")
|
|
|
|
|
|
def dxf_to_dwg(dxf_bytes: bytes) -> bytes:
|
|
"""DXF 를 DWG 로 바꾼다. 변환기가 없으면 「DXF 로 받으라」는 안내와 함께 실패."""
|
|
converter = _dxf2dwg_path()
|
|
if not converter:
|
|
raise ValueError("이 서버는 아직 DWG 로 내보내지 못합니다. DXF 로 내려받아 주십시오.")
|
|
with tempfile.TemporaryDirectory(prefix="aislo-export-") as temporary:
|
|
work_dir = Path(temporary)
|
|
source = work_dir / "drawing.dxf"
|
|
target = work_dir / "drawing.dwg"
|
|
source.write_bytes(dxf_bytes)
|
|
try:
|
|
subprocess.run(
|
|
[converter, "-o", str(target), str(source)],
|
|
check=True,
|
|
timeout=180,
|
|
capture_output=True,
|
|
)
|
|
except (subprocess.CalledProcessError, subprocess.TimeoutExpired) as exc:
|
|
logger.info("B07 도면 DWG 내보내기 실패: %s", exc)
|
|
raise ValueError("DWG 로 바꾸지 못했습니다. DXF 로 내려받아 주십시오.") from exc
|
|
if not target.is_file() or target.stat().st_size == 0:
|
|
raise ValueError("DWG 로 바꾸지 못했습니다. DXF 로 내려받아 주십시오.")
|
|
return target.read_bytes()
|
|
|
|
|
|
def export_drawing(drawing: dict[str, Any], file_format: str) -> tuple[bytes, dict[str, int]]:
|
|
"""도면을 요청한 형식(dxf·dwg) 파일 바이트로 낸다."""
|
|
if file_format == "dxf":
|
|
return drawing_to_dxf(drawing)
|
|
if file_format == "dwg":
|
|
dxf_bytes, skipped = drawing_to_dxf(drawing, _DWG_SOURCE_VERSION)
|
|
return dxf_to_dwg(dxf_bytes), skipped
|
|
raise ValueError("DXF 또는 DWG 로만 내보낼 수 있습니다.")
|