feat(B06): 세월교 노면을 월류 높이만큼 내려 앉힌다

횡단도에 그려지던 노면은 사실 월류부 가장 아래를 자른 자리다 — 구체 위 노면은
월류 높이만큼 낮게 앉아야 한다(2026-08-30 사용자 확정). 계획고를 통째로 내려
잡아 측벽·바닥판·십자선·절성토 면적이 한 번에 따라오게 했다. 전폭 평행 하강이라
횡단경사·측구·사면 규칙은 그대로다.

백엔드
- `_ford_set`이 `ford_height_m`을 `overflow_depth_m`으로 스펙에 싣는다.
- `compute_cross_design(surface_drop_m=)` — 계획고를 그만큼 내려 계산하고, 내린
  양을 `surface_drop_m`으로 echo한다(프론트가 점선을 되그리는 데 쓴다).
- `ford_surface_drops()`·`ford_drop_at()` — 관 지점 정본에서 측점별 하강량을 읽는다
  (포장 구간과 같은 방식). 설계 계산 경로 전부(기본값·재계산·프리뷰·종횡단 확정)에
  물렸다.
- `enforce_ford_surface_drops()` — 저장분이 옛 계획고면 상세 조회에서 다시 계산한다.
  월류 높이를 지우면 같은 경로로 원래 계획고로 되돌아온다.

프론트
- 십자선을 하강량만큼 내려 찍는다(`cardDesignElevation`). 종단 계획고를 그대로
  쓰던 자리다.
- `appendFordSurfaceDropPlan()` — "월류가 없었다면" 노면을 점선으로 남긴다.
  물넘이포장의 「기존 계획고 점선」과 같은 표기라 한 규칙으로 읽힌다.
- 측벽·바닥판은 내려간 설계선(`design_line`)에 그대로 붙으므로 따로 손대지 않았다.

자체검증(공용 브라우저 5174 + 워크트리 백엔드 8001, 측점 7+9.7, 월류 높이 0.39m):
- `tmp/tests/test_ford_surface_drop.py` 2건 통과 — 계획고·좌우 노견이 정확히
  0.39m 내려가고, 절토는 늘고 성토는 줄며, 하강이 없으면 `surface_drop_m` 키 자체가
  없다.
- 화면 실측: y축 눈금 1m = 24.375px에서 십자선이 점선보다 9.50px 아래
  (= 0.390m, 오차 0.001m). 수정 전에는 두 선이 같은 자리였다.
- 세월교 측벽 자동 높이 1.70m → 1.50m. 이론값 1.31m이나 하한(관경 1.0 + 최소 토피
  0.5 = 1.5m)에 걸린 값이다 — 기존 규칙 그대로다.
- 성토 면적 18.46 → 13.85 ㎡. 이웃 일반 측점에는 점선이 그려지지 않는다.
- 검증에 쓴 `pipe_points.json` 임시 옵션은 백업본으로 되돌렸다(정본 무변경).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-08-30 12:54:29 +09:00
co-authored by Claude Opus 5
parent 7385b4d76f
commit 933f513da0
9 changed files with 175 additions and 4 deletions
@@ -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)
+7
View File
@@ -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). */
@@ -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,
+9
View File
@@ -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해 프론트가 세션값 없이도 오버레이·재계산에 쓰게 한다.
+13
View File
@@ -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"
+92
View File
@@ -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
@@ -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 — 호출부는 일반 포장층 박스를 건너뛴다
* (같은 자리에 두 겹으로 깔리면 색 구분이 사라진다).
+7 -1
View File
@@ -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 숨김 세트로 옮기는 중 — 이중 그리기 방지 가드).
+13 -3
View File
@@ -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,