- 원인: 펴기 허용오차 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
386 lines
15 KiB
Python
386 lines
15 KiB
Python
"""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
|