Files
Aislo/B07_DesignDetail/B07_DesignDetail_Engine_Frame_Import.py
T
eomsangdonandClaude Opus 5.5 008558d4ee fix(B07): DXF·DWG 불러오기 작은 원·호 뭉개짐 고침 — 곡선 토막마다 8마디 이상 · 부분 타원 닫는 줄 없앰(99-3)
- 원인: 펴기 허용오차 0.2 고정 — m 단위 도면(중력식 사방댐 시트 폭 약 80)에서 반지름 0.071 원이 길이 0 선 1개 · 0.15~0.2 원이 삼각형 · 0.36~0.46 호가 직선 한 마디
- 고침: 원·호·타원·스플라인·bulge 폴리선 모두 ezdxf 경로로 펴고 90° 이하 토막마다 8마디 이상
- 원본 DWG 대조: 원 42 · 호 16 오차 반지름의 0.5% 이하 · 호 16 모두 원래 각 범위 안(큰 호로 뒤집힘 0) · 불러오기 1.45 s · 도형 1,959
- 시험: test_b07_frame_import_shapes 작은 원·호 시험 하나 더함

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015KYZ2HWWdUwZWYnn3tQz7X
2026-10-08 15:16:40 +09:00

386 lines
15 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""B07 외부 도각 파일 불러오기 — DXF(및 변환된 DWG)를 도각 JSON 으로 바꾼다.
고객이 내는 도각은 DWG 일 확률이 높지만 DWG 는 비공개 형식이라 파이썬이 바로 못 읽는다.
**LibreDWG 의 `dwg2dxf`** 로 바꿔 읽는다 (2026-09-06 사용자 확정). ODA File Converter 는
비회원 무료 사용이 **비상업 용도로 제한**돼 이 프로그램에는 쓰지 않는다.
LibreDWG 는 별도 실행 파일로만 부른다 — 라이브러리로 끌어안으면 GPL 이 이 프로그램까지
번진다. 별도 프로세스 호출은 그 의무가 생기지 않는다.
읽는 범위는 **R2018(AC1032) 까지**다. 그보다 새 형식이나 변환 실패는 「R2018 이하 또는
DXF 로 저장해 달라」는 안내로 떨어진다.
프로그램 값이 들어갈 자리는 여기서 알아맞히지 않는다 — 불러온 뒤 사용자가 자리표를
직접 놓는다. 좌표는 파일에 있는 그대로 쓴다(도각은 실치수 1:1 mm 로 그린다).
"""
from __future__ import annotations
import io
import logging
import os
import shutil
import subprocess
import tempfile
from math import cos, radians, sin
from pathlib import Path
from typing import Any
from uuid import NAMESPACE_URL, uuid5
import ezdxf
from ezdxf import colors as ezdxf_colors
from ezdxf import path as ezdxf_path
from ezdxf.enums import TextEntityAlignment
from ezdxf.lldxf.encoding import decode_dxf_unicode, has_dxf_unicode
logger = logging.getLogger(__name__)
_IMPORT_NS = uuid5(NAMESPACE_URL, "aislo/b07/frame-import")
_DEFAULT_COLOR = "#f5f7fa"
# 곡선을 선분으로 풀 때 허용 오차(mm) — 도각 크기(840x594mm)에서 눈에 띄지 않는다.
_FLATTEN_MM = 0.2
# 곡선 한 토막(90° 이하)을 적어도 이만큼 나눔 — 단위가 m 인 도면의 작은 원 · 호가 점 · 삼각형 · 직선으로 뭉개지지 않게.
_CURVE_SEGMENTS = 8
_MAX_ENTITIES = 20000
# 블록 속 블록을 풀 깊이 — 자기를 품는 깨진 블록에서 끝없이 돌지 않게.
_MAX_BLOCK_DEPTH = 16
# 프로그램에 함께 담은 LibreDWG 자리 — `B07_DesignDetail/openwebcad/tools/libredwg/`.
# 별도 실행 파일로만 부른다(라이브러리로 품지 않는다) — 자세한 것은 그 폴더의 README.
_BUNDLED_DIR = Path(__file__).resolve().parent / "openwebcad" / "tools" / "libredwg"
def bundled_tool(name: str) -> str | None:
"""동봉한 변환기 경로. 없으면 None."""
path = _BUNDLED_DIR / name
return str(path) if path.is_file() else None
def _entity_id(index: int) -> str:
return str(uuid5(_IMPORT_NS, str(index)))
def _color(entity: Any) -> str:
"""DXF 색 번호를 화면 색으로. 도면층 색(BYLAYER)이면 기본색을 쓴다."""
try:
aci = int(entity.dxf.color)
if aci in (0, 256): # BYBLOCK · BYLAYER
return _DEFAULT_COLOR
red, green, blue = ezdxf_colors.aci2rgb(aci)
return f"#{red:02x}{green:02x}{blue:02x}"
except Exception:
return _DEFAULT_COLOR
def _point(x: float, y: float) -> dict[str, float]:
return {"x": float(x), "y": float(y)}
def _base(index: int, entity: Any, kind: str) -> dict[str, Any]:
return {
"id": _entity_id(index),
"type": kind,
"lineColor": _color(entity),
"lineWidth": 1,
"layerId": str(getattr(entity.dxf, "layer", "0")),
}
def _line(index: int, entity: Any, start: Any, end: Any) -> dict[str, Any]:
return {
**_base(index, entity, "Line"),
"shapeData": {
"startPoint": _point(start[0], start[1]),
"endPoint": _point(end[0], end[1]),
},
}
def _polyline(index: int, entity: Any, points: list[Any], closed: bool) -> dict[str, Any] | None:
"""점 목록을 선분 묶음(PolyLine)으로 바꾼다. 점이 2개 미만이면 버린다."""
vertices = [(float(p[0]), float(p[1])) for p in points]
if closed and len(vertices) > 2:
vertices.append(vertices[0])
if len(vertices) < 2:
return None
children = [
{
**_base(index, entity, "Line"),
"id": str(uuid5(_IMPORT_NS, f"{index}:{seq}")),
"shapeData": {
"startPoint": _point(*vertices[seq]),
"endPoint": _point(*vertices[seq + 1]),
},
}
for seq in range(len(vertices) - 1)
]
return {**_base(index, entity, "PolyLine"), "shapeData": None, "children": children}
def _plain(label: str) -> str:
"""옛 DXF 는 한글을 유니코드 escape 로 적는다 — 글자로 되돌린다."""
return decode_dxf_unicode(label) if has_dxf_unicode(label) else label
def _text(
index: int,
entity: Any,
label: str,
insert: Any,
height: float,
direction: Any = None,
align: str = "left",
) -> dict[str, Any]:
if direction is None:
rotation = float(getattr(entity.dxf, "rotation", 0.0) or 0.0)
direction = (cos(radians(rotation)), sin(radians(rotation)))
return {
**_base(index, entity, "Text"),
"shapeData": {
"label": _plain(label),
"basePoint": _point(insert[0], insert[1]),
"options": {
"textDirection": _point(direction[0], direction[1]),
"textAlign": align,
"textColor": _color(entity),
"fontSize": float(height) or 3.0,
"fontFamily": "sans-serif",
},
},
}
def _path_points(entity: Any) -> list[Any]:
"""도형 → WCS 경로 점열 — 곡선 토막마다 `_CURVE_SEGMENTS` 이상으로 나눔."""
return list(ezdxf_path.make_path(entity).flattening(_FLATTEN_MM, segments=_CURVE_SEGMENTS))
def _flatten(entity: Any) -> tuple[list[Any], bool] | None:
"""원·호·타원·스플라인 → (선분 점열, 닫힘). 경로는 닫는 마디까지 넣음 · 못 펴면 None."""
try:
return _path_points(entity), False
except Exception:
pass
try:
return list(entity.flattening(_FLATTEN_MM)), entity.dxftype() == "CIRCLE"
except Exception:
return None
def _polyline_points(entity: Any) -> tuple[list[Any], bool]:
"""폴리선 → WCS 점열. bulge(호 마디)는 펴고 OCS(압출 −Z 거울 블록)는 WCS 로.
`make_path` 가 닫힌 폴리선의 닫는 마디까지 넣으므로 닫힘 표시는 False.
면 · 메시 POLYLINE 처럼 경로로 못 바꾸면 꼭짓점을 그대로 잇는다.
"""
try:
return _path_points(entity), False
except Exception:
if entity.dxftype() == "LWPOLYLINE":
return list(entity.get_points("xy")), bool(entity.closed)
return [vertex.dxf.location for vertex in entity.vertices], bool(entity.is_closed)
# DXF 가로 정렬 → CAD 글 정렬. 맞춤(ALIGNED · FIT)은 첫 점에서 왼쪽.
_TEXT_ALIGN = {
TextEntityAlignment.CENTER: "center",
TextEntityAlignment.MIDDLE: "center",
TextEntityAlignment.TOP_CENTER: "center",
TextEntityAlignment.MIDDLE_CENTER: "center",
TextEntityAlignment.BOTTOM_CENTER: "center",
TextEntityAlignment.RIGHT: "right",
TextEntityAlignment.TOP_RIGHT: "right",
TextEntityAlignment.MIDDLE_RIGHT: "right",
TextEntityAlignment.BOTTOM_RIGHT: "right",
}
def _text_placement(entity: Any) -> tuple[Any, Any, str]:
"""TEXT 의 (WCS 기준점, WCS 방향, 가로 정렬). 정렬점 · OCS(압출 −Z)를 따른다."""
align, first, _second = entity.get_placement()
ocs = entity.ocs()
rotation = radians(float(entity.dxf.rotation or 0.0))
direction = ocs.to_wcs((cos(rotation), sin(rotation), 0.0))
return ocs.to_wcs(first), direction, _TEXT_ALIGN.get(align, "left")
def _convert_entity(index: int, entity: Any) -> list[dict[str, Any]]:
kind = entity.dxftype()
if kind == "LINE":
return [_line(index, entity, entity.dxf.start, entity.dxf.end)]
if kind in ("LWPOLYLINE", "POLYLINE"):
points, closed = _polyline_points(entity)
shape = _polyline(index, entity, points, closed)
return [shape] if shape else []
if kind in ("CIRCLE", "ARC", "ELLIPSE", "SPLINE"):
flat = _flatten(entity)
if not flat:
return []
shape = _polyline(index, entity, *flat)
return [shape] if shape else []
if kind == "POINT":
location = entity.dxf.location
return [
{
**_base(index, entity, "Point"),
"shapeData": {"point": _point(location[0], location[1])},
}
]
if kind == "TEXT":
label = str(entity.dxf.text or "").strip()
if not label:
return []
insert, direction, align = _text_placement(entity)
height = float(entity.dxf.height or 3.0)
return [_text(index, entity, label, insert, height, direction, align)]
if kind == "MTEXT":
label = str(entity.plain_text() or "").strip()
if not label:
return []
height = float(entity.dxf.char_height or 3.0)
# 붙임점 1~9 = 위 · 가운데 · 아래 줄의 왼 · 가운데 · 오른.
align = ("left", "center", "right")[(int(entity.dxf.attachment_point or 1) - 1) % 3]
return [_text(index, entity, label, entity.dxf.insert, height, align=align)]
return []
def _expand(entity: Any, depth: int = 0) -> list[Any]:
"""블록·치수처럼 속에 도형을 품은 것은 풀어서 낱개로 — 블록 속 블록까지. 못 풀면 버린다."""
if entity.dxftype() in ("INSERT", "DIMENSION", "LEADER", "MULTILEADER"):
if depth >= _MAX_BLOCK_DEPTH:
logger.info("B07 도각 불러오기 — 블록이 %d 겹 넘게 겹쳐 건너뜀", _MAX_BLOCK_DEPTH)
return []
try:
inner = list(entity.virtual_entities())
except Exception:
logger.info("B07 도각 불러오기 — %s 는 풀지 못해 건너뜀", entity.dxftype())
return []
return [item for child in inner for item in _expand(child, depth + 1)]
return [entity]
def dxf_to_entities(path: Path) -> list[dict[str, Any]]:
"""DXF 파일을 도각 엔티티 목록으로. 지원 밖 도형(해치·솔리드 등)은 버린다."""
return _document_entities(ezdxf.readfile(str(path)))
def _layer_color(document: Any, name: str) -> str | None:
"""도면층 색(ACI) → 화면 색. 꺼진 층은 음수로 적히므로 절댓값."""
try:
aci = abs(int(document.layers.get(name).dxf.color))
red, green, blue = ezdxf_colors.aci2rgb(aci)
return f"#{red:02x}{green:02x}{blue:02x}"
except Exception:
return None
def dxf_text_to_entities(text: str) -> tuple[list[dict[str, Any]], list[dict[str, Any]]]:
"""클립보드 DXF 글 → (엔티티 목록, 쓰인 도면층 [{name, color}])."""
return _entities_with_layers(ezdxf.read(io.StringIO(text)))
def drawing_file_to_entities(
filename: str, data: bytes
) -> tuple[list[dict[str, Any]], list[dict[str, Any]]]:
"""끌어다 놓은 DXF·DWG 파일 → (엔티티 목록, 쓰인 도면층 [{name, color}])."""
return _entities_with_layers(_read_drawing_file(filename, data))
def _entities_with_layers(document: Any) -> tuple[list[dict[str, Any]], list[dict[str, Any]]]:
"""엔티티 `layerId` 는 DXF 도면층 이름 그대로 — 웹 CAD 가 이름으로 자기 도면층에 잇는다."""
entities = _document_entities(document)
names = sorted({str(entity["layerId"]) for entity in entities})
return entities, [{"name": name, "color": _layer_color(document, name)} for name in names]
def _document_entities(document: Any) -> list[dict[str, Any]]:
entities: list[dict[str, Any]] = []
index = 0
for source in document.modelspace():
for item in _expand(source):
entities.extend(_convert_entity(index, item))
index += 1
if len(entities) > _MAX_ENTITIES:
raise ValueError(
f"도형이 너무 많습니다({_MAX_ENTITIES}개 넘음)."
" 도각만 남겨 다시 저장해 주십시오."
)
if not entities:
raise ValueError("읽을 수 있는 도형이 없습니다. 선·글자가 있는 도각인지 확인해 주십시오.")
return entities
# DWG 머리글의 형식 표시(앞 6바이트) — 읽을 수 있는 것과 사람이 읽을 이름.
_DWG_VERSIONS: dict[str, str] = {
"AC1014": "R14",
"AC1015": "2000",
"AC1018": "2004",
"AC1021": "2007",
"AC1024": "2010",
"AC1027": "2013",
"AC1032": "2018",
}
_SAVE_AS_GUIDE = (
"캐드에서 「다른 이름으로 저장」으로 AutoCAD 2018 DWG 또는 DXF 를 골라 저장한 뒤 올려 주십시오."
)
def dwg_version(data: bytes) -> str | None:
"""DWG 머리글에서 형식 이름을 읽는다. 우리가 아는 형식이 아니면 None."""
return _DWG_VERSIONS.get(data[:6].decode("ascii", "ignore"))
def _dwg2dxf_path() -> str | None:
"""LibreDWG 변환기(dwg2dxf) 자리 — `.env` 값 → 동봉본 → PATH 순으로 찾는다."""
configured = os.getenv("LIBREDWG_DWG2DXF_PATH", "").strip()
if configured:
return configured if Path(configured).is_file() else None
return bundled_tool("dwg2dxf.exe") or shutil.which("dwg2dxf")
def _dwg_to_dxf(source: Path, work_dir: Path) -> Path:
"""LibreDWG 로 DWG 를 DXF 로 바꾼다. 못 읽는 형식·변환기 없음은 안내와 함께 실패."""
with source.open("rb") as handle:
version = dwg_version(handle.read(6))
if version is None:
raise ValueError(f"이 DWG 는 R2018 이후이거나 알 수 없는 형식입니다. {_SAVE_AS_GUIDE}")
converter = _dwg2dxf_path()
if not converter:
raise ValueError(f"이 서버는 아직 DWG 를 바로 읽지 못합니다. {_SAVE_AS_GUIDE}")
target = work_dir / "converted.dxf"
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): %s", version, exc)
raise ValueError(f"DWG({version}) 를 바꾸지 못했습니다. {_SAVE_AS_GUIDE}") from exc
if not target.is_file() or target.stat().st_size == 0:
raise ValueError(f"DWG({version}) 를 바꾸지 못했습니다. {_SAVE_AS_GUIDE}")
return target
def import_frame_file(filename: str, data: bytes) -> list[dict[str, Any]]:
"""올린 도각 파일(DXF·DWG)을 도각 엔티티 목록으로 바꾼다."""
return _document_entities(_read_drawing_file(filename, data))
def _read_drawing_file(filename: str, data: bytes) -> Any:
"""DXF·DWG 파일 → ezdxf 문서(메모리에 다 읽음 · 임시 폴더는 바로 지움)."""
suffix = Path(filename).suffix.lower()
if suffix not in (".dxf", ".dwg"):
raise ValueError("DXF 또는 DWG 파일만 올릴 수 있습니다.")
with tempfile.TemporaryDirectory(prefix="aislo-frame-") as temporary:
work_dir = Path(temporary)
source = work_dir / f"frame{suffix}"
source.write_bytes(data)
target = _dwg_to_dxf(source, work_dir) if suffix == ".dwg" else source
try:
return ezdxf.readfile(str(target))
except ezdxf.DXFError as exc:
raise ValueError(f"DXF 를 읽지 못했습니다: {exc}") from exc