Files
Aislo/B07_DesignDetail/B07_DesignDetail_Engine_Template.py
eomsangdonandClaude Opus 5 784d6df801 feat(B07): 도각 외부 파일 불러오기(DXF/DWG)와 자리표 배치 도구
- DXF -> 도각 JSON 변환 엔진 신설 (ezdxf) — 선·폴리선·원/호/타원/스플라인 평탄화·글자·점 변환, 블록·치수는 분해, 해치 등 미지원 요소는 제외
- DWG 는 ODA File Converter(.env ODA_CONVERTER_PATH) 경유 변환, 미설치 시 DXF 저장 안내로 폴백
- POST /{project_id}/frame-template/import 신설 — 파일을 편집 화면용 도면으로 반환(저장은 기존 [완료] 경로 유지), 20MB·2만 도형 상한
- 도각 편집 띠에 「파일 불러오기」 추가, 자리표 팔레트 신설 (글자 14종·그림 4종을 도면 중앙에 배치 후 이동)
- CAD 텍스트 선택 시 회색 점선 외곽선 표시 (출력물에는 미포함)

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-06 12:41:22 +09:00

471 lines
20 KiB
Python

"""B07 도각 템플릿 병합 — openwebcad JSON 템플릿을 도면 콘텐츠 둘레에 배치한다.
resources/template_2dDrawing/의 사전 변환 템플릿(A1 도각 등)을 로드해,
도면 콘텐츠 bbox에 맞춰 균등 스케일·이동시킨 뒤 잠금 프레임 레이어
(b08-frame) 엔티티로 병합한다. 콘텐츠 좌표(m)는 건드리지 않는다 —
템플릿 쪽을 확대해 콘텐츠를 감싼다.
A1 템플릿 기하(변환 시점 고정값): 전체 840x594, 하단 y17~47 표제란,
내부 작도 영역 (42, 47) ~ (812, 567).
"""
import binascii
import json
import logging
import re
from base64 import b64decode
from contextvars import ContextVar
from functools import lru_cache
from pathlib import Path
from typing import Any
from uuid import NAMESPACE_URL, uuid5
from B07_DesignDetail.B07_DesignDetail_Engine_Cad import (
_ENTITY_NS,
DRAWING_FORMAT,
FRAME_LAYER_ID,
)
from B07_DesignDetail.B07_DesignDetail_Engine_Cad_Svg import fit_polylines, svg_polylines
_TEMPLATE_DIR = Path(__file__).resolve().parent.parent / "resources" / "template_2dDrawing"
logger = logging.getLogger(__name__)
# 벡터로 바꾼 서명 폴리선 id 를 결정적으로 만드는 이름공간 — 같은 도면을 두 번 뽑아도
# 엔티티 id 가 같아야 CAD 가 같은 것으로 본다.
_SVG_NS_UUID = uuid5(NAMESPACE_URL, "aislo/b07/signature-vector")
A1_TEMPLATE = "00_template_A1"
COMPASS_TEMPLATE = "00_template_compass" # 유역도 등 평면 도면의 방위표
# A1 내부 작도 영역(템플릿 좌표) — 콘텐츠가 이 영역 중앙에 오도록 배치한다.
_A1_INNER = (42.0, 47.0, 812.0, 567.0)
# 내부 작도 영역 대비 콘텐츠 여백 비율(각 방향). 5%는 도각 안쪽에 70mm 가까이를
# 비워 횡단 장이 한 장 더 늘었다 — 2%로 줄여 작도 영역을 쓴다(2026-08-30 사용자).
_CONTENT_MARGIN = 0.02
# 회사가 자기 도각을 두는 자리 — `storage/{회사}/templates/`. 프로그램 기본 도각
# (`resources/template_2dDrawing/`)은 **읽기 전용**이고, 고객이 고친 도각은 여기 쌓인다
# (2026-09-01 사용자 확정: "정본은 그냥 두고 수정하는 기능").
COMPANY_TEMPLATE_SUBDIR = "templates"
# 이 요청이 읽을 회사 도각 폴더. 라우터가 요청마다 세운다 — 엔진 6개(종단·횡단장·
# 토적도·유역도·표지·공용)의 서명을 줄줄이 고치지 않으려고 문맥 변수를 쓴다.
# `asyncio.to_thread`가 문맥을 복사하므로 스레드로 넘어간 작도에도 그대로 따라간다.
_company_dir: ContextVar[Path | None] = ContextVar("b07_company_template_dir", default=None)
def use_company_templates(company_dir: Path | None) -> None:
"""이 요청이 읽을 회사 도각 폴더를 정한다. None이면 프로그램 기본 도각."""
_company_dir.set(company_dir)
# 이 요청이 도각 표제란에 채울 값. 회사 도각 폴더와 같은 이유로 문맥 변수다 —
# 엔진 6개의 서명을 줄줄이 고치지 않는다. 값을 못 구한 자리는 **빈칸**으로 남는다.
_title_fields: ContextVar[dict[str, str]] = ContextVar("b07_title_fields", default={})
def use_title_fields(fields: dict[str, str] | None) -> None:
"""이 요청이 도각 표제란에 채울 값을 정한다. None이면 표제란이 전부 빈칸이다."""
_title_fields.set(fields or {})
def add_title_fields(extra: dict[str, str]) -> None:
"""이미 세운 표제란 값에 몇 개를 덧붙인다.
도면번호처럼 **도면을 읽는 도중에야 아는 값**을 위해서다. 라우터가 DB 값을 먼저 세우고,
작도 스레드가 여기서 나머지를 얹는다(`asyncio.to_thread` 가 문맥을 복사한다).
"""
_title_fields.set({**_title_fields.get(), **(extra or {})})
def company_template_path(company_dir: Path, name: str = A1_TEMPLATE) -> Path:
"""회사 도각 파일 경로(없을 수도 있다)."""
return Path(company_dir) / COMPANY_TEMPLATE_SUBDIR / f"{name}.json"
@lru_cache(maxsize=16)
def _read_template(path_str: str, mtime_ns: int) -> dict[str, Any]:
"""파일 하나를 읽어 캐시한다. 수정 시각이 캐시 키라 저장 즉시 새 도각이 나간다."""
return json.loads(Path(path_str).read_text(encoding="utf-8"))
def _load_template(name: str) -> dict[str, Any] | None:
"""회사 도각이 있으면 그것, 없으면 프로그램 기본 도각."""
company_dir = _company_dir.get()
if company_dir is not None:
override = company_template_path(company_dir, name)
if override.is_file():
return _read_template(str(override), override.stat().st_mtime_ns)
path = _TEMPLATE_DIR / f"{name}.json"
if not path.is_file():
return None
return _read_template(str(path), path.stat().st_mtime_ns)
def template_entities(name: str = A1_TEMPLATE) -> list[dict[str, Any]]:
"""도각 원본 엔티티(실치수 1:1). 편집 화면이 그대로 싣고, 저장도 이 좌표계로 받는다."""
template = _load_template(name)
return list(template.get("entities", [])) if template else []
def frame_template_document(name: str = A1_TEMPLATE) -> dict[str, Any]:
"""도각 편집 화면이 그대로 싣는 도면 — 실치수 1:1, 잠금 없는 도각 층 하나.
1:1이라 편집 캔버스 좌표가 곧 템플릿 좌표다. 저장할 때 되돌릴 변환이 없다.
"""
return frame_document(template_entities(name))
def frame_document(entities: list[dict[str, Any]]) -> dict[str, Any]:
"""엔티티 목록을 도각 편집 화면이 그대로 싣는 도면 한 장으로 감싼다 (실치수 1:1)."""
return {
"format": DRAWING_FORMAT,
"entities": [{**entity, "layerId": FRAME_LAYER_ID} for entity in entities],
"layers": [{"id": FRAME_LAYER_ID, "name": "도각", "isVisible": True, "isLocked": False}],
}
def validate_template_entities(entities: list[dict[str, Any]]) -> None:
"""도면 조립이 실제로 쓰는 좌표가 숫자인지 본다. 어긋나면 ValueError.
검사 없이 받으면 좌표가 빠진 도형 하나로 **이후 모든 도면 요청이 500**이 되고
되돌릴 길이 없다(2026-09-01 지적). 여기서 막으면 편집 화면에 400으로 돌아간다.
`_transform_entity`·`entities_bbox`가 읽는 키만 본다 — 그 밖은 그대로 통과시킨다.
"""
def _check(entity: Any, where: str) -> None:
if not isinstance(entity, dict):
raise ValueError(f"도각 도형이 올바르지 않습니다 ({where}).")
shape = entity.get("shapeData")
if isinstance(shape, dict):
for key in ("startPoint", "endPoint", "basePoint", "point", "center"):
point = shape.get(key)
if point is None:
continue
if not isinstance(point, dict) or not all(
isinstance(point.get(axis), (int, float)) for axis in ("x", "y")
):
raise ValueError(f"도각 도형의 {key} 좌표가 숫자가 아닙니다 ({where}).")
if "radius" in shape and not isinstance(shape["radius"], (int, float)):
raise ValueError(f"도각 도형의 반지름이 숫자가 아닙니다 ({where}).")
children = entity.get("children")
if isinstance(children, list):
for index, child in enumerate(children):
_check(child, f"{where}:{index}")
for index, entity in enumerate(entities):
_check(entity, f"#{index}")
def clear_company_template(company_dir: Path, name: str = A1_TEMPLATE) -> bool:
"""회사 도각을 지워 프로그램 기본 도각으로 되돌린다. 지울 것이 없으면 False."""
path = company_template_path(company_dir, name)
if not path.is_file():
return False
path.unlink()
return True
def save_company_template(
company_dir: Path, entities: list[dict[str, Any]], name: str = A1_TEMPLATE
) -> Path:
"""편집한 도각을 회사 도각 파일로 저장한다. 프로그램 기본 도각은 건드리지 않는다."""
validate_template_entities(entities)
path = company_template_path(company_dir, name)
path.parent.mkdir(parents=True, exist_ok=True)
document = {
"format": DRAWING_FORMAT,
"source": "B07 도각 편집 화면",
# 도각은 도면마다 잠금 층 하나로 붙으므로 편집 중 새로 만든 층은 도각으로 모은다.
"entities": [{**entity, "layerId": FRAME_LAYER_ID} for entity in entities],
"layers": [{"id": FRAME_LAYER_ID, "name": "도각", "isVisible": True, "isLocked": False}],
}
temporary = path.with_suffix(".tmp")
temporary.write_text(json.dumps(document, ensure_ascii=False, indent=2), encoding="utf-8")
temporary.replace(path)
return path
def entities_bbox(entities: list[dict[str, Any]]) -> tuple[float, float, float, float] | None:
"""엔티티 목록의 (min_x, min_y, max_x, max_y). 좌표가 없으면 None."""
xs: list[float] = []
ys: list[float] = []
def _collect(entity: dict[str, Any]) -> None:
shape = entity.get("shapeData") or {}
for key in ("startPoint", "endPoint", "basePoint", "point"):
p = shape.get(key)
if isinstance(p, dict):
xs.append(float(p["x"]))
ys.append(float(p["y"]))
# 꼭짓점 배열을 쓰는 엔티티(Image·Hatch)도 범위에 넣는다 — 넣지 않으면 라이다
# 음영기복 그림이 도곽 계산에서 통째로 빠진다(2026-09-04).
for vertex in shape.get("points") or []:
if isinstance(vertex, dict) and "x" in vertex and "y" in vertex:
xs.append(float(vertex["x"]))
ys.append(float(vertex["y"]))
center = shape.get("center")
if isinstance(center, dict):
r = float(shape.get("radius", 0.0))
xs.extend((float(center["x"]) - r, float(center["x"]) + r))
ys.extend((float(center["y"]) - r, float(center["y"]) + r))
for child in entity.get("children") or []:
_collect(child)
for entity in entities:
_collect(entity)
if not xs:
return None
return (min(xs), min(ys), max(xs), max(ys))
def _transform_entity(
entity: dict[str, Any], seed: str, scale: float, dx: float, dy: float
) -> dict[str, Any]:
"""템플릿 엔티티를 스케일+이동 복사한다. id는 도면별 결정적 재생성."""
out = dict(entity)
out["id"] = str(uuid5(_ENTITY_NS, seed))
out["layerId"] = FRAME_LAYER_ID
shape = entity.get("shapeData")
if isinstance(shape, dict):
new_shape = dict(shape)
for key in ("startPoint", "endPoint", "basePoint", "point", "center"):
p = shape.get(key)
if isinstance(p, dict):
new_shape[key] = {"x": p["x"] * scale + dx, "y": p["y"] * scale + dy}
# 꼭짓점 배열을 쓰는 엔티티(Image 로고·서명, Hatch 띠)도 함께 옮긴다.
points = shape.get("points")
if isinstance(points, list):
new_shape["points"] = [
{"x": point["x"] * scale + dx, "y": point["y"] * scale + dy}
for point in points
if isinstance(point, dict)
]
if "radius" in shape:
new_shape["radius"] = shape["radius"] * scale
options = shape.get("options")
if isinstance(options, dict):
new_options = dict(options)
new_options["fontSize"] = options.get("fontSize", 4.0) * scale
new_shape["options"] = new_options
out["shapeData"] = new_shape
children = entity.get("children")
if isinstance(children, list):
out["children"] = [
_transform_entity(child, f"{seed}:{index}", scale, dx, dy)
for index, child in enumerate(children)
]
return out
def compass_entities(
drawing_id: str,
center: tuple[float, float],
size_mm: float,
template_name: str = COMPASS_TEMPLATE,
) -> list[dict[str, Any]]:
"""방위표 템플릿을 지정 위치에 지정 크기로 놓는다(잠금 프레임 레이어).
도각과 달리 콘텐츠를 감쌀 일이 없으므로 긴 변이 size_mm가 되도록 균등 축소한다.
"""
template = _load_template(template_name)
if not template:
return []
entities = template.get("entities", [])
bbox = entities_bbox(entities)
if not bbox:
return []
min_x, min_y, max_x, max_y = bbox
span = max(max_x - min_x, max_y - min_y, 1e-6)
scale = size_mm / span
dx = center[0] - (min_x + max_x) / 2.0 * scale
dy = center[1] - (min_y + max_y) / 2.0 * scale
return [
_transform_entity(entity, f"{drawing_id}:compass:{index}", scale, dx, dy)
for index, entity in enumerate(entities)
]
def usable_bbox() -> tuple[float, float, float, float]:
"""작도 영역 한가운데에 놓인 **수용 한도 크기**의 빈 bbox.
담을 내용이 없는 도면(빈 도면)이 도각만 두를 때 쓴다. `_A1_INNER`를 그대로 넘기면
여백을 뺀 한도(`usable_area()`)보다 커서 "작도 영역을 넘습니다" 경고가 뜬다 —
내용이 없는데 넘칠 리 없다. 중심이 같으므로 도각 배치(이동량 0)는 그대로다.
"""
ix0, iy0, ix1, iy1 = _A1_INNER
width, height = usable_area()
cx, cy = (ix0 + ix1) / 2.0, (iy0 + iy1) / 2.0
return (cx - width / 2.0, cy - height / 2.0, cx + width / 2.0, cy + height / 2.0)
def usable_area() -> tuple[float, float]:
"""A1 내부 작도 영역에서 여백을 뺀 유효 크기(mm). 척도 고정 도면의 수용 한도."""
ix0, iy0, ix1, iy1 = _A1_INNER
return (
(ix1 - ix0) * (1.0 - 2.0 * _CONTENT_MARGIN),
(iy1 - iy0) * (1.0 - 2.0 * _CONTENT_MARGIN),
)
_PLACEHOLDER = re.compile(r"\{\{\s*([^}]+?)\s*\}\}")
def _fill_placeholders(
entities: list[dict[str, Any]], fields: dict[str, str]
) -> list[dict[str, Any]]:
"""도각의 {{키}}를 값으로 바꾼 엔티티 목록을 낸다. 값이 없으면 빈칸 — 남의 값이 남지 않는다.
요청 문맥의 표제란 값(`use_title_fields`)이 바탕이고, 인자로 준 값(도면마다 다른
도면명 등)이 위에 얹힌다. 합치는 자리를 **치환 함수 한 곳**에 둬야 도각을 두르지
않는 표지처럼 다른 경로로 들어온 도면도 같은 값을 받는다(2026-09-02 표지 누락).
"""
fields = {**_title_fields.get(), **(fields or {})}
def substitute(text: str) -> str:
return _PLACEHOLDER.sub(lambda match: str(fields.get(match.group(1), "")), text)
for entity in entities:
shape = entity.get("shapeData") or {}
# 글자 자리
label = shape.get("label")
if entity.get("type") == "Text" and isinstance(label, str) and "{{" in label:
shape["label"] = substitute(label)
# 그림 자리(회사 로고·개인 서명) — 값은 data URL. 못 구하면 그림을 통째로
# 빼서 빈 칸으로 둔다(빈 문자열을 남기면 CAD가 깨진 그림으로 그린다).
image = shape.get("imageData")
if entity.get("type") == "Image" and isinstance(image, str) and "{{" in image:
shape["imageData"] = substitute(image)
# 그림을 못 구한 자리는 엔티티째 뺀다 — 빈 문자열을 남기면 CAD가 깨진 그림을 그린다.
kept = [
entity
for entity in entities
if entity.get("type") != "Image" or (entity.get("shapeData") or {}).get("imageData")
]
return [replaced for entity in kept for replaced in _vectorize_svg_image(entity)]
def _vectorize_svg_image(entity: dict[str, Any]) -> list[dict[str, Any]]:
"""SVG 로 올라온 서명·로고를 CAD 폴리선으로 바꾼다(2026-09-03 사용자 결정).
래스터(PNG·JPG)는 그릴 선이 없으므로 종전대로 `ImageEntity` 로 둔다. SVG 만 벡터로
바꿔 DXF 로 나가도 선으로 남고 확대해도 깨지지 않게 한다. 읽지 못하면 원래 그림을
그대로 둔다 — 서명이 통째로 사라지는 것보다 낫다.
"""
if entity.get("type") != "Image":
return [entity]
shape = entity.get("shapeData") or {}
data_url = shape.get("imageData")
if not isinstance(data_url, str) or not data_url.startswith("data:image/svg+xml;base64,"):
return [entity]
points = shape.get("points") or []
if len(points) < 4:
return [entity]
try:
svg_text = b64decode(data_url.split(",", 1)[1]).decode("utf-8", errors="ignore")
except (ValueError, binascii.Error) as exc:
logger.warning("서명 SVG data URL 을 풀지 못했습니다 — %s", exc)
return [entity]
view_box, runs = svg_polylines(svg_text)
if not runs:
return [entity]
xs = [float(point["x"]) for point in points]
ys = [float(point["y"]) for point in points]
fitted = fit_polylines(runs, view_box, (min(xs), min(ys), max(xs), max(ys)))
layer_id = entity.get("layerId")
color = entity.get("lineColor", "#000000")
vector: list[dict[str, Any]] = []
for index, run in enumerate(fitted):
children = [
{
"id": str(uuid5(_SVG_NS_UUID, f"{entity.get('id')}:{index}:{step}")),
"type": "Line",
"lineColor": color,
"lineWidth": 1,
"layerId": layer_id,
"shapeData": {
"startPoint": {"x": run[step][0], "y": run[step][1]},
"endPoint": {"x": run[step + 1][0], "y": run[step + 1][1]},
},
}
for step in range(len(run) - 1)
]
if not children:
continue
vector.append(
{
"id": str(uuid5(_SVG_NS_UUID, f"{entity.get('id')}:{index}")),
"type": "PolyLine",
"lineColor": color,
"lineWidth": 1,
"layerId": layer_id,
"shapeData": None,
"children": children,
}
)
return vector or [entity]
def scale_fields(*denominators: tuple[str, int]) -> dict[str, str]:
"""축척 칸(`{{축척_A1}}`·`{{축척_A3}}`)에 넣을 값.
도각이 `A1 = 1 :` 를 이미 찍으므로 **분모만** 낸다. A3 는 A1 도면을 절반으로 뽑는
종이라 분모가 2배다. 가로·세로 축척이 다른 도면(종단·토적)은 이름표를 붙여 함께 적는다.
값은 `config_system` 의 축척 상수에서 오며 여기서 새로 정하지 않는다.
"""
def text(factor: int) -> str:
return " · ".join(
f"{denominator * factor}({name})" if name else str(denominator * factor)
for name, denominator in denominators
)
return {"축척_A1": text(1), "축척_A3": text(2)}
def frame_entities(
drawing_id: str,
content_bbox: tuple[float, float, float, float],
template_name: str = A1_TEMPLATE,
fit: bool = True,
fields: dict[str, str] | None = None,
) -> list[dict[str, Any]]:
"""콘텐츠 bbox를 감싸는 도각 프레임 엔티티 목록(잠금 레이어). 템플릿 없으면 빈 목록.
fit=False면 도각을 **실치수(1:1)** 로 두고 위치만 맞춘다 — 콘텐츠가 이미 종이
밀리미터로 그려진 척도 고정 도면(종단·횡단)용. fit=True는 척도가 없는 도면을
도각에 맞춰 늘리던 기존 동작이다.
"""
template = _load_template(template_name)
if not template:
return []
min_x, min_y, max_x, max_y = content_bbox
content_w = max(max_x - min_x, 1e-6)
content_h = max(max_y - min_y, 1e-6)
ix0, iy0, ix1, iy1 = _A1_INNER
usable_w, usable_h = usable_area()
scale = max(content_w / usable_w, content_h / usable_h) if fit else 1.0
# 한도와 **같은** 크기는 넘친 것이 아니다 — 부동소수 오차만큼의 여유를 둔다
# (수용 한도를 그대로 넘기는 빈 도면이 마지막 자리 오차로 경고를 냈다).
if not fit and (content_w > usable_w + 1e-6 or content_h > usable_h + 1e-6):
logger.warning(
"도면 콘텐츠가 A1 작도 영역을 넘습니다: %s (%.0fx%.0f mm > %.0fx%.0f mm)",
drawing_id,
content_w,
content_h,
usable_w,
usable_h,
)
# 콘텐츠 중심 = 내부 작도 영역 중심이 되도록 이동량 산출.
dx = (min_x + max_x) / 2.0 - (ix0 + ix1) / 2.0 * scale
dy = (min_y + max_y) / 2.0 - (iy0 + iy1) / 2.0 * scale
placed = [
_transform_entity(entity, f"{drawing_id}:frame:{index}", scale, dx, dy)
for index, entity in enumerate(template.get("entities", []))
]
return _fill_placeholders(placed, fields or {})