"""D04 예정노선 내보내기 — 조각(구역 × 갈래)마다 · 옛 프로젝트는 연도마다 노선(꺾은선)을 용화 예정노선 양식 shapefile 한 벌로 만든다. 양식 = `resources/knowledge/original/실무문서/2026년 임도 용화/예정노선/대상지_일월.용화.2.2.*` (분석 345-C): 2D 폴리라인 1레코드 · UTM-K(EPSG 5179) · cp949 · 칸 `대상지` C(20) · `길이` N(11,3) km + 덧칸 `연도` N(4) · `시점누가` · `종점누가` N(11,2) m. `.prj` 는 용화 원문 그대로 · `.cpg` 는 `949`. 계산 자리 ③ 서버 단독 — 화면이 만지지 않는다. 순수 함수(값 → 바이트)만 두고 파일은 임시 폴더에서만 쓴다. 짝: `station_label` ↔ `common_util/common_util_station.ts` `stationLabel(…, 1)` (거울 시험 `test_545_1_station_label_mirror.py`). """ from __future__ import annotations import io import math import re import tempfile import zipfile from dataclasses import dataclass, field from pathlib import Path import geopandas as gpd from openpyxl import Workbook from pyproj import Transformer from shapely.geometry import LineString EXPORT_EPSG = 5179 SHP_SUFFIXES = (".shp", ".shx", ".dbf", ".prj", ".cpg") MIME_ZIP = "application/zip" MIME_XLSX = "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet" # 용화 `.prj` 원문(ESRI WKT) 그대로 — 양식 일치(시험이 용화 파일과 바이트 비교). _YONGHWA_PRJ = ( 'PROJCS["PCS_ITRF2000_UTM_K",GEOGCS["GCS_ITRF_2000",DATUM["D_ITRF_2000",' 'SPHEROID["GRS_1980",6378137.0,298.257222101]],PRIMEM["Greenwich",0.0],' 'UNIT["Degree",0.0174532925199433]],PROJECTION["Transverse_Mercator"],' 'PARAMETER["False_Easting",1000000.0],PARAMETER["False_Northing",2000000.0],' 'PARAMETER["Central_Meridian",127.5],PARAMETER["Scale_Factor",0.9996],' 'PARAMETER["Latitude_Of_Origin",38.0],UNIT["METER",1.0]]' ) _SCHEMA = { "geometry": "LineString", "properties": { "대상지": "str:20", "길이": "float:11.3", "연도": "int:4", "시점누가": "float:11.2", "종점누가": "float:11.2", }, } _BAD_FILENAME = re.compile(r'[\\/:*?"<>|\r\n\t]') STATION_INTERVAL_M = 20.0 # D03 `PLAN_STATION_INTERVAL_M` 와 같음 def station_label(chainage_m: float, interval_m: float = STATION_INTERVAL_M) -> str: """측점 표기 `61+14.5` — TS `stationLabel(chainage, interval, 1)` 짝(소수 한 자리 · 반올림 위로).""" safe = interval_m if interval_m > 0 else 1.0 number = math.floor((chainage_m + 1e-6) / safe) rest = chainage_m - number * safe if abs(rest) < 0.05: rest = 0.0 if rest >= safe - 0.05: number, rest = number + 1, 0.0 tenth = math.floor(rest * 10 + 0.5) / 10 return f"{number}+{int(tenth) if tenth == int(tenth) else tenth}" @dataclass class YearRoute: """한 조각(또는 옛 연도)의 노선 — `points` 는 `crs_epsg` 좌표의 (x, y) 꺾은선(시점 → 종점). 누가거리 두 값이 있으면 그대로 칸에 쓰고(조각 = 선마다 No.0), 없으면 연도 순서로 이어 셈. `piece` = 조각 id(옛 연도 노선은 None) · `line` = 선 이름(파일 이름 · 전체 묶음 열쇠). """ year: int points: list[tuple[float, float]] crs_epsg: int start_station_m: float | None = None end_station_m: float | None = None piece: str | None = None line: str | None = None @property def key(self) -> str | int: return self.piece or self.year @dataclass class ExportBundle: """`by_year[열쇠]` = 그 조각(열쇠 = 조각 id · 옛은 연도) shp 한 벌 + 좌표표 · `all` = 선마다 이은 레코드 한 벌 + 좌표표.""" by_year: dict[int, dict[str, bytes]] = field(default_factory=dict) all: dict[str, bytes] = field(default_factory=dict) lengths_m: dict[int, float] = field(default_factory=dict) def safe_name(text: str) -> str: """파일명에 못 쓰는 글자를 `_` 로.""" return _BAD_FILENAME.sub("_", text).strip() or "_" def polyline_length_m(points: list[tuple[float, float]]) -> float: return sum(math.dist(a, b) for a, b in zip(points, points[1:], strict=False)) def to_utmk(points: list[tuple[float, float]], crs_epsg: int) -> list[tuple[float, float]]: """프로젝트 좌표 → UTM-K(5179). 같은 좌표계면 그대로.""" pts = [(float(p[0]), float(p[1])) for p in points] if crs_epsg == EXPORT_EPSG: return pts tf = Transformer.from_crs(crs_epsg, EXPORT_EPSG, always_xy=True) return [tf.transform(x, y) for x, y in pts] def _clip_bytes(text: str, limit: int = 20) -> str: """dbf 문자 칸(cp949 바이트) 한도에 글자 단위로 맞춤.""" while len(text.encode("cp949", errors="replace")) > limit: text = text[:-1] return text def _target_text(region: str, length_m: float) -> str: return f"{region}.{length_m / 1000:.1f}" Record = tuple[list[tuple[float, float]], int | None, tuple[float, float]] def _write_shapefile(records: list[Record], region: str) -> dict[str, bytes]: """5179 꺾은선 레코드(점 · 연도 · 누가 두 값) → `.shp/.shx/.dbf/.prj/.cpg` 바이트 (파일명은 호출부가 붙임: 키 = 확장자).""" lengths = [polyline_length_m(points) for points, _, _ in records] frame = gpd.GeoDataFrame( { "대상지": [_clip_bytes(_target_text(region, m)) for m in lengths], "길이": [round(m / 1000, 3) for m in lengths], "연도": [year for _, year, _ in records], "시점누가": [round(stations[0], 2) for _, _, stations in records], "종점누가": [round(stations[1], 2) for _, _, stations in records], }, geometry=[LineString(points) for points, _, _ in records], crs=EXPORT_EPSG, ) with tempfile.TemporaryDirectory() as tmp: out = Path(tmp) / "r.shp" frame.to_file( out, driver="ESRI Shapefile", encoding="cp949", engine="fiona", schema=_SCHEMA ) out.with_suffix(".prj").write_text(_YONGHWA_PRJ, encoding="ascii") out.with_suffix(".cpg").write_text("949", encoding="ascii") return {suffix: out.with_suffix(suffix).read_bytes() for suffix in SHP_SUFFIXES} def _coordinate_xlsx(project_name: str, rows: list[dict]) -> bytes: """사람이 읽는 좌표표 — 연도별 시점 · 종점(UTM-K · 위경도) · 측점 · 누가 · 연장.""" to_ll = Transformer.from_crs(EXPORT_EPSG, 4326, always_xy=True) book = Workbook() sheet = book.active sheet.title = "좌표표" sheet.append([f"{project_name} 예정노선 좌표표 (UTM-K EPSG:5179)"]) sheet.append( [ "연도", "시점 X", "시점 Y", "종점 X", "종점 Y", "시점 경도", "시점 위도", "종점 경도", "종점 위도", "시점 측점", "종점 측점", "시점누가(m)", "종점누가(m)", "연장(m)", "선", "조각", ] ) for row in rows: (sx, sy), (ex, ey) = row["points"][0], row["points"][-1] (slon, slat), (elon, elat) = to_ll.transform(sx, sy), to_ll.transform(ex, ey) sheet.append( [ row["year"], round(sx, 3), round(sy, 3), round(ex, 3), round(ey, 3), round(slon, 7), round(slat, 7), round(elon, 7), round(elat, 7), station_label(row["start_station"]), station_label(row["end_station"]), round(row["start_station"], 2), round(row["end_station"], 2), round(row["length_m"], 2), row["line"], row["piece"], ] ) buffer = io.BytesIO() book.save(buffer) return buffer.getvalue() def _named(base: str, parts: dict[str, bytes]) -> dict[str, bytes]: return {f"{base}{suffix}": data for suffix, data in parts.items()} def build_export( project_name: str, region: str, years: list[YearRoute], first_station_m: float = 0.0, with_xlsx: bool = True, ) -> ExportBundle: """조각(옛 = 연도) 노선들 → 조각마다 한 벌 + 전체 한 벌(+ 좌표표). 누가거리 = 값이 없으면 `first_station_m` 에서 시작해 연도 순서로 이어 셈(직전 연도 종점 = 다음 연도 시점). shp 안 측점은 칸으로만 보관(B03 은 칸을 무시). """ ordered = sorted(years, key=lambda y: y.year) rows: list[dict] = [] station = float(first_station_m) for item in ordered: points = to_utmk(item.points, item.crs_epsg) if len(points) < 2: raise ValueError(f"{item.year}년 노선 점이 2개 미만입니다.") length_m = polyline_length_m(points) if item.start_station_m is not None and item.end_station_m is not None: start, end = float(item.start_station_m), float(item.end_station_m) else: start, end = station, station + length_m rows.append( { "year": item.year, "points": points, "length_m": length_m, "start_station": start, "end_station": end, "key": item.key, "piece": item.piece, "line": item.line, } ) station = end bundle = ExportBundle() region_name = safe_name(region) xlsx_name = f"좌표표_{safe_name(project_name)}.xlsx" for row in rows: base = f"대상지_{region_name}.{row['length_m'] / 1000:.1f}_{row['year']}" if row["piece"]: base += f"_{safe_name(row['line'] or '')}_{safe_name(row['piece'])}" stations = (row["start_station"], row["end_station"]) files = _named(base, _write_shapefile([(row["points"], row["year"], stations)], region)) if with_xlsx: files[xlsx_name] = _coordinate_xlsx(project_name, [row]) bundle.by_year[row["key"]] = files bundle.lengths_m[row["key"]] = row["length_m"] # 전체: 선마다 한 레코드 · 앞 조각 종점 = 다음 조각 시점이라 겹치는 점 하나는 뺌. by_line: dict[str | None, list[dict]] = {} for row in rows: by_line.setdefault(row["line"], []).append(row) records: list[Record] = [] for parts in by_line.values(): parts.sort(key=lambda r: r["start_station"]) merged: list[tuple[float, float]] = [] for row in parts: same = merged and math.dist(merged[-1], row["points"][0]) < 1e-6 merged.extend(row["points"][1:] if same else row["points"]) records.append((merged, None, (parts[0]["start_station"], parts[-1]["end_station"]))) total_m = sum(polyline_length_m(points) for points, _, _ in records) bundle.lengths_m[0] = total_m base = f"대상지_{region_name}.{total_m / 1000:.1f}_전체" bundle.all = _named(base, _write_shapefile(records, region)) if with_xlsx: bundle.all[xlsx_name] = _coordinate_xlsx(project_name, rows) return bundle def company_zip(project_name: str, label: str | int, files: dict[str, bytes]) -> tuple[str, bytes]: """업체에 보낼 `<프로젝트명>_예정노선_<연도 | 연도_선_조각>.zip` (이름, 바이트).""" buffer = io.BytesIO() with zipfile.ZipFile(buffer, "w", zipfile.ZIP_DEFLATED) as archive: for name, data in files.items(): archive.writestr(name, data) return f"{safe_name(project_name)}_예정노선_{safe_name(str(label))}.zip", buffer.getvalue()