diff --git a/B05_Profile/B05_Profile_Engine_Grade_Profile.py b/B05_Profile/B05_Profile_Engine_Grade_Profile.py index a29c9e84..887f5caf 100644 --- a/B05_Profile/B05_Profile_Engine_Grade_Profile.py +++ b/B05_Profile/B05_Profile_Engine_Grade_Profile.py @@ -119,6 +119,20 @@ def _profile_entry( } +def _clearance_at( + clearances: dict[float, float], chainage_m: float, tolerance: float = 0.5 +) -> float: + """앵커 누가거리에 대응하는 최소 여유(m). 근처에 시설이 없으면 0.""" + best = 0.0 + closest = tolerance + for key, value in clearances.items(): + gap = abs(key - chainage_m) + if gap <= closest: + closest = gap + best = value + return best + + def design_pipe_anchored_profile( longitudinal: dict[str, Any], options: GradeDesignOptions, @@ -126,6 +140,7 @@ def design_pipe_anchored_profile( *, station_interval_m: float | None = None, edits: dict[str, Any] | None = None, + pipe_clearances: dict[float, float] | None = None, ) -> tuple[dict[str, Any], dict[str, Any]]: """배관 배치 측점을 변화점으로 삼는 1차 계획선. @@ -182,8 +197,16 @@ def design_pipe_anchored_profile( raise ValueError("계획선 변화점으로 쓸 배관 배치 측점이 없습니다.") base_s = np.array([0.0, *anchors, total], dtype=np.float64) - # 목표: 배관 자리 계획고 = 지반고(지면선 교차점이 호 위). 시·종점만 오프셋을 얹는다. + # 목표: 배관 자리 계획고 = 지반고 + **시설 최소 여유**. 지반고에 딱 맞추면 관·구체가 + # 들어갈 자리가 없다(2026-08-23 사용자 지시). 여유는 관경+토피/구체높이+토피/월류 + # 높이로, 정본 산식은 `common_util_drainage_pipes.facility_clearance_m`이다. + # 시·종점만 오프셋을 얹는다. target = np.interp(base_s, chainage, ground) + if pipe_clearances: + for index, value in enumerate(base_s): + clearance = _clearance_at(pipe_clearances, float(value)) + if clearance > 0: + target[index] += clearance target[0] = fixed[0] target[-1] = fixed[1] diff --git a/B05_Profile/B05_Profile_Engine_Sections.py b/B05_Profile/B05_Profile_Engine_Sections.py index 7ed77a3f..fb52205b 100644 --- a/B05_Profile/B05_Profile_Engine_Sections.py +++ b/B05_Profile/B05_Profile_Engine_Sections.py @@ -20,7 +20,11 @@ from B05_Profile.B05_Profile_Engine_Sections_Core import ( SectionGenerationOptions, generate_sections, ) -from common_util.common_util_drainage_pipes import parse_pipe_points, route_signature +from common_util.common_util_drainage_pipes import ( + parse_pipe_points, + pipe_anchor_clearances, + route_signature, +) from common_util.common_util_json import atomic_write_json from common_util.common_util_route_geometry import RouteVertex from common_util.common_util_surface_sampler import build_surface_sampler @@ -89,7 +93,9 @@ def _cross_summary(cross_section: dict[str, Any]) -> dict[str, Any]: } -def _load_pipe_chainages(project_root: Path, polyline: list[list[float]]) -> list[float]: +def _load_pipe_anchors( + project_root: Path, polyline: list[list[float]] +) -> tuple[list[float], dict[float, float]]: """배수유역도가 확정한 배관 배치 측점(누가거리)을 읽는다. 관 지점 파일에는 저장 당시 노선 지문이 함께 있다 — 노선이 바뀌었으면 버린다 @@ -103,12 +109,12 @@ def _load_pipe_chainages(project_root: Path, polyline: list[list[float]]) -> lis / DRAINAGE_PIPE_POINTS_FILENAME ) if not path.is_file(): - return [] + return [], {} try: document = json.loads(path.read_text(encoding="utf-8")) except (OSError, json.JSONDecodeError): logger.warning("B05 계획선: 관 지점 파일을 읽지 못했습니다 (%s)", path) - return [] + return [], {} vertices = [ RouteVertex( x=float(p[0]), y=float(p[1]), z=float(p[2]) if len(p) > 2 else 0.0, chainage_m=0.0 @@ -117,8 +123,12 @@ def _load_pipe_chainages(project_root: Path, polyline: list[list[float]]) -> lis ] if str(document.get("route_signature") or "") != route_signature(vertices): logger.info("B05 계획선: 노선이 바뀌어 저장된 관 지점을 쓰지 않습니다.") - return [] - return [pipe.chainage_m for pipe in parse_pipe_points(document.get("points"))] + return [], {} + points = parse_pipe_points(document.get("points")) + # 시설 제원이 요구하는 최소 여유(관경+토피 등)를 함께 넘긴다 — 계획선이 그만큼 + # 들려야 관·구체가 들어갈 자리가 생긴다(2026-08-23 사용자 지시). + clearances = {chainage: clearance for chainage, clearance in pipe_anchor_clearances(points)} + return [pipe.chainage_m for pipe in points], clearances def _append_design_profiles( @@ -126,6 +136,7 @@ def _append_design_profiles( grade_options: GradeDesignOptions | None, station_interval_m: float | None = None, pipe_chainages: list[float] | None = None, + pipe_clearances: dict[float, float] | None = None, ) -> dict[str, Any] | None: """종단 계획선을 산출해 longitudinal에 붙이고 요약을 반환한다. @@ -147,6 +158,7 @@ def _append_design_profiles( grade_options, pipe_chainages, station_interval_m=station_interval_m, + pipe_clearances=pipe_clearances, ) except (ValueError, KeyError, ArithmeticError): logger.exception("B05 배관 정착 계획선 산출 실패 — 직선 분할 선형으로 대체") @@ -261,12 +273,14 @@ def run_section_generation( cross_dir.mkdir(parents=True, exist_ok=True) # 종단면 저장 (계획선은 저장 직전에 종단 데이터에 붙인다) + pipe_anchors = _load_pipe_anchors(project_root, polyline) grade_summary = _append_design_profiles( result["longitudinal"], grade_options, (result.get("options") or {}).get("station_interval_m"), # 1차 계획선의 변화점 = 배수유역도가 확정한 배관 배치 측점. - pipe_chainages=_load_pipe_chainages(project_root, polyline), + pipe_chainages=pipe_anchors[0], + pipe_clearances=pipe_anchors[1], ) # 계획선 경사 기반 포장 제안(법정 상한 초과 측점) — 비치명적. try: diff --git a/B05_Profile/B05_Profile_UI_Profile_MinCover.ts b/B05_Profile/B05_Profile_UI_Profile_MinCover.ts index 7bad1c15..09037e96 100644 --- a/B05_Profile/B05_Profile_UI_Profile_MinCover.ts +++ b/B05_Profile/B05_Profile_UI_Profile_MinCover.ts @@ -10,8 +10,12 @@ * 산식(사용자 확정 예시 그대로): * 배수관 Ø1000 → 지반고 + 1.0(관경) + 0.5(토피) = +1.5 * BOX암거 2.0×2.0 → 지반고 + 2.0(구체 높이) + 0.5(토피) = +2.5 + * 물넘이·세월교 → 지반고 + 월류 높이(좌측 패널이 설계유량으로 산출한 `ford_height_m`) * 토피 0.5m는 B06 배수관 엔진의 `MIN_PIPE_COVER_M`과 같은 값이다(교차 확인). - * 세월교·물넘이포장은 월류 구조라 최소 토피 개념이 다르다 — 이번 범위에서 뺀다. + * + * **정본 산식은 백엔드** `common_util_drainage_pipes.facility_clearance_m`이다 — + * 계획선 자동 생성이 그 값으로 변화점을 들어 올린다. 여기 있는 것은 편집 중 즉시 + * 경고하기 위한 같은 산식의 화면 사본이며, 상수 일치는 테스트로 잠가 둔다. * ========================================================================== */ import type { PipeFacility } from "../B04_PreProcess/B04_PreProcess_Api_Fetch"; @@ -29,6 +33,7 @@ export const MIN_COVER_M = 0.5; /** 시설 제원이 비었을 때 쓰는 기본값 — 화면 기본 선택과 맞춘다. */ const DEFAULT_PIPE_DIAMETER_MM = 1000; const DEFAULT_BOX_HEIGHT_M = 2; +const DEFAULT_FORD_HEIGHT_M = 0.3; export interface MinCoverPoint { chainage_m: number; @@ -52,20 +57,28 @@ export function minCoverPoints(pipes: MinCoverPipe[]): MinCoverPoint[] { for (const pipe of pipes) { const facility = pipe.facility ?? "pipe"; const options = pipe.options ?? {}; - if (facility === "pipe") { - const diameterM = numberOf(options.pipe_diameter_mm, DEFAULT_PIPE_DIAMETER_MM) / 1000; - result.push({ - chainage_m: pipe.chainage_m, - clearance_m: diameterM + MIN_COVER_M, - label: `배수관 Ø${Math.round(diameterM * 1000)}`, - }); - } else if (facility === "box_culvert") { + if (facility === "box_culvert") { const heightM = numberOf(options.body_height_m, DEFAULT_BOX_HEIGHT_M); result.push({ chainage_m: pipe.chainage_m, clearance_m: heightM + MIN_COVER_M, label: `BOX암거 H${heightM.toFixed(1)}`, }); + } else if (facility === "ford_pavement" || facility === "ford_bridge") { + // 월류 구조 — 토피가 아니라 월류 높이만큼 노면이 올라간다(좌측 패널 산출값). + const heightM = numberOf(options.ford_height_m, DEFAULT_FORD_HEIGHT_M); + result.push({ + chainage_m: pipe.chainage_m, + clearance_m: heightM, + label: `${facility === "ford_bridge" ? "세월교" : "물넘이포장"} 월류 ${heightM.toFixed(2)}m`, + }); + } else { + const diameterM = numberOf(options.pipe_diameter_mm, DEFAULT_PIPE_DIAMETER_MM) / 1000; + result.push({ + chainage_m: pipe.chainage_m, + clearance_m: diameterM + MIN_COVER_M, + label: `배수관 Ø${Math.round(diameterM * 1000)}`, + }); } } return result.sort((a, b) => a.chainage_m - b.chainage_m); diff --git a/common_util/common_util_drainage_pipes.py b/common_util/common_util_drainage_pipes.py index 2ceb6c41..e83f882c 100644 --- a/common_util/common_util_drainage_pipes.py +++ b/common_util/common_util_drainage_pipes.py @@ -266,3 +266,44 @@ def save_detail_basins(stored_path: str, features: list[dict[str, Any]]) -> Path atomic_write_json(path, {"type": "FeatureCollection", "features": features}) logger.info("배수유역: 세부유역 %d개를 저장했습니다 (%s).", len(features), path.name) return path + + +# ── 횡단배수 최소 계획고 (2026-08-23 사용자 확정) ───────────────────────────── +# 계획 종단선의 변화점(PVI)은 배수 시설 자리다. 그 자리에서 계획고를 지반고와 같게 +# 두면 시설이 들어갈 자리가 없다 — 시설 제원만큼 계획고를 들어 올려야 한다. +# 배수관 Ø1000 → 지반고 + 1.0(관경) + 0.5(토피) = +1.5 +# BOX암거 2×2 → 지반고 + 2.0(구체 높이) + 0.5(토피) = +2.5 +# 물넘이·세월교 → 지반고 + 월류 높이(좌측 패널이 설계유량으로 산출한 값) +# 토피 0.5m는 B06 배수관 엔진(`MIN_PIPE_COVER_M`)과 같은 값이다. +MIN_PIPE_COVER_M = 0.5 +DEFAULT_PIPE_DIAMETER_MM = 1000.0 +DEFAULT_BOX_HEIGHT_M = 2.0 +DEFAULT_FORD_HEIGHT_M = 0.3 + + +def _positive(value: Any, fallback: float) -> float: + try: + parsed = float(value) + except (TypeError, ValueError): + return fallback + return parsed if parsed > 0 else fallback + + +def facility_clearance_m(facility: str, options: dict[str, Any] | None) -> float: + """시설이 요구하는 지반고 대비 최소 여유(m). 계획선·경고가 같이 쓰는 정본 산식.""" + values = options or {} + if facility == PIPE_FACILITY_BOX: + return _positive(values.get("body_height_m"), DEFAULT_BOX_HEIGHT_M) + MIN_PIPE_COVER_M + if facility in (PIPE_FACILITY_FORD_PAVEMENT, PIPE_FACILITY_FORD_BRIDGE): + # 월류 구조라 토피가 아니라 월류 높이만큼 노면이 올라간다. + return _positive(values.get("ford_height_m"), DEFAULT_FORD_HEIGHT_M) + diameter_m = _positive(values.get("pipe_diameter_mm"), DEFAULT_PIPE_DIAMETER_MM) / 1000.0 + return diameter_m + MIN_PIPE_COVER_M + + +def pipe_anchor_clearances(points: list[PipePoint]) -> list[tuple[float, float]]: + """계획선 변화점으로 쓸 (누가거리, 최소 여유) 목록. 누가거리 오름차순.""" + return sorted( + (float(point.chainage_m), facility_clearance_m(point.facility, point.options)) + for point in points + )