- 원인 확인: 유역도 23 MB = 1 m 아님 · 도엽 N3L 5 m 주곡선 그대로(등고선 층이 응답 대부분) - 유역도 종류 파일 contour_interval_m(5 · 10) · 배경선 = 간격 배수 높이만 · 10 m = 23.17 → 11.91 MB - 지문: 종류 목록 지문에서 간격 뺌 → 간격 바꾸면 유역도만 다시 받음 · 기본 종류 조각 차례 맞춤(파일로 내려도 지문 그대로) - 시험: test_512_1_b07_basin_contour 새로 · test_b07_plan_kinds · helper_130 새 칸 · test_cad_host_tabs 508-1f 뒤 ☰ 자리 단언 고침 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
475 lines
19 KiB
Python
475 lines
19 KiB
Python
"""B07 도면 지문(508-3b) — 도면마다 입력 지문을 **그리기 없이** 싸게 센다.
|
|
|
|
지문이 같으면 같은 그림 — 화면 미리받기가 바뀐 도면만 다시 받고(횡단 장 = 측점 지문 모음),
|
|
평면도 GET 이 서버 파생 저장본(`_derived/b07_plan_<도면 id>/`)을 그대로 낸다.
|
|
⚠ 입력 하나라도 빠지면 옛 그림 — 도면 조립(`_Router_Support._read_drawing`)이 읽는 것을
|
|
고치면 여기도.
|
|
|
|
파일 도장 = 작은 파일은 **내용** 해시(B06 [저장]이 같은 내용으로 다시 쓰는 종단 · 횡단 파일) ·
|
|
큰 파일(지형 · 모델)은 시각:크기.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import hashlib
|
|
import json
|
|
import logging
|
|
from functools import cache, lru_cache
|
|
from pathlib import Path
|
|
from typing import Any
|
|
from uuid import UUID
|
|
|
|
from B05_Profile.B05_Profile_Engine_Sections import cross_station, find_cross_file
|
|
from B05_Profile.B05_Profile_Structures_Repository import structures_file_path
|
|
from B07_DesignDetail.B07_DesignDetail_Engine_Cad_Development import DEVELOPMENT_ID
|
|
from B07_DesignDetail.B07_DesignDetail_Engine_Cad_Plan import plan_chunks
|
|
from B07_DesignDetail.B07_DesignDetail_Engine_Cad_PlanKinds import LEGACY_PIECES
|
|
from B07_DesignDetail.B07_DesignDetail_Engine_Cad_Sheet import CROSS_SHEET_ID
|
|
from B07_DesignDetail.B07_DesignDetail_Engine_Template import system_template_path
|
|
from B07_DesignDetail.B07_DesignDetail_Router_Support_Io import (
|
|
_design_root,
|
|
_read_json,
|
|
_read_manifest,
|
|
)
|
|
from B07_DesignDetail.B07_DesignDetail_Router_Support_Plan import (
|
|
PLAN_KIND_ID,
|
|
WATERSHED_LAYOUT,
|
|
list_kinds,
|
|
)
|
|
from common_util.common_util_derived_store import (
|
|
derived_dir,
|
|
derived_key,
|
|
file_stamp,
|
|
prune_derived,
|
|
)
|
|
from common_util.common_util_drainage_pipes import (
|
|
detail_basins_path,
|
|
pipe_points_path_in,
|
|
route_signature,
|
|
)
|
|
from M02_MasterTemplete.M02_Template_Layers import project_dir
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
#: 판 번호 — 지문 짜임을 바꾸면 올림(옛 저장본 · 화면 기억을 한 번에 버림)
|
|
FINGERPRINT_VERSION = 1
|
|
_CONTENT_MAX = 8 * 1024 * 1024
|
|
_SLOPE_PIECES = {*LEGACY_PIECES["slope_outline"], "slope_ticks"}
|
|
_MASS_HAUL_ID = "mass_haul"
|
|
_STANDARD_ID = "cross_standard"
|
|
|
|
|
|
@lru_cache(maxsize=4096)
|
|
def _content(path_str: str, mtime_ns: int, size: int) -> str:
|
|
return hashlib.blake2b(Path(path_str).read_bytes(), digest_size=12).hexdigest()
|
|
|
|
|
|
def _canon(value: Any) -> Any:
|
|
"""값 꼴만 다른 것은 같게 — 4.0 = 4(브라우저를 거친 저장이 정수로 바꿈) · 열쇠는 글."""
|
|
if isinstance(value, float) and value.is_integer():
|
|
return int(value)
|
|
if isinstance(value, dict):
|
|
return {str(_canon(key)): _canon(item) for key, item in value.items()}
|
|
if isinstance(value, (list, tuple)):
|
|
return [_canon(item) for item in value]
|
|
return value
|
|
|
|
|
|
def _key(*parts: Any) -> str:
|
|
return derived_key(*_canon(list(parts)))
|
|
|
|
|
|
def _without_warnings(value: Any) -> Any:
|
|
"""경고 글(`warnings`)은 B07 이 안 그림 — 다시 셈 · 글 꼴만 바뀌어도 종단 지문이 안 흔들리게."""
|
|
if isinstance(value, dict):
|
|
return {k: _without_warnings(v) for k, v in value.items() if k != "warnings"}
|
|
if isinstance(value, list):
|
|
return [_without_warnings(item) for item in value]
|
|
return value
|
|
|
|
|
|
@lru_cache(maxsize=64)
|
|
def _longitudinal(path_str: str, mtime_ns: int, size: int) -> str:
|
|
data = json.loads(Path(path_str).read_text(encoding="utf-8"))
|
|
return _key(_without_warnings(data))
|
|
|
|
|
|
def longitudinal_stamp_of(path: Path) -> str | None:
|
|
"""종단 파일 지문 — 내용(경고 글 뺌 · 값 꼴 맞춤) · 없거나 깨졌으면 None."""
|
|
try:
|
|
info = path.stat()
|
|
return _longitudinal(str(path), info.st_mtime_ns, info.st_size)
|
|
except (OSError, ValueError):
|
|
return None
|
|
|
|
|
|
def stamp(path: Path | str | None) -> str | None:
|
|
"""파일 도장 — 8 MB 이하 = 내용 해시(시각 · 크기로 기억) · 넘으면 시각:크기 · 없으면 None."""
|
|
if path is None:
|
|
return None
|
|
try:
|
|
info = Path(path).stat()
|
|
except OSError:
|
|
return None
|
|
if info.st_size > _CONTENT_MAX:
|
|
return file_stamp(Path(path))
|
|
try:
|
|
return _content(str(path), info.st_mtime_ns, info.st_size)
|
|
except OSError:
|
|
return None
|
|
|
|
|
|
def tree_stamp(folder: Path, skip: tuple[str, ...] = ()) -> dict[str, str | None]:
|
|
"""폴더 아래 파일 전부 {상대 경로: 도장}(없으면 빈 dict)."""
|
|
if not folder.is_dir():
|
|
return {}
|
|
return {
|
|
path.relative_to(folder).as_posix(): stamp(path)
|
|
for path in sorted(folder.rglob("*"))
|
|
if path.is_file() and not path.name.endswith(".tmp") and path.name not in skip
|
|
}
|
|
|
|
|
|
@cache
|
|
def code_stamp() -> str:
|
|
"""B07 엔진 코드 도장 — 배포 · 고침이 옛 저장본을 그대로 내지 않게(프로세스마다 한 번)."""
|
|
folder = Path(__file__).resolve().parent
|
|
return derived_key({path.name: file_stamp(path) for path in sorted(folder.glob("*.py"))})
|
|
|
|
|
|
def _settings(project_root: Path) -> dict[str, Any]:
|
|
"""B07 설정 파일(축척 · 장 배치 · 표식 · 평면도 종류) — manifest · 그린 결과 폴더는 뺌.
|
|
|
|
평면도 종류 = 읽은 종류 목록(기본 종류를 파일로 내려도 같음) · 유역도 등고선 간격은 뺌 —
|
|
그 도면만 바뀜(`_plan_extra` 의 종류 칸).
|
|
"""
|
|
root = _design_root(project_root)
|
|
files = sorted(root.glob("*.json")) if root.is_dir() else []
|
|
return {
|
|
"files": {path.name: stamp(path) for path in files if path.name != "manifest.json"},
|
|
"plan_kinds": [
|
|
{key: value for key, value in kind.items() if key != "contour_interval_m"}
|
|
for kind in list_kinds(project_root)
|
|
],
|
|
}
|
|
|
|
|
|
def _facilities(project_root: Path) -> dict[str, str | None]:
|
|
"""시설 정본 — 구조물 · 관 지점."""
|
|
return {
|
|
"structures": stamp(structures_file_path(str(project_root))),
|
|
"pipes": stamp(pipe_points_path_in(project_root)),
|
|
}
|
|
|
|
|
|
def _plan_world(project_root: Path, context: Any, longitudinal_stamp: str | None) -> str:
|
|
"""평면도 · 유역도가 함께 기대는 노선 · 지형 · 배경 — 종류 · 장마다 같음."""
|
|
return _key(
|
|
route_signature(list(context.vertices)),
|
|
context.crs,
|
|
context.surface_params,
|
|
tree_stamp(project_root / "B03_FileInput" / "input"),
|
|
tree_stamp(project_root / "B04_PreProcess" / "processed"),
|
|
tree_stamp(project_root / "B04_PreProcess" / "models"),
|
|
tree_stamp(project_root / "B05_Profile" / "route"),
|
|
stamp(detail_basins_path(context.stored_path)),
|
|
_facilities(project_root),
|
|
longitudinal_stamp,
|
|
)
|
|
|
|
|
|
def _neighbors(chainages: list[int | float], start: float, end: float) -> list[int | float]:
|
|
"""구간 안 측점 + 앞뒤 하나(사면 선이 이웃 측점까지 읽음)."""
|
|
inside = [index for index, value in enumerate(chainages) if start <= value <= end]
|
|
if not inside:
|
|
return []
|
|
return chainages[max(inside[0] - 1, 0) : inside[-1] + 2]
|
|
|
|
|
|
def compute_fingerprints(
|
|
project_root: Path,
|
|
longitudinal_path: Path,
|
|
drawings: list[Any],
|
|
sources: dict[str, Any],
|
|
) -> dict[str, dict[str, Any]]:
|
|
"""{도면 id: {"fingerprint", ["stations"]}} — 동기 · 파일만 읽음(DB 값은 `sources`).
|
|
|
|
`sources` = 라우터가 모은 DB · 문맥 값 — title(표제란) · designs({측점: 설계}) ·
|
|
detail({측점: 종횡단 상세 칸}) · overlays({측점: 덧그림 범위}) · context(배수 문맥) ·
|
|
mass_haul · standard · sheets(횡단 장 계획) · elevations({측점: 계획고}) ·
|
|
cross_inputs(측점 → 장 입력 조각 함수).
|
|
고르지 않은 종류에 쓰는 값은 없어도 됨.
|
|
"""
|
|
manifest = _read_manifest(project_root)["drawings"]
|
|
longitudinal_stamp = longitudinal_stamp_of(longitudinal_path)
|
|
templates = {
|
|
"project": tree_stamp(project_dir(project_root)), # 도각 · 구조물 사본
|
|
"system": tree_stamp(system_template_path().parent),
|
|
}
|
|
common = _key(
|
|
FINGERPRINT_VERSION,
|
|
code_stamp(),
|
|
templates,
|
|
_settings(project_root),
|
|
sources.get("title") or {},
|
|
)
|
|
designs = sources.get("designs") or {}
|
|
out: dict[str, dict[str, Any]] = {}
|
|
sheets: dict[str, dict[str, Any]] = {
|
|
sheet["id"]: sheet for sheet in sources.get("sheets") or []
|
|
}
|
|
kinds: dict[str, dict[str, Any]] | None = None
|
|
world: str | None = None
|
|
for item in drawings:
|
|
drawing_id = item.id
|
|
entry = manifest.get(drawing_id) or {}
|
|
saved = _design_root(project_root) / "drawings" / f"{drawing_id}.json"
|
|
head = (common, entry, stamp(saved) if entry.get("confirmed") else None)
|
|
extra: Any = None
|
|
stations: dict[str, str] | None = None
|
|
if drawing_id == _MASS_HAUL_ID:
|
|
extra = (longitudinal_stamp, sources.get("mass_haul"))
|
|
elif drawing_id == _STANDARD_ID:
|
|
extra = sources.get("standard")
|
|
elif item.kind == "longitudinal":
|
|
extra = longitudinal_stamp
|
|
elif item.kind == "structure":
|
|
extra = _facilities(project_root)
|
|
elif DEVELOPMENT_ID.fullmatch(drawing_id):
|
|
extra = (longitudinal_stamp, designs, _facilities(project_root))
|
|
elif CROSS_SHEET_ID.fullmatch(drawing_id) and drawing_id in sheets:
|
|
sheet = sheets[drawing_id]
|
|
stations = {
|
|
str(chainage): _station_fingerprint(sources, chainage)
|
|
for chainage in sheet["chainages"]
|
|
}
|
|
extra = (sheet, sources.get("elevations"), stations)
|
|
elif PLAN_KIND_ID.fullmatch(drawing_id) and sources.get("context") is not None:
|
|
if kinds is None:
|
|
kinds = {kind["id"]: kind for kind in list_kinds(project_root)}
|
|
world = _plan_world(project_root, sources["context"], longitudinal_stamp)
|
|
extra = _plan_extra(project_root, longitudinal_path, drawing_id, kinds, world, designs)
|
|
out[drawing_id] = {"fingerprint": _key(*head, item.kind, extra)[:16]}
|
|
if stations is not None:
|
|
out[drawing_id]["stations"] = stations
|
|
return out
|
|
|
|
|
|
def _station_fingerprint(sources: dict[str, Any], chainage: int | float) -> str:
|
|
"""측점 하나 — 장 입력 조각(원본 · 설계 · 계획선 · 표 · 글) · 상세 칸(화면 구조물) · 덧그림."""
|
|
return _key(
|
|
sources["cross_inputs"](chainage),
|
|
(sources.get("detail") or {}).get(chainage),
|
|
(sources.get("overlays") or {}).get(chainage),
|
|
)[:16]
|
|
|
|
|
|
def _plan_extra(
|
|
project_root: Path,
|
|
longitudinal_path: Path,
|
|
drawing_id: str,
|
|
kinds: dict[str, dict[str, Any]],
|
|
world: str | None,
|
|
designs: dict[int | float, Any],
|
|
) -> Any:
|
|
"""평면도 한 장 — 종류 정의 · 장 번호 + 노선 세계 + (사면 조각이면) 그 장 측점 설계 · 원본."""
|
|
match = PLAN_KIND_ID.fullmatch(drawing_id)
|
|
kind = kinds.get(match.group(1)) if match else None
|
|
if kind is None:
|
|
return (world, None)
|
|
number = int(match.group(2)) if match.group(2) else 1
|
|
if kind["layout"] == WATERSHED_LAYOUT or not _SLOPE_PIECES & set(kind["pieces"]):
|
|
return (world, kind, number)
|
|
longitudinal = _read_json(longitudinal_path)
|
|
stations = [
|
|
(float(row["chainage_m"]), float(row.get("center_x", 0)), float(row.get("center_y", 0)))
|
|
for row in longitudinal.get("stations") or []
|
|
if isinstance(row, dict) and isinstance(row.get("chainage_m"), (int, float))
|
|
]
|
|
chunk = next(
|
|
(item for item in plan_chunks(stations, kind["scale"]) if item["number"] == number), None
|
|
)
|
|
if chunk is None:
|
|
return (world, kind, number)
|
|
chainages = sorted({cross_station(value) for value, _x, _y in stations})
|
|
near = _neighbors(chainages, float(chunk["start_m"]), float(chunk["end_m"]))
|
|
cross_dir = longitudinal_path.parent.parent / "cross_sections"
|
|
return (
|
|
world,
|
|
kind,
|
|
number,
|
|
{str(c): (designs.get(c), stamp(find_cross_file(cross_dir, c))) for c in near},
|
|
)
|
|
|
|
|
|
#: 평면도 파생 저장본 종류 접두 — `_derived/b07_plan_<도면 id>/<지문>.json`(응답 그대로 · 최근 2벌)
|
|
PLAN_DERIVED = "b07_plan_"
|
|
|
|
|
|
def read_plan_copy(project_root: Path, drawing_id: str, key: str) -> bytes | None:
|
|
"""지문이 같은 평면도 응답(JSON 글 그대로) — 없으면 None · 읽기만(지우지 않음)."""
|
|
path = derived_dir(project_root, PLAN_DERIVED + drawing_id) / f"{key}.json"
|
|
try:
|
|
body = path.read_bytes()
|
|
path.touch() # 최근에 쓴 것 = 정리 때 남김
|
|
except OSError:
|
|
return None
|
|
return body or None
|
|
|
|
|
|
def write_plan_copy(project_root: Path, drawing_id: str, key: str, body: bytes) -> None:
|
|
"""원자 쓰기 뒤 그 도면의 옛 지문을 정리(최근 2벌)."""
|
|
folder = derived_dir(project_root, PLAN_DERIVED + drawing_id)
|
|
folder.mkdir(parents=True, exist_ok=True)
|
|
temporary = folder / f".{key}.tmp"
|
|
temporary.write_bytes(body)
|
|
temporary.replace(folder / f"{key}.json")
|
|
prune_derived(project_root, PLAN_DERIVED + drawing_id)
|
|
|
|
|
|
async def plan_copy_key(
|
|
project_id: UUID,
|
|
route_id: int,
|
|
project_root: Path,
|
|
longitudinal_path: Path,
|
|
drawing_id: str,
|
|
title: dict[str, str],
|
|
context: Any,
|
|
designs: dict[int | float, Any],
|
|
) -> str | None:
|
|
"""평면도 저장본 열쇠 = 그 장 지문 — 확정 저장본(manifest 길) · 못 셈 = None(저장본 안 씀)."""
|
|
from B07_DesignDetail.B07_DesignDetail_Schema import DesignDrawingItem
|
|
|
|
if _read_manifest(project_root)["drawings"].get(drawing_id, {}).get("confirmed"):
|
|
return None
|
|
item = DesignDrawingItem(id=drawing_id, kind="plan", label=drawing_id)
|
|
try:
|
|
prints = await drawing_fingerprints(
|
|
project_id, route_id, project_root, longitudinal_path, [item], title,
|
|
context=context, designs=designs,
|
|
) # fmt: skip
|
|
except Exception:
|
|
logger.warning("B07 평면도 지문 실패 — 저장본 없이 그림: %s", drawing_id, exc_info=True)
|
|
return None
|
|
return prints[drawing_id]["fingerprint"]
|
|
|
|
|
|
async def standard_cross_source(project_id: UUID) -> Any:
|
|
"""표준 횡단면 설정(B06) — GET 과 지문이 같은 값을 씀."""
|
|
from B06_Section.B06_Section_Repository import get_project_standard_cross_section
|
|
from config.config_db import get_db_pool
|
|
|
|
pool = get_db_pool()
|
|
async with pool.acquire() as connection:
|
|
async with connection.cursor() as cursor:
|
|
await cursor.execute(
|
|
"SELECT company_id FROM projects WHERE id = %s AND deleted_at IS NULL",
|
|
(str(project_id),),
|
|
)
|
|
row = await cursor.fetchone()
|
|
if not row:
|
|
return None
|
|
return await get_project_standard_cross_section(connection, int(row[0]), project_id)
|
|
|
|
|
|
async def drawing_fingerprints(
|
|
project_id: UUID,
|
|
route_id: int,
|
|
project_root: Path,
|
|
longitudinal_path: Path,
|
|
drawings: list[Any],
|
|
title: dict[str, str],
|
|
context: Any = None,
|
|
designs: dict[int | float, Any] | None = None,
|
|
) -> dict[str, dict[str, Any]]:
|
|
"""고른 도면만 지문 — 쓰는 종류의 DB · 문맥 값만 모아 `compute_fingerprints` 로.
|
|
|
|
`context` · `designs` = 단건 GET 이 이미 읽은 값(다시 안 읽음).
|
|
"""
|
|
import asyncio
|
|
|
|
from B06_Section.B06_Section_Repository import get_longitudinal_section
|
|
from B07_DesignDetail.B07_DesignDetail_Router import _designs_by_chainage
|
|
from B07_DesignDetail.B07_DesignDetail_Router_Support import (
|
|
_cross_section_input,
|
|
_cross_sheet_plan,
|
|
load_note_sources,
|
|
)
|
|
from B07_DesignDetail.B07_DesignDetail_Router_Support_Overlay import cross_overlay_extents
|
|
from common_util.common_util_drainage_context import load_drainage_context
|
|
from config.config_db import get_db_pool
|
|
|
|
ids = {item.id for item in drawings}
|
|
has_sheet = any(CROSS_SHEET_ID.fullmatch(drawing_id) for drawing_id in ids)
|
|
has_plan = any(PLAN_KIND_ID.fullmatch(drawing_id) for drawing_id in ids)
|
|
sources: dict[str, Any] = {"title": title, "designs": designs or {}}
|
|
|
|
# DB 가 원격 — 서로 안 기다리는 것끼리 겹쳐 보냄(값은 같고 왕복만 겹침)
|
|
async def read_designs() -> None:
|
|
if designs is None and (
|
|
has_sheet or has_plan or any(DEVELOPMENT_ID.fullmatch(i) for i in ids)
|
|
):
|
|
sources["designs"] = await _designs_by_chainage(route_id)
|
|
|
|
async def read_context() -> None:
|
|
if has_plan:
|
|
sources["context"] = context or (await load_drainage_context(project_id))[0]
|
|
|
|
async def read_mass_haul() -> None:
|
|
if _MASS_HAUL_ID in ids:
|
|
pool = get_db_pool()
|
|
async with pool.acquire() as connection:
|
|
section = await get_longitudinal_section(connection, project_id, route_id)
|
|
sources["mass_haul"] = ((section or {}).get("data") or {}).get("mass_haul")
|
|
|
|
async def read_standard() -> None:
|
|
if _STANDARD_ID in ids:
|
|
sources["standard"] = await standard_cross_source(project_id)
|
|
|
|
async def read_cross() -> None:
|
|
if not has_sheet:
|
|
return
|
|
from B06_Section.B06_Section_Router import get_section_detail
|
|
|
|
response = await get_section_detail(project_id, route_id)
|
|
dump = getattr(response, "model_dump", None)
|
|
rows = dump(mode="json").get("cross_sections") or [] if dump else []
|
|
detail = {cross_station(row["chainage_m"]): row for row in rows if "chainage_m" in row}
|
|
sources["detail"] = detail
|
|
sources["overlays"] = await cross_overlay_extents(
|
|
project_id, route_id, response if dump else None
|
|
)
|
|
sources["elevations"] = {
|
|
str(chainage): (row.get("design") or {}).get("design_elevation_m")
|
|
for chainage, row in detail.items()
|
|
}
|
|
|
|
async def read_files() -> None:
|
|
if has_sheet:
|
|
sources["sheets"], sources["longitudinal"], sources["pipes"] = await asyncio.gather(
|
|
asyncio.to_thread(_cross_sheet_plan, project_root, longitudinal_path),
|
|
asyncio.to_thread(_read_json, longitudinal_path),
|
|
asyncio.to_thread(load_note_sources, project_root),
|
|
)
|
|
|
|
await asyncio.gather(
|
|
read_designs(),
|
|
read_context(),
|
|
read_mass_haul(),
|
|
read_standard(),
|
|
read_cross(),
|
|
read_files(),
|
|
)
|
|
if has_sheet:
|
|
sources["cross_inputs"] = lambda chainage: _cross_section_input(
|
|
longitudinal_path,
|
|
sources["longitudinal"],
|
|
chainage,
|
|
sources["designs"].get(chainage),
|
|
sources["overlays"].get(chainage),
|
|
sources["pipes"],
|
|
)
|
|
return await asyncio.to_thread(
|
|
compute_fingerprints, project_root, longitudinal_path, drawings, sources
|
|
)
|