"""B07 횡단 장 배치용 **덧그림 범위** — 화면이 B06 산식으로 덧그리는 설계선·구조물이 측점마다 차지하는 범위(오프셋·표고 m)를 서버가 잰다(68-3). 계산은 새로 짜지 않는다 — 화면이 쓰는 TS(`B07_DesignDetail_Cross_Overlay`)를 Node 진입점(`B07_DesignDetail_Cross_Extent_Node.ts`)으로 그대로 돌린다(CLAUDE.md 5장 ②). 입력은 화면이 받는 것과 같은 종횡단 상세(`get_section_detail`)다. 실패는 비치명적이다 — 빈 범위를 내면 장 배치는 서버 그림만으로 선다(68-1 동작). """ from __future__ import annotations import asyncio import hashlib import json import logging from pathlib import Path from typing import Any from uuid import UUID from B05_Profile.B05_Profile_Engine_Sections import cross_station from common_util.common_util_node_bundle import bundle_stale, run_bundle_json logger = logging.getLogger(__name__) ROOT = Path(__file__).resolve().parents[1] BUNDLE = ROOT / "config" / "b07_extent_node" / "B07_DesignDetail_Cross_Extent_Node.js" _NPM_SCRIPT = "build:b07-extent" Extent = tuple[float, float, float, float] # 같은 상세면 같은 범위 — 목록 · 장마다 다시 부르므로 상세 내용으로 기억한다. # ponytail: 프로세스 메모리 · 최근 몇 벌만. 서버 여러 대면 파일 캐시로. _CACHE: dict[str, dict[int | float, Extent]] = {} _CACHE_LIMIT = 8 def _extents_from(output: Any) -> dict[int | float, Extent]: extents: dict[int | float, Extent] = {} rows = output.get("extents") if isinstance(output, dict) else None for row in rows if isinstance(rows, list) else []: try: extents[cross_station(float(row["chainage_m"]))] = ( float(row["x_min"]), float(row["x_max"]), float(row["y_min"]), float(row["y_max"]), ) except (KeyError, TypeError, ValueError): continue return extents async def cross_overlay_extents(project_id: UUID | str, route_id: int) -> dict[int | float, Extent]: """{측점: (오프셋 최소, 최대, 표고 최소, 최대)} — 덧그림이 없는 측점은 빠진다.""" from B06_Section.B06_Section_Router import get_section_detail try: response = await get_section_detail(UUID(str(project_id)), route_id) dump = getattr(response, "model_dump", None) if dump is None: # JSONResponse = 실패 return {} detail = dump(mode="json") key = hashlib.sha1( json.dumps(detail, sort_keys=True, default=str).encode("utf-8") ).hexdigest() # 번들이 낡았으면(화면 TS 가 바뀜) 기억을 버리고 다시 잰다 — 다시 빌드도 함께. if key in _CACHE and not await asyncio.to_thread(bundle_stale, BUNDLE): return _CACHE[key] output = await asyncio.to_thread(run_bundle_json, BUNDLE, _NPM_SCRIPT, {"detail": detail}) except Exception: logger.warning("B07 덧그림 범위 계산 실패 — 서버 그림만으로 배치", exc_info=True) return {} if output is None: return {} extents = _extents_from(output) if len(_CACHE) >= _CACHE_LIMIT: _CACHE.pop(next(iter(_CACHE))) _CACHE[key] = extents return extents