"""B07 도각 템플릿 병합 — openwebcad JSON 템플릿을 도면 콘텐츠 둘레에 배치한다. resources/template_2dDrawing/의 사전 변환 템플릿(A1 도각 등)을 로드해, 도면 콘텐츠 bbox에 맞춰 균등 스케일·이동시킨 뒤 잠금 프레임 레이어 (b08-frame) 엔티티로 병합한다. 콘텐츠 좌표(m)는 건드리지 않는다 — 템플릿 쪽을 확대해 콘텐츠를 감싼다. A1 템플릿 기하(변환 시점 고정값): 전체 840x594, 하단 y17~47 표제란, 내부 작도 영역 (42, 47) ~ (812, 567). """ import json import logging from functools import lru_cache from pathlib import Path from typing import Any from uuid import uuid5 from B07_DesignDetail.B07_DesignDetail_Engine_Cad import ( _ENTITY_NS, FRAME_LAYER_ID, ) _TEMPLATE_DIR = Path(__file__).resolve().parent.parent / "resources" / "template_2dDrawing" logger = logging.getLogger(__name__) A1_TEMPLATE = "00_template_A1" COMPASS_TEMPLATE = "00_template_compass" # 유역도 등 평면 도면의 방위표 # A1 내부 작도 영역(템플릿 좌표) — 콘텐츠가 이 영역 중앙에 오도록 배치한다. _A1_INNER = (42.0, 47.0, 812.0, 567.0) _CONTENT_MARGIN = 0.05 # 내부 작도 영역 대비 콘텐츠 여백 비율(각 방향 5%) @lru_cache(maxsize=8) def _load_template(name: str) -> dict[str, Any] | None: path = _TEMPLATE_DIR / f"{name}.json" if not path.exists(): return None return json.loads(path.read_text(encoding="utf-8")) 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"])) 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} 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_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), ) def frame_entities( drawing_id: str, content_bbox: tuple[float, float, float, float], template_name: str = A1_TEMPLATE, fit: bool = True, ) -> 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 or content_h > usable_h): 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 return [ _transform_entity(entity, f"{drawing_id}:frame:{index}", scale, dx, dy) for index, entity in enumerate(template.get("entities", [])) ]