diff --git a/B05_Profile/B05_Profile_Router_Confirm.py b/B05_Profile/B05_Profile_Router_Confirm.py index d324be50..14539e1f 100644 --- a/B05_Profile/B05_Profile_Router_Confirm.py +++ b/B05_Profile/B05_Profile_Router_Confirm.py @@ -96,6 +96,7 @@ async def sync_uphill_overrides_into_designs( update_cross_section_design, ) from B06_Section.B06_Section_Router import _read_cross_design_inputs + from B06_Section.B06_Section_Router_Design import ford_drop_at, ford_surface_drops if not overrides: return @@ -108,6 +109,7 @@ async def sync_uphill_overrides_into_designs( options = longitudinal["data"].get("options") if isinstance(options, dict): stored_standard = options.get("standard_cross_section") + ford_drops = ford_surface_drops(Path(project_root)) for record in designs: chainage = round(float(record["chainage_m"]), 3) side = by_chainage.get(chainage) @@ -136,6 +138,7 @@ async def sync_uphill_overrides_into_designs( rock_boundary_offset_m=design.get("rock_boundary_offset_m"), two_stage_slope=bool(design.get("two_stage_slope", True)), ditch_enabled=design.get("ditch_enabled"), + surface_drop_m=ford_drop_at(float(chainage), ford_drops), ) next_design["status"] = design.get("status", "provisional") next_design["pavement_suggested"] = design.get("pavement_suggested", pavement_suggested) diff --git a/B06_Section/B06_Section_Api_Fetch.ts b/B06_Section/B06_Section_Api_Fetch.ts index c88ce07d..3ce92495 100644 --- a/B06_Section/B06_Section_Api_Fetch.ts +++ b/B06_Section/B06_Section_Api_Fetch.ts @@ -252,6 +252,9 @@ export interface FordSet { pipe_count: number; /** 구체의 도로 진행 방향 길이(m) = 월류 폭. 기준 측점 전후로 절반씩 걸친다. */ span_m: number; + /** 월류 높이(m) — 구체 위 노면이 이만큼 낮게 앉는다. 계획고를 통째로 내려 잡으므로 + * 측벽·바닥판·절성토 면적이 함께 따라간다(2026-08-30 사용자 확정). 0 = 내리지 않음. */ + overflow_depth_m: number; slab_thickness_m: number; wall_thickness_m: number; min_cover_m: number; @@ -370,6 +373,10 @@ export interface CrossDesign { lateralM: number; slopeM: number; }; + /** 세월교 월류 높이만큼 계획고를 내려 잡은 양(m) — 이 값이 있으면 `design_elevation_m`· + * `design_line`·절성토 면적이 **이미 내려간 값**이다. 프론트는 이 값으로 "월류가 + * 없었다면" 노면을 점선으로 되그린다(2026-08-30 사용자 확정). */ + surface_drop_m?: number; /** 세월교 측벽 조작값(유입·유출) — 높이·좌우·상하(2026-08-25). */ ford_adjust?: StoredFordAdjust; /** BOX암거 구체 조작값(좌·우 끝) — 길이·표고(2026-08-25). */ diff --git a/B06_Section/B06_Section_Engine_Culvert.py b/B06_Section/B06_Section_Engine_Culvert.py index acb8f872..43c39bca 100644 --- a/B06_Section/B06_Section_Engine_Culvert.py +++ b/B06_Section/B06_Section_Engine_Culvert.py @@ -310,12 +310,17 @@ def _ford_set(options: dict[str, Any] | None) -> dict[str, Any]: kind = values.get("pipe_kind") or defaults.get("pipe_kind") width = _number(values.get("ford_width_m"), _number(defaults.get("ford_width_m"), None)) count = _number(values.get("pipe_count"), _number(defaults.get("pipe_count"), None)) + depth = _number(values.get("ford_height_m"), None) return { "type": "ford", "pipe_kind": str(kind) if kind else None, "diameter_m": round((diameter_mm or 1000.0) / 1000.0, 3), "pipe_count": max(int(count), 1) if count else 1, "span_m": width if width and width > 0 else FORD_DEFAULT_WIDTH_M, + # 월류 높이 — 구체 위 노면은 이만큼 낮게 앉는다(단면은 월류부 가장 아래를 자른 + # 자리다). 계획고를 통째로 내려 측벽·바닥판·절성토 면적이 함께 따라간다 + # (2026-08-30 사용자 확정). 값이 없으면 0 = 내리지 않는다. + "overflow_depth_m": depth if depth and depth > 0 else 0.0, "slab_thickness_m": FORD_SLAB_THICKNESS_M, "wall_thickness_m": FORD_WALL_THICKNESS_M, "min_cover_m": MIN_PIPE_COVER_M, diff --git a/B06_Section/B06_Section_Engine_Design.py b/B06_Section/B06_Section_Engine_Design.py index 1dca68b6..669e1eb1 100644 --- a/B06_Section/B06_Section_Engine_Design.py +++ b/B06_Section/B06_Section_Engine_Design.py @@ -445,6 +445,7 @@ def compute_cross_design( rock_boundary_offset_m: float | None = None, two_stage_slope: bool = True, ditch_enabled: bool | None = None, + surface_drop_m: float = 0.0, ) -> dict[str, Any]: """측점 하나의 표준횡단 설계선과 절·성토 단면적을 계산한다. @@ -455,6 +456,9 @@ def compute_cross_design( standard: B06 설정 패널 편집값(STANDARD_CROSS_SECTION 형태). 요청값 → config 순. rock_boundary_offset_m: 암반 경계선 오프셋(지반선 기준, 음수=하향). 암 지반 2단계 절토용. two_stage_slope: 암 지반에서 암반 경계 기준 2단계 경사 적용 여부(기본 True, 토글로 해제). + surface_drop_m: 노면을 통째로 내리는 양(m) — 세월교 월류 높이. 구체 위 노면은 월류 + 높이만큼 낮게 앉으므로 계획고를 그만큼 내려 잡는다. 단면 전체가 평행 이동하므로 + 횡단경사·측구·사면 규칙은 그대로고 절·성토 면적만 따라 바뀐다(2026-08-30 사용자). """ if ground_type not in SECTION_GROUND_TYPE_PRESET: raise ValueError(f"지원하지 않는 지반유형입니다: {ground_type}") @@ -464,6 +468,8 @@ def compute_cross_design( raise ValueError(f"지원하지 않는 측구 형식입니다: {ditch_type}") if design_elevation_m is None: raise ValueError("계획고(design_elevation_m)가 없어 횡단 설계를 계산할 수 없습니다.") + drop = max(float(surface_drop_m), 0.0) + design_elevation_m = float(design_elevation_m) - drop preset_key = SECTION_GROUND_TYPE_PRESET[ground_type] if ditch_type == "l_type" and preset_key != "rock": @@ -630,6 +636,9 @@ def compute_cross_design( "ditch_area_m2": round(ditch_area, 4), "design_line": design_line, } + if drop > 0: + # 내려 앉힌 양 — 프론트가 "월류가 없었다면" 노면을 점선으로 되그리는 데 쓴다. + result["surface_drop_m"] = round(drop, 4) if paved: result["pavement_thickness_m"] = round(paved_group["pavement_thickness_m"], 4) # 암 지반은 경계선 오프셋을 echo해 프론트가 세션값 없이도 오버레이·재계산에 쓰게 한다. diff --git a/B06_Section/B06_Section_Router.py b/B06_Section/B06_Section_Router.py index 5c424a95..6e0259ab 100644 --- a/B06_Section/B06_Section_Router.py +++ b/B06_Section/B06_Section_Router.py @@ -43,9 +43,12 @@ from B06_Section.B06_Section_Router_Design import ( attach_default_designs as _attach_default_designs, compute_default_designs as _compute_default_designs, enforce_pavement_ranges as _enforce_pavement_ranges, + enforce_ford_surface_drops as _enforce_ford_surface_drops, stored_standard_cross_section as _stored_standard_cross_section, pavement_ranges as _pavement_ranges, paved_at as _paved_at, + ford_surface_drops, + ford_drop_at, ) from B06_Section.B06_Section_Router_Design import ( default_section_modes as _default_section_modes, @@ -318,6 +321,15 @@ async def get_section_detail( project_root, standard, ) + # 세월교 측점은 구체 위 노면이 월류 높이만큼 낮게 앉는다 — 저장분이 옛 계획고면 + # 여기서 다시 계산한다(2026-08-30 사용자 확정). + await asyncio.to_thread( + _enforce_ford_surface_drops, + detail["longitudinal"], + detail["cross_sections"], + project_root, + standard, + ) # 지정값이 없는 측점은 기본값(토사/좌절토)으로 즉석 계산해 프리뷰로 채운다. # (미저장 프리뷰: 실제 저장은 사용자가 카드를 조작하거나 확정할 때 이뤄진다.) await asyncio.to_thread( @@ -603,6 +615,7 @@ async def compute_cross_section_design( rock_boundary_offset_m=request.rock_boundary_offset_m, two_stage_slope=request.two_stage_slope, ditch_enabled=request.ditch_enabled, + surface_drop_m=ford_drop_at(request.chainage_m, ford_surface_drops(project_root)), ) # B06 지정 시점은 잠정치. B07 도면 확정 시 동일 엔진으로 재계산해 confirmed로 승격한다. design["status"] = "provisional" diff --git a/B06_Section/B06_Section_Router_Design.py b/B06_Section/B06_Section_Router_Design.py index 4509f63e..d563bb15 100644 --- a/B06_Section/B06_Section_Router_Design.py +++ b/B06_Section/B06_Section_Router_Design.py @@ -15,6 +15,10 @@ from config.config_system import STANDARD_ROCK_BOUNDARY_DEFAULT_OFFSET_M logger = logging.getLogger(__name__) +# 세월교 노면 하강이 걸리는 측점 판정 허용 오차(m) — 세트 부착(`attach_culvert_sets`)과 +# 같은 기준이라 "구체가 그려진 측점"과 "노면이 내려간 측점"이 어긋나지 않는다. +_FORD_DROP_TOLERANCE_M = 0.02 + PREVIEW_DESIGN_FIELDS = ( "ground_type", "roadbed_width_m", @@ -69,6 +73,36 @@ def pavement_ranges(project_root: Path) -> list[tuple[float, float]]: return ranges +def ford_surface_drops(project_root: Path | None) -> list[tuple[float, float]]: + """세월교가 앉은 측점의 노면 하강량 — [(누가거리, 월류 높이 m)]. + + 구체 위 노면은 월류 높이만큼 낮게 앉는다(2026-08-30 사용자 확정). 계획고를 그만큼 + 내려 잡아야 측벽·바닥판이 맞고 절·성토 면적도 따라온다. 포장 구간과 같은 방식으로 + 관 지점 정본에서 읽는다 — 읽기 실패는 비치명(하강 없음으로 본다). + """ + if project_root is None: + return [] + drops: list[tuple[float, float]] = [] + try: + for chainage, spec in load_culvert_sets(project_root).items(): + if spec.get("type") != "ford": + continue + depth = float(spec.get("overflow_depth_m") or 0.0) + if depth > 0: + drops.append((chainage, depth)) + except Exception: # noqa: BLE001 — 정본을 못 읽어도 설계 계산은 이어 간다 + logger.exception("B06 세월교 월류 높이를 읽지 못했습니다 (노면 하강 없음으로 본다)") + return drops + + +def ford_drop_at(chainage_m: float, drops: list[tuple[float, float]]) -> float: + """그 측점의 노면 하강량(m). 구체가 앉은 측점만 — 붙는 기준은 세트 부착과 같다.""" + for at, depth in drops: + if abs(chainage_m - at) <= _FORD_DROP_TOLERANCE_M: + return depth + return 0.0 + + def paved_at(chainage_m: float, ranges: list[tuple[float, float]], stored: Any = None) -> bool: """이 측점을 포장으로 볼 것인가 — 구간 안이면 강제, 밖이면 사용자 저장값(기본 비포장).""" for start, end in ranges: @@ -118,6 +152,7 @@ def enforce_pavement_ranges( ranges = pavement_ranges(project_root) if not ranges: return 0 + ford_drops = ford_surface_drops(project_root) changed = 0 for section in cross_sections: design = section.get("design") @@ -141,6 +176,57 @@ def enforce_pavement_ranges( ), two_stage_slope=bool(design.get("two_stage_slope", True)), ditch_enabled=design.get("ditch_enabled"), + surface_drop_m=ford_drop_at(chainage, ford_drops), + ) + except (ValueError, KeyError): + continue + for key in ("status", "pavement_suggested", *_USER_TOUCHED_KEYS): + if design.get(key) is not None: + recomputed[key] = design[key] + section["design"] = recomputed + changed += 1 + return changed + + +def enforce_ford_surface_drops( + longitudinal: dict[str, Any], + cross_sections: list[dict[str, Any]], + project_root: Path, + standard: dict[str, Any] | None = None, +) -> int: + """세월교가 앉은 측점의 계획고를 월류 높이만큼 내려 다시 계산한다. + + 저장분은 월류 높이를 넣기 전에 계산된 것이라 노면이 그대로다 — 포장 구간과 같은 + 방식으로 **저장분과 지금 값이 다를 때만** 다시 계산한다(2026-08-30 사용자 확정). + 월류 높이를 지우면 같은 경로로 원래 계획고로 되돌아온다. 사용자 조작값은 승계한다. + """ + drops = ford_surface_drops(project_root) + changed = 0 + for section in cross_sections: + design = section.get("design") + if not isinstance(design, dict): + continue + chainage = float(section.get("chainage_m", 0.0)) + wanted = ford_drop_at(chainage, drops) + stored = float(design.get("surface_drop_m") or 0.0) + if abs(wanted - stored) < 1e-6: + continue + try: + recomputed = compute_cross_design( + section.get("samples", []), + design_elevation_from_longitudinal(longitudinal, chainage), + ground_type=str(design.get("ground_type") or "ripping_rock"), + section_mode=str(design.get("section_mode") or "left_cut"), + ditch_side=design.get("ditch_side"), + ditch_type=str(design.get("ditch_type") or "standard"), + paved=bool(design.get("paved", False)), + standard=standard, + rock_boundary_offset_m=design.get( + "rock_boundary_offset_m", STANDARD_ROCK_BOUNDARY_DEFAULT_OFFSET_M + ), + two_stage_slope=bool(design.get("two_stage_slope", True)), + ditch_enabled=design.get("ditch_enabled"), + surface_drop_m=wanted, ) except (ValueError, KeyError): continue @@ -194,6 +280,7 @@ def attach_default_designs( modes = default_section_modes(longitudinal) pavement = pavement_suggestions(longitudinal) paved_ranges = pavement_ranges(project_root) if project_root else [] + ford_drops = ford_surface_drops(project_root) for section in cross_sections: if section.get("design"): continue @@ -209,6 +296,7 @@ def attach_default_designs( paved=paved_at(chainage, paved_ranges), standard=standard, rock_boundary_offset_m=STANDARD_ROCK_BOUNDARY_DEFAULT_OFFSET_M, + surface_drop_m=ford_drop_at(chainage, ford_drops), ) design.update(status="provisional", pavement_suggested=suggested) section["design"] = design @@ -227,6 +315,7 @@ def recompute_designs_for_alignment( modes = default_section_modes(longitudinal) pavement = pavement_suggestions(longitudinal) paved_ranges = pavement_ranges(project_root) if project_root else [] + ford_drops = ford_surface_drops(project_root) stored_by_chainage = { round(float(record["chainage_m"]), 3): (record.get("design") or {}) for record in stored_designs @@ -258,6 +347,7 @@ def recompute_designs_for_alignment( ), two_stage_slope=bool(stored.get("two_stage_slope", True)), ditch_enabled=stored.get("ditch_enabled"), + surface_drop_m=ford_drop_at(chainage, ford_drops), ) except (ValueError, KeyError): continue @@ -295,6 +385,7 @@ def compute_default_designs( default_modes = default_section_modes(longitudinal) pavement = pavement_suggestions(longitudinal) paved_ranges = pavement_ranges(root) + ford_drops = ford_surface_drops(root) results: list[tuple[float, dict[str, Any]]] = [] for chainage_m in chainages: cross_path = cross_dir / cross_filename(chainage_m) @@ -315,6 +406,7 @@ def compute_default_designs( paved=paved_at(chainage_m, paved_ranges), standard=standard, rock_boundary_offset_m=STANDARD_ROCK_BOUNDARY_DEFAULT_OFFSET_M, + surface_drop_m=ford_drop_at(chainage_m, ford_drops), ) design["status"] = "provisional" design["pavement_suggested"] = suggested diff --git a/B06_Section/B06_Section_UI_Cross_Ford_Pavement.ts b/B06_Section/B06_Section_UI_Cross_Ford_Pavement.ts index 3f4439ac..be1e4f48 100644 --- a/B06_Section/B06_Section_UI_Cross_Ford_Pavement.ts +++ b/B06_Section/B06_Section_UI_Cross_Ford_Pavement.ts @@ -67,6 +67,32 @@ function line(points: string[], className: string): SVGPolylineElement { return polyline; } +/** + * 세월교로 **내려 앉힌 노면** 위에 "월류가 없었다면" 노면을 점선으로 되그린다 + * (2026-08-30 사용자 확정). 계획고는 이미 백엔드가 월류 높이만큼 내려 잡았으므로 + * (`design.surface_drop_m`) 여기서는 그 양만큼 위로 올린 선 한 줄만 얹는다. + * 물넘이포장의 「기존 계획고 점선」과 같은 표기라 사용자가 한 규칙으로 읽는다. + */ +export function appendFordSurfaceDropPlan( + svg: SVGElement, + design: CrossDesign, + x: (offset: number) => number, + y: (elevation: number) => number, +): void { + const drop = design.surface_drop_m ?? 0; + if (!(drop > 0) || !design.road_edges) return; + const { left, right } = design.road_edges; + svg.append( + line( + [ + `${x(left.offset_m)},${y(left.elevation_m + drop)}`, + `${x(right.offset_m)},${y(right.elevation_m + drop)}`, + ], + "b06-chart__ford-deck-plan", + ), + ); +} + /** * 물넘이 파임을 그린다. 그렸으면 true — 호출부는 일반 포장층 박스를 건너뛴다 * (같은 자리에 두 겹으로 깔리면 색 구분이 사라진다). diff --git a/B06_Section/B06_Section_UI_Cross_View.ts b/B06_Section/B06_Section_UI_Cross_View.ts index 3e553132..580dfb85 100644 --- a/B06_Section/B06_Section_UI_Cross_View.ts +++ b/B06_Section/B06_Section_UI_Cross_View.ts @@ -14,7 +14,10 @@ import { buildAreaReadout, type CrossAreaKey, } from "./B06_Section_UI_Cross_Areas"; -import { appendFordPavementOverlay } from "./B06_Section_UI_Cross_Ford_Pavement"; +import { + appendFordPavementOverlay, + appendFordSurfaceDropPlan, +} from "./B06_Section_UI_Cross_Ford_Pavement"; import { appendRevetmentOverlay, computeRevetmentLayout } from "./B06_Section_UI_Cross_Revetment"; import { appendCrossDesignOverlay, @@ -462,6 +465,9 @@ export function createCrossSectionCard( toDisplayY, ); if (!fordPaved) appendPavementOverlay(plotLayer, section.design, x, toDisplayY); + // 세월교 측점 — 계획고는 이미 월류 높이만큼 내려와 있다. "월류가 없었다면" + // 노면을 점선으로 남겨 얼마나 내려앉았는지 읽히게 한다(2026-08-30 사용자). + appendFordSurfaceDropPlan(plotLayer, section.design, x, toDisplayY); // 독립 기슭막이(구 D군 경로) — 이 측점에 배관/숨김 기슭막이 세트(section.culvert)가 // 붙으면 그쪽(배관 경로)이 그린다. 아직 이관 안 된 정본만 이 옛 경로로 그린다 // (2026-08-28 이관: pipe_points 숨김 세트로 옮기는 중 — 이중 그리기 방지 가드). diff --git a/B06_Section/B06_Section_UI_Section_View.ts b/B06_Section/B06_Section_UI_Section_View.ts index 27064096..515cee82 100644 --- a/B06_Section/B06_Section_UI_Section_View.ts +++ b/B06_Section/B06_Section_UI_Section_View.ts @@ -407,6 +407,18 @@ export function createSectionView( applySelection(previous); }; + /** 십자선이 설 계획고 — 세월교 측점은 구체 위 노면이 월류 높이만큼 낮게 앉으므로 + * 그만큼 내려 찍는다(2026-08-30 사용자 확정). 하강량은 백엔드가 계획고를 내려 + * 잡으며 실어 준 값(`design.surface_drop_m`)이고, 원래 노면은 점선이 남긴다. */ + const cardDesignElevation = ( + detail: SectionDetailResponse, + section: CrossSection, + ): number | undefined => { + const planZ = designElevationAt(detail.longitudinal.design_profiles, section.chainage_m); + if (planZ === undefined) return undefined; + return planZ - (section.design?.surface_drop_m ?? 0); + }; + const buildCrossCard = (section: CrossSection, forcedHeightPx?: number): HTMLElement => createCrossSectionCard( section, @@ -417,9 +429,7 @@ export function createSectionView( currentCrossHalfWidth, cachedCardWidth, forcedHeightPx, - currentDetail - ? designElevationAt(currentDetail.longitudinal.design_profiles, section.chainage_m) - : undefined, + currentDetail ? cardDesignElevation(currentDetail, section) : undefined, onDesignChange, rockBoundary, section.station_id === selectedStationId ? activeAreaKey : null,