"""B06 종단·횡단 원시 데이터 생성. 확정 경로 폴리라인과 표고 sampler로 CAD 인계 가능한 종단(longitudinal)·횡단 (cross) 데이터를 생성한다. BP(0m)부터 station_interval 간격 측점을 만들고 각 측점에서 경로 접선의 좌우 방향으로 횡단을 샘플링한다. """ import math from dataclasses import dataclass from typing import Any import numpy as np from B06_wf3_ProfileCross.B06_wf3_ProfileCross_Engine_Sampler import SurfaceElevationSampler from config.config_system import ( SECTION_CROSS_HALF_WIDTH_M, SECTION_CROSS_SAMPLE_INTERVAL_M, SECTION_INCLUDE_ENDPOINT, SECTION_LONG_SAMPLE_INTERVAL_M, SECTION_STATION_INTERVAL_M, ) SECTION_SCHEMA_VERSION = 1 @dataclass(frozen=True) class SectionGenerationOptions: station_interval_m: float = SECTION_STATION_INTERVAL_M cross_half_width_m: float = SECTION_CROSS_HALF_WIDTH_M cross_sample_interval_m: float = SECTION_CROSS_SAMPLE_INTERVAL_M long_sample_interval_m: float = SECTION_LONG_SAMPLE_INTERVAL_M include_endpoint: bool = SECTION_INCLUDE_ENDPOINT def validate(self) -> None: values = { "횡단 측점 간격": self.station_interval_m, "횡단 좌우 폭": self.cross_half_width_m, "횡단 샘플 간격": self.cross_sample_interval_m, "종단 샘플 간격": self.long_sample_interval_m, } for label, value in values.items(): if not math.isfinite(value) or value <= 0: raise ValueError(f"{label}은 0보다 큰 유한한 값이어야 합니다.") if self.cross_sample_interval_m > self.cross_half_width_m * 2: raise ValueError("횡단 샘플 간격이 전체 횡단 폭보다 클 수 없습니다.") def format_station(chainage_m: float) -> str: """WebCAD 도면에서도 재사용할 수 있는 STA.k+mmm.mmm 표기.""" chainage_m = max(float(chainage_m), 0.0) km = int(chainage_m // 1000.0) remainder = chainage_m - km * 1000.0 return f"STA.{km}+{remainder:07.3f}" def _clean_polyline(polyline: np.ndarray) -> tuple[np.ndarray, np.ndarray]: points = np.asarray(polyline, dtype=np.float64) if points.ndim != 2 or points.shape[1] < 2 or len(points) < 2: raise ValueError("경로는 최소 2개의 (x, y, z) 좌표로 구성되어야 합니다.") if points.shape[1] == 2: points = np.column_stack([points, np.full(len(points), np.nan)]) else: points = points[:, :3] if not np.all(np.isfinite(points[:, :2])): raise ValueError("경로 XY 좌표에 NaN 또는 Infinity가 있습니다.") distances = np.hypot(np.diff(points[:, 0]), np.diff(points[:, 1])) keep = np.r_[True, distances > 1e-8] points = points[keep] if len(points) < 2: raise ValueError("수평 길이가 있는 경로 구간이 없습니다.") distances = np.hypot(np.diff(points[:, 0]), np.diff(points[:, 1])) chainage = np.r_[0.0, np.cumsum(distances)] return points, chainage def _chainages(total: float, interval: float, include_endpoint: bool) -> np.ndarray: values = np.arange(0.0, total + 1e-9, interval, dtype=np.float64) if not len(values) or abs(values[0]) > 1e-9: values = np.r_[0.0, values] if include_endpoint and total - values[-1] > 1e-6: values = np.r_[values, total] return values def _interpolate_xy(points: np.ndarray, chainage: np.ndarray, targets: np.ndarray) -> np.ndarray: return np.column_stack( [ np.interp(targets, chainage, points[:, 0]), np.interp(targets, chainage, points[:, 1]), ] ) def _tangent_at( points: np.ndarray, chainage: np.ndarray, target: float, probe: float ) -> np.ndarray: total = float(chainage[-1]) before = max(0.0, target - probe) after = min(total, target + probe) if after - before <= 1e-9: before = max(0.0, target - 1e-3) after = min(total, target + 1e-3) pair = _interpolate_xy(points, chainage, np.array([before, after])) vector = pair[1] - pair[0] length = float(np.hypot(vector[0], vector[1])) if length <= 1e-9: raise ValueError(f"측점 {target:.3f}m에서 경로 접선 방향을 계산할 수 없습니다.") return vector / length def _float_or_none(value: float) -> float | None: return round(float(value), 6) if math.isfinite(float(value)) else None def generate_sections( polyline: np.ndarray | list[list[float]], sampler: SurfaceElevationSampler, options: SectionGenerationOptions | None = None, *, source_snapshot: dict[str, Any] | None = None, crs: str | None = None, ) -> dict[str, Any]: """확정 경로로 CAD 인계 가능한 종단·횡단 원시 데이터를 생성한다.""" options = options or SectionGenerationOptions() options.validate() points, route_chainage = _clean_polyline(np.asarray(polyline, dtype=np.float64)) total = float(route_chainage[-1]) long_chainage = _chainages(total, options.long_sample_interval_m, True) long_xy = _interpolate_xy(points, route_chainage, long_chainage) long_z, long_valid = sampler.sample_xy(long_xy) station_chainage = _chainages(total, options.station_interval_m, options.include_endpoint) station_xy = _interpolate_xy(points, route_chainage, station_chainage) tangents = np.vstack( [ _tangent_at( points, route_chainage, float(value), max(options.long_sample_interval_m, 0.5) ) for value in station_chainage ] ) left_axes = np.column_stack([-tangents[:, 1], tangents[:, 0]]) offsets = np.arange( -options.cross_half_width_m, options.cross_half_width_m + options.cross_sample_interval_m * 0.5, options.cross_sample_interval_m, dtype=np.float64, ) offsets = offsets[offsets <= options.cross_half_width_m + 1e-9] if not np.any(np.isclose(offsets, 0.0, atol=1e-9)): offsets = np.sort(np.r_[offsets, 0.0]) all_cross_xy = ( station_xy[:, None, :] + left_axes[:, None, :] * offsets[None, :, None] ).reshape(-1, 2) all_cross_z, all_cross_valid = sampler.sample_xy(all_cross_xy) all_cross_z = all_cross_z.reshape(len(station_chainage), len(offsets)) all_cross_valid = all_cross_valid.reshape(len(station_chainage), len(offsets)) stations: list[dict[str, Any]] = [] cross_sections: list[dict[str, Any]] = [] for index, value in enumerate(station_chainage): station_id = f"station_{int(round(float(value) * 1000)):012d}" kind = "bp" if index == 0 else "ep" if abs(float(value) - total) <= 1e-6 else "regular" center_index = int(np.argmin(np.abs(offsets))) center_z = all_cross_z[index, center_index] tangent = tangents[index] left = left_axes[index] azimuth = (math.degrees(math.atan2(tangent[0], tangent[1])) + 360.0) % 360.0 frame = { "origin": { "x": round(float(station_xy[index, 0]), 6), "y": round(float(station_xy[index, 1]), 6), "z": _float_or_none(center_z), }, "tangent_xy": [round(float(tangent[0]), 9), round(float(tangent[1]), 9)], "left_xy": [round(float(left[0]), 9), round(float(left[1]), 9)], "up_xyz": [0.0, 0.0, 1.0], } station = { "station_id": station_id, "chainage_m": round(float(value), 6), "label": format_station(float(value)), "kind": kind, "center_x": round(float(station_xy[index, 0]), 6), "center_y": round(float(station_xy[index, 1]), 6), "center_z": _float_or_none(center_z), "azimuth_deg": round(azimuth, 6), "frame": frame, } stations.append(station) samples = [] cross_xy_view = all_cross_xy.reshape(len(station_chainage), len(offsets), 2) for offset_index, offset in enumerate(offsets): valid = bool(all_cross_valid[index, offset_index]) xy = cross_xy_view[index, offset_index] samples.append( { "offset_m": round(float(offset), 6), "x": round(float(xy[0]), 6), "y": round(float(xy[1]), 6), "z": _float_or_none(all_cross_z[index, offset_index]) if valid else None, "elevation_m": _float_or_none(all_cross_z[index, offset_index]) if valid else None, "valid": valid, } ) cross_sections.append({**station, "samples": samples}) longitudinal_samples = [ { "chainage_m": round(float(chainage), 6), "x": round(float(xy[0]), 6), "y": round(float(xy[1]), 6), "z": _float_or_none(z) if valid else None, "elevation_m": _float_or_none(z) if valid else None, "valid": bool(valid), } for chainage, xy, z, valid in zip(long_chainage, long_xy, long_z, long_valid) ] finite_z = np.asarray( [sample["z"] for sample in longitudinal_samples if sample["z"] is not None] ) datum = math.floor(float(finite_z.min()) / 10.0) * 10.0 if finite_z.size else None return { "schema_version": SECTION_SCHEMA_VERSION, "status": "completed", "source": source_snapshot or {}, "coordinate_reference": { "crs": crs, "world_axes": {"x": "project_easting", "y": "project_northing", "z": "elevation"}, "units": {"horizontal": "m", "vertical": "m", "angle": "degree"}, }, "cad_exchange": { "station_origin": "BP", "chainage_direction": "BP_to_EP", "cross_offset_sign": {"negative": "right", "positive": "left"}, "cross_local_axes": {"x": "offset_m", "y": "elevation_m"}, "recommended_drawing_datum_m": datum, }, "options": { "station_interval_m": options.station_interval_m, "cross_half_width_m": options.cross_half_width_m, "cross_sample_interval_m": options.cross_sample_interval_m, "long_sample_interval_m": options.long_sample_interval_m, "include_endpoint": options.include_endpoint, }, "longitudinal": { "length_m": round(total, 6), "samples": longitudinal_samples, "stations": stations, }, "cross_sections": cross_sections, "summary": { "station_count": len(stations), "cross_sample_count": int(len(stations) * len(offsets)), "invalid_longitudinal_samples": int((~long_valid).sum()), "invalid_cross_samples": int((~all_cross_valid).sum()), }, }