feat(B05,B06): 물넘이 3D 파임 + 독립 기슭막이 횡단·3D + 포장 기본값·표준횡단 반영

2026-08-28 사용자 지시 4건.

① 물넘이 파임을 3D 코리도에도 반영
   classifyStation 이 노면·노견 조각 표고를 횡단도와 같은 산식(fordDeckElevationAt)
   으로 내린다. 노면 밖 비탈은 그대로라 노견 끝에 수직 단차가 선다. BUILD_VERSION 85.

② 표준횡단면 저장분을 포장 재계산에 싣는다
   확정 때 종단 정본 options 에 실린 standard_cross_section 을 읽어
   enforce_pavement_ranges / attach_default_designs 에 넘긴다. 없으면 config 기본값.

③ 포장 구간 기본값 10 / 5 / 5 (기슭막이와 같은 출발값).

④ 독립 기슭막이
   - 측점: is_station_planting_type() 규칙 신설(A군 + D군 구간형). 구간형은
     시작·기준·종료에 측점을 심는다 — 길이가 길수록 횡단도가 여러 장 나온다.
     구조물 재이관 차단도 같은 규칙을 본다(기타 고스트 방지).
   - 서버: B06_Section_Engine_Revetment 신설 — 구간 안 측점 전부에 section.revetment.
   - 횡단도: B06_Section_UI_Cross_Revetment 신설 — 벽 상단 = 성토면 끝(설계선-지반
     교차점), 아래로 높이 + 근입 0.5m, 전면 1:0.3(교본 7-3). 설치 측은 사용자가
     좌/우로 고른다(레지스트리 side 옵션 신설 — 자동 판정 없음).
   - 3D: 같은 폴리곤을 기준측점 전/후만큼 스윕. BUILD_VERSION 86 + 구조물 해시에
     물넘이·기슭막이 제원 추가.

검증: pytest 241 passed / 7 skipped(신규 4건). 공용 브라우저 실측 —
물넘이 240m 투입 시 3D 노면이 계획고보다 0.38~0.42m 아래(대조군 200m 정상),
기슭막이 225~255 투입 시 측점 23→25·횡단 카드 3장·3D revet 솔리드 3개(31링).
임시 데이터는 원래 정본으로 복구했다.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-08-28 12:54:45 +09:00
co-authored by Claude Opus 5
parent 80c2a265f9
commit e371c2754a
16 changed files with 409 additions and 22 deletions
+21 -5
View File
@@ -22,7 +22,10 @@ from B05_Profile.B05_Profile_Engine_Sections_Core import (
generate_sections,
)
from B05_Profile.B05_Profile_Structures_Repository import load_structures
from B05_Profile.B05_Profile_Structures_Schema import structure_type_map
from B05_Profile.B05_Profile_Structures_Schema import (
is_station_planting_type,
structure_type_map,
)
from common_util.common_util_drainage_pipes import (
PIPE_FACILITY_FORD_PAVEMENT,
PIPE_FACILITY_PIPE,
@@ -172,13 +175,26 @@ def resolve_extra_stations(
continue
label = definition.name
derived.append((float(pipe.chainage_m), label))
# A군 수동 구조물(노출형 횡단수로·개거)도 같은 자리에 측점이 필요하다. 관 정본이
# 관리하는 타입(managed_by)은 structures.json에 저장되지 않아 중복되지 않는다.
# 구조물 정본에서 측점이 필요한 것들. 관 정본이 관리하는 타입(managed_by)은
# structures.json에 저장되지 않아 중복되지 않는다.
# · 점형(A군 노출형 횡단수로·개거): 기준 측점 한 곳.
# · 구간형(D군 기슭막이): **시작·기준·종료** — 길이가 길수록 횡단도가 여러 장
# 나와야 한다(2026-08-28 사용자). 격자와 같은 정수 미터면 generate_sections가
# 격자로 스냅해 파일이 겹치지 않는다.
try:
for structure in load_structures(str(project_root))[1]:
definition = types.get(structure.type_id)
if definition and definition.group == "A" and not definition.managed_by:
derived.append((float(structure.anchor_m()), definition.name))
if definition is None or definition.managed_by:
continue
if not is_station_planting_type(definition):
continue
if definition.placement == "interval":
marks = (structure.start_m, structure.chainage_m, structure.end_m)
else:
marks = (structure.anchor_m(),)
for mark in marks:
if mark is not None:
derived.append((float(mark), definition.name))
except Exception:
logger.exception("B05: 구조물 정본을 읽지 못했습니다 (관 유래 측점만 씁니다)")
return tuple(sorted(derived))
+12 -3
View File
@@ -983,6 +983,15 @@
},
"drawing_views": ["profile", "cross_section", "quantity"],
"options": [
{
"key": "side",
"label": "설치 측",
"input": "select",
"choices": ["좌", "우"],
"default": "좌",
"required": false,
"phase": "b05"
},
{
"key": "form",
"label": "형태",
@@ -1322,21 +1331,21 @@
"label": "길이",
"input": "number",
"unit": "m",
"default": 0
"default": 10
},
{
"key": "before_m",
"label": "기준측점 전",
"input": "number",
"unit": "m",
"default": 0
"default": 5
},
{
"key": "after_m",
"label": "기준측점 후",
"input": "number",
"unit": "m",
"default": 0
"default": 5
},
{
"key": "thickness_cm",
@@ -17,7 +17,7 @@ from typing import Any, Iterable
from B05_Profile.B05_Profile_Structures_Schema import (
StructureInstance,
load_structure_types,
station_planting_labels,
structure_type_map,
)
@@ -38,11 +38,10 @@ _ESCAPE_LABEL = re.compile(r"^대피로(?:\s+([\d.]+)\s*m)?", re.UNICODE)
def _managed_elsewhere_labels() -> set[str]:
"""A군(계곡 통과 시설·횡단배수) 표시 이름 — 서버가 종단 정본에 직접 심는 라벨이라
구조물로 재이관하면 안 된다(정본 이중화 → "기타" 고스트). 레지스트리에서 뽑아 목록이
바뀌어도 따라간다(load_structure_types는 lru_cache).
"""서버가 종단 정본에 직접 심는 라벨 — 구조물로 재이관하면 안 된다(정본 이중화 →
"기타" 고스트). 규칙은 측점 생성과 **같은 자리**에서 온다(station_planting_labels).
"""
return {item.name for item in load_structure_types() if item.group == "A"}
return station_planting_labels()
def _is_managed_elsewhere(entry: dict[str, Any]) -> bool:
@@ -78,6 +78,26 @@ def structure_type_map() -> dict[str, StructureType]:
return {item.type_id: item for item in load_structure_types()}
def is_station_planting_type(item: StructureType) -> bool:
"""서버가 종단 정본에 **측점으로 심는** 타입인가 (단일 규칙, 2026-08-28).
- A군(횡단배수·계곡 통과 시설): 그 자리에 횡단도가 필요하다.
- D군 구간형(기슭막이 등): 구간 시작·기준·종료에 횡단도가 필요하다 — 길이가 길면
그만큼 여러 장이 나온다(사용자 확정).
측점 생성(`resolve_extra_stations`)과 구조물 재이관 차단
(`B05_Profile_Structures_Migration`)이 같은 규칙을 봐야 "기타" 고스트가 안 생긴다.
"""
if item.group == "A":
return True
return item.group == "D" and item.placement == "interval"
def station_planting_labels() -> set[str]:
"""서버가 심는 측점 라벨 집합 — 구조물로 되옮기면 정본이 이중화된다."""
return {item.name for item in load_structure_types() if is_station_planting_type(item)}
def registry_schema_version() -> int:
with open(_REGISTRY_PATH, encoding="utf-8") as handle:
return int(json.load(handle).get("schema_version", 1))
+5 -1
View File
@@ -195,7 +195,11 @@ function fnv1a(text: string): string {
// (실측: 도려낸 셀의 18% — 성토 끝단 쪽 col 40~46 — 이 영역에서 빠졌다.
// 2026-08-27 사용자: "유출구에 일부 성토부에 터짐 있음"). 오른손 규칙으로 갈림목을
// 가르고 **닫힌 고리만** 담는다.
const BUILD_VERSION = 84;
// 85 = 물넘이포장 자리 노면을 판다 — 횡단도와 같은 산식(fordDeckElevationAt)으로 노면·노견
// 조각 표고를 내린다. 노면 밖 비탈은 그대로라 노견 끝에 수직 단차가 선다(2026-08-28 사용자).
// 86 = 독립 기슭막이(구조물 정본 D군)를 3D에 세운다 — 횡단 카드와 같은 폴리곤
// (computeRevetmentLayout)을 기준측점 전/후 길이만큼 스윕한다(2026-08-28 사용자).
const BUILD_VERSION = 86;
/** 종횡단 정본에서 코리도에 영향을 주는 입력만 요약해 해시 — 갱신 감지 기준. */
export function corridorHash(detail: SectionDetailResponse, routePoints: RoutePoint[]): string {
+17 -1
View File
@@ -9,6 +9,7 @@
* (2026-08-23 사용자 지시)의 핵심.
* ========================================================================== */
import { fordDeckElevationAt } from "../B06_Section/B06_Section_UI_Cross_Ford_Pavement";
import type { CrossSection } from "../B06_Section/B06_Section_Api_Fetch";
import { structureSilhouettes } from "./B05_Profile_UI_Corridor_Station_Structure";
@@ -237,9 +238,24 @@ export function classifyStation(section: CrossSection): StationPieces | null {
const design = section.design;
const line = design?.design_line;
if (!design || !line || line.length < 2) return null;
const sorted = [...line].sort((a, b) => a.offset_m - b.offset_m);
const roadL = design.road_edges.left.offset_m;
const roadR = design.road_edges.right.offset_m;
// 물넘이포장 — 노면(노견 포함)이 파인다. 횡단도와 **같은 산식**으로 표고를 내려
// 3D도 같은 자리에 같은 깊이로 팬다(2026-08-28 사용자 지시). 노면 밖 비탈은 그대로라
// 노견 끝에 파임 벽(수직 단차)이 선다 — 횡단도 그림과 같은 표현이다.
const ford = section.ford_pavement;
const carved =
ford && ford.depth_m
? line.map((point) =>
point.offset_m <= roadL + 1e-9 && point.offset_m >= roadR - 1e-9
? {
...point,
elevation_m: fordDeckElevationAt(design, ford, point.offset_m, point.elevation_m),
}
: point,
)
: line;
const sorted = [...carved].sort((a, b) => a.offset_m - b.offset_m);
const cwL = design.carriageway_edges?.left.offset_m ?? roadL;
const cwR = design.carriageway_edges?.right.offset_m ?? roadR;
@@ -38,6 +38,7 @@ import type { SectionPolygon } from "./B05_Profile_UI_Corridor_Structures_Box";
import { computeBoxLayout, DEFAULT_BOX_SIDE_ADJUST } from "../B06_Section/B06_Section_UI_Cross_Box";
import type { BoxAdjust, BoxLayout } from "../B06_Section/B06_Section_UI_Cross_Box";
import { buildWingSolids } from "./B05_Profile_UI_Corridor_Structures_Wing";
import { computeRevetmentLayout } from "../B06_Section/B06_Section_UI_Cross_Revetment";
/** 스윕 프레임 하나 — 노선 위 한 지점의 중심 XY·좌향 단위벡터와 종단 표고 오프셋. */
export interface StructureFrame {
@@ -274,7 +275,9 @@ export function buildCorridorStructures(
for (const section of crossSections) {
const layout = culvertLayoutOf(section);
const fordLayout = fordLayoutOf(section);
if (!layout && !fordLayout && !section.box) continue;
// 독립 기슭막이 — 횡단 카드와 **같은 폴리곤**을 그대로 스윕한다(2026-08-28 사용자).
const revetLayout = computeRevetmentLayout(section);
if (!layout && !fordLayout && !section.box && !revetLayout) continue;
const chainage = section.chainage_m;
const stationFrame: StructureFrame = {
cx: section.center_x,
@@ -326,6 +329,10 @@ export function buildCorridorStructures(
});
};
if (revetLayout) {
pushSwept("revet", revetLayout.polygon, revetLayout.span.beforeM, revetLayout.span.afterM);
}
/** 링 누가거리 목록으로 프레임을 만든다(보어처럼 촘촘한 간격이 필요할 때). */
const ringsAt = (chainages: number[]): StructureFrame[] =>
chainages.map((at) => {
@@ -571,9 +578,29 @@ export function buildCorridorStructures(
/** 해시 입력용 요약 — 구조물에 영향을 주는 값만 짧게 이어 붙인다(Corridor.ts가 쓴다). */
export function structureHashParts(section: CrossSection): Array<string | number> {
// 물넘이 파임·독립 기슭막이는 다른 세트와 함께 설 수 있으니 앞에 이어 붙인다 —
// 빼먹으면 값을 고쳐도 저장 코리도가 만료되지 않는다(2026-08-28).
const extras: Array<string | number> = [];
const fordPave = section.ford_pavement;
if (fordPave) {
extras.push("fp", fordPave.span_m, fordPave.depth_m ?? "", fordPave.slope_pct ?? "");
}
const ownRevet = section.revetment;
if (ownRevet) {
extras.push(
"rv",
ownRevet.start_m,
ownRevet.end_m,
ownRevet.anchor_m,
ownRevet.height_m ?? "",
ownRevet.side ?? "",
ownRevet.form ?? "",
);
}
const box = section.box;
if (box) {
return [
...extras,
"bx",
box.inner_width_m,
box.inner_height_m,
@@ -598,6 +625,7 @@ export function structureHashParts(section: CrossSection): Array<string | number
return value ? `${value.heightM ?? ""},${value.lateralM},${value.slopeM}` : "";
};
return [
...extras,
"fd",
ford.diameter_m,
ford.pipe_count,
@@ -609,7 +637,7 @@ export function structureHashParts(section: CrossSection): Array<string | number
];
}
const culvert = section.culvert;
if (!culvert) return [];
if (!culvert) return extras;
const side = (spec: CulvertSideSpec): string =>
[
spec.structure,
@@ -626,6 +654,7 @@ export function structureHashParts(section: CrossSection): Array<string | number
const revet = section.design?.revet_adjust;
const counts = section.design?.extra_wall_counts;
return [
...extras,
"cv",
culvert.diameter_m,
culvert.pipe_kind ?? "",
+7
View File
@@ -640,6 +640,13 @@ export function createRouteViewer(): RouteViewer {
})),
outlineRows: build.outline.chainages.length,
outline: build.outline,
// 구조물 솔리드 요약 — 어떤 시설이 몇 개 섰는지 화면 밖에서 센다(2026-08-28).
structures: (build.structures ?? []).map((solid) => ({
kind: solid.kind,
at: solid.chainage_m,
rings: solid.rings?.length ?? 0,
points: solid.polygon?.length ?? 0,
})),
// 원본 참조 — 투영선이 실제로 서피스에 얹혔는지 좌표로 대조할 때 쓴다.
raw: {
ribbons: build.ribbons,
+4 -1
View File
@@ -69,7 +69,8 @@ export type {
/** 물넘이포장 제원 — 정의처는 렌더 모듈이다(사본 금지, 타입 전용 import라 순환 없음). */
import type { FordPavementSpec } from "./B06_Section_UI_Cross_Ford_Pavement";
export type { FordPavementSpec };
import type { RevetmentSpec } from "./B06_Section_UI_Cross_Revetment";
export type { FordPavementSpec, RevetmentSpec };
export interface SectionContextResponse {
project_id: string;
@@ -282,6 +283,8 @@ export interface CrossSection extends SectionStation {
box?: BoxSet;
/** 물넘이포장이 파는 노면 제원(있을 때만). 월류 폭 안의 측점 전부에 붙는다. */
ford_pavement?: FordPavementSpec;
/** 독립 기슭막이 제원(있을 때만). 구조물 정본 D군 구간 안의 측점 전부에 붙는다. */
revetment?: RevetmentSpec;
}
export interface SectionDetailResponse {
@@ -0,0 +1,78 @@
"""독립 기슭막이(구조물 정본 D군) 제원을 횡단 측점에 얹는다.
배관 유입·유출에 딸린 기슭막이는 관 정본이 관리하지만(`B06_Section_Engine_Culvert`),
배관과 무관한 **독립 기슭막이**는 구조물 정본(`structures.json`)이 정본이다. 여기서는
구간(시작~종료) 안의 측점에 형태·높이·설치 측을 붙이기만 한다 — 치수 결정·도형은
화면(`B06_Section_UI_Cross_Revetment`)과 3D가 같은 산식으로 그린다(2026-08-28 사용자 확정).
측점 자체는 `B05_Profile_Engine_Sections.resolve_extra_stations`가 시작·기준·종료에 심는다.
"""
import logging
from pathlib import Path
from typing import Any
from B05_Profile.B05_Profile_Structures_Repository import load_structures
from B05_Profile.B05_Profile_Structures_Schema import structure_type_map
logger = logging.getLogger(__name__)
# 구간 경계 측점을 구간 안으로 볼 허용 오차(m) — 정본이 누가거리를 0.01m로 끊어 쓴다.
_EDGE_TOLERANCE_M = 0.02
def load_revetments(project_root: Path) -> list[dict[str, Any]]:
"""구조물 정본에서 독립 기슭막이(D군 구간형) 목록을 읽는다. 실패하면 빈 목록."""
try:
types = structure_type_map()
found: list[dict[str, Any]] = []
for structure in load_structures(str(project_root))[1]:
definition = types.get(structure.type_id)
if definition is None or definition.group != "D" or definition.placement != "interval":
continue
start, end = structure.start_m, structure.end_m
if start is None or end is None:
continue
options = structure.options or {}
found.append(
{
"structure_id": structure.structure_id,
"type_id": structure.type_id,
"name": definition.name,
"start_m": float(min(start, end)),
"end_m": float(max(start, end)),
"anchor_m": float(structure.anchor_m()),
"form": options.get("form"),
"height_m": options.get("height_m"),
"side": options.get("side"),
}
)
return found
except Exception: # noqa: BLE001 — 정본을 못 읽어도 횡단 조회는 이어 간다
logger.exception("B06 독립 기슭막이 정본을 읽지 못했습니다 (없는 것으로 본다)")
return []
def attach_revetments(project_root: Path, cross_sections: list[dict[str, Any]]) -> int:
"""구간 안 측점의 횡단 dict에 `revetment` 키를 얹는다. 얹은 개수를 돌려준다.
한 측점에 여러 개가 겹치면 **먼저 시작한 것**을 쓴다 — 겹침 정리는 사용자 몫이다.
"""
revetments = load_revetments(project_root)
if not revetments:
return 0
attached = 0
for section in cross_sections:
chainage = section.get("chainage_m")
if not isinstance(chainage, (int, float)):
continue
for spec in revetments:
if (
spec["start_m"] - _EDGE_TOLERANCE_M
<= float(chainage)
<= spec["end_m"] + _EDGE_TOLERANCE_M
):
section["revetment"] = spec
attached += 1
break
return attached
+9
View File
@@ -21,6 +21,7 @@ from B05_Profile.B05_Profile_Engine_Sections import (
)
from B05_Profile.B05_Profile_Engine_Sections_Core import SectionGenerationOptions
from B06_Section.B06_Section_Engine_Culvert import attach_culvert_sets
from B06_Section.B06_Section_Engine_Revetment import attach_revetments
from B06_Section.B06_Section_Engine_Design import compute_cross_design
from B06_Section.B06_Section_Repository import (
count_cross_sections,
@@ -43,6 +44,7 @@ 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,
stored_standard_cross_section as _stored_standard_cross_section,
pavement_ranges as _pavement_ranges,
paved_at as _paved_at,
)
@@ -252,6 +254,8 @@ def _read_section_detail(project_root: Path, longitudinal_file_path: str) -> dic
raise ValueError("종횡단 상세 파일 형식이 올바르지 않습니다.")
# 배수관 측점에 세트(배관·기슭막이·보호공) 제원을 얹는다 — 횡단 카드가 그림을 그린다.
attach_culvert_sets(root, cross_sections)
# 배관과 무관한 **독립 기슭막이**(구조물 정본 D군)는 구간 안 측점 전부에 얹는다.
attach_revetments(root, cross_sections)
return {"longitudinal": longitudinal, "cross_sections": cross_sections}
@@ -307,11 +311,14 @@ async def get_section_detail(
section["design"] = record["design"]
break
# 포장 구간·물넘이 범위는 저장분이 비포장이어도 포장으로 맞춘다(2026-08-28).
# 표준횡단면은 확정 때 저장해 둔 사용자 값을 쓴다 — 없으면 config 기본값.
standard = _stored_standard_cross_section(longitudinal)
await asyncio.to_thread(
_enforce_pavement_ranges,
detail["longitudinal"],
detail["cross_sections"],
project_root,
standard,
)
# 지정값이 없는 측점은 기본값(토사/좌절토)으로 즉석 계산해 프리뷰로 채운다.
# (미저장 프리뷰: 실제 저장은 사용자가 카드를 조작하거나 확정할 때 이뤄진다.)
@@ -320,6 +327,7 @@ async def get_section_detail(
detail["longitudinal"],
detail["cross_sections"],
project_root,
standard,
)
return SectionDetailResponse(**detail, balloon_offsets=_read_balloon_offsets(longitudinal))
except FileNotFoundError as exc:
@@ -469,6 +477,7 @@ async def regenerate_sections(
result = sections["result"]
# 재생성 응답도 상세 조회와 같은 배수관 세트 정보를 실어야 화면이 어긋나지 않는다.
attach_culvert_sets(project_root, result["cross_sections"])
attach_revetments(project_root, result["cross_sections"])
return SectionDetailResponse(
longitudinal=result["longitudinal"], cross_sections=result["cross_sections"]
)
+19 -1
View File
@@ -91,8 +91,23 @@ _USER_TOUCHED_KEYS = (
)
def stored_standard_cross_section(longitudinal_row: dict[str, Any] | None) -> dict[str, Any] | None:
"""확정 때 저장해 둔 표준횡단면 설정값(없으면 None = config 기본값).
사용자가 표준 횡단면 설정에서 고친 값은 확정 종단 정본 options에 실린다.
포장 강제 재계산도 같은 값을 써야 카드가 패널과 어긋나지 않는다(2026-08-28).
"""
options = (longitudinal_row or {}).get("data") or {}
options = options.get("options") if isinstance(options, dict) else None
standard = options.get("standard_cross_section") if isinstance(options, dict) else None
return standard if isinstance(standard, dict) else None
def enforce_pavement_ranges(
longitudinal: dict[str, Any], cross_sections: list[dict[str, Any]], project_root: Path
longitudinal: dict[str, Any],
cross_sections: list[dict[str, Any]],
project_root: Path,
standard: dict[str, Any] | None = None,
) -> int:
"""포장 구간·물넘이 범위 안 측점을 포장으로 맞춘다 — 저장분이 비포장이어도 그렇다.
@@ -120,6 +135,7 @@ def enforce_pavement_ranges(
ditch_side=design.get("ditch_side"),
ditch_type=str(design.get("ditch_type") or "standard"),
paved=True,
standard=standard,
rock_boundary_offset_m=design.get(
"rock_boundary_offset_m", STANDARD_ROCK_BOUNDARY_DEFAULT_OFFSET_M
),
@@ -173,6 +189,7 @@ def attach_default_designs(
longitudinal: dict[str, Any],
cross_sections: list[dict[str, Any]],
project_root: Path | None = None,
standard: dict[str, Any] | None = None,
) -> None:
modes = default_section_modes(longitudinal)
pavement = pavement_suggestions(longitudinal)
@@ -190,6 +207,7 @@ def attach_default_designs(
section_mode=modes.get(round(chainage, 3), "left_cut"),
# 포장은 사용자 구간 지정만 켠다 — 경사 제안은 경고로만 남는다(2026-08-28).
paved=paved_at(chainage, paved_ranges),
standard=standard,
rock_boundary_offset_m=STANDARD_ROCK_BOUNDARY_DEFAULT_OFFSET_M,
)
design.update(status="provisional", pavement_suggested=suggested)
@@ -40,11 +40,24 @@ function inflowSign(design: CrossDesign, offsetM: number): number {
return towardLeft === inflowLeft ? 1 : -1;
}
function bottomAt(design: CrossDesign, spec: FordPavementSpec, edge: Edge): number {
/**
* (m). 3D ** ** .
* , .
*/
export function fordDeckElevationAt(
design: CrossDesign,
spec: FordPavementSpec,
offsetM: number,
fallbackElevationM = 0,
): number {
const depth = spec.depth_m ?? 0;
const slope = (spec.slope_pct ?? design.cross_slope_pct) / 100;
const center = design.design_elevation_m ?? edge.elevation_m;
return center - depth + slope * Math.abs(edge.offset_m) * inflowSign(design, edge.offset_m);
const center = design.design_elevation_m ?? fallbackElevationM;
return center - depth + slope * Math.abs(offsetM) * inflowSign(design, offsetM);
}
function bottomAt(design: CrossDesign, spec: FordPavementSpec, edge: Edge): number {
return fordDeckElevationAt(design, spec, edge.offset_m, edge.elevation_m);
}
function line(points: string[], className: string): SVGPolylineElement {
@@ -0,0 +1,155 @@
/* =============================================================================
* B06_Section_UI_Cross_Revetment.ts
* ( ) + (2026-08-28 ).
*
* : ** ( )**.
* + 0.5m만큼 . ( ) 1:0.3으로
* ( , 7-3). ** / ** .
*
* 3D (`B05_Profile_UI_Corridor_Structures`)
* .
* ========================================================================== */
import type { CrossSection } from "./B06_Section_Api_Fetch";
import {
REVET_EMBED_DEPTH_M,
REVET_LEAN_RATIO,
REVET_THICKNESS_M,
} from "./B06_Section_UI_Cross_Culvert_Const";
const SVG_NS = "http://www.w3.org/2000/svg";
/** 서버가 얹어 주는 독립 기슭막이 제원(`section.revetment`, 구조물 정본 D군). */
export interface RevetmentSpec {
structure_id: string | null;
type_id: string;
name: string;
start_m: number;
end_m: number;
anchor_m: number;
form?: string | null;
height_m?: number | null;
/** 사용자가 고른 설치 측 — "좌" | "우". */
side?: string | null;
}
export interface RevetPoint {
offset: number;
elevation: number;
}
export interface RevetmentLayout {
side: "left" | "right";
/** 벽 상단 자리 = 성토면 끝(설계선-지반 교차점). */
top: RevetPoint;
/** 단면 폴리곤(도로측 수직 배면 → 상단 → 기운 전면 → 바닥). */
polygon: RevetPoint[];
/** 기준 측점 전/후 점유 길이(m) — 3D 스윕 범위. */
span: { beforeM: number; afterM: number };
}
/** 지반 표고 보간 — 샘플 사이는 선형. 유효 표본이 없으면 null. */
function groundElevationAt(section: CrossSection, offsetM: number): number | null {
const points = section.samples
.filter(
(sample) =>
sample.valid !== false &&
typeof sample.offset_m === "number" &&
typeof sample.elevation_m === "number",
)
.map((sample) => ({ offset: sample.offset_m as number, elevation: sample.elevation_m as number }))
.sort((a, b) => a.offset - b.offset);
const first = points[0];
const last = points[points.length - 1];
if (!first || !last || points.length < 2) return null;
if (offsetM <= first.offset) return first.elevation;
if (offsetM >= last.offset) return last.elevation;
for (let i = 1; i < points.length; i += 1) {
const a = points[i - 1];
const b = points[i];
if (!a || !b) continue;
if (offsetM <= b.offset) {
const t = (offsetM - a.offset) / (b.offset - a.offset || 1);
return a.elevation + (b.elevation - a.elevation) * t;
}
}
return null;
}
/**
* .
* ( ) .
*/
function fillToeAt(section: CrossSection, side: "left" | "right"): RevetPoint | null {
const design = section.design;
const line = design?.design_line;
if (!design || !line || line.length < 2) return null;
const outward = side === "left" ? 1 : -1;
const edge = design.road_edges[side].offset_m;
const beyond = line
.filter((point) => (point.offset_m - edge) * outward > 1e-9)
.sort((a, b) => (a.offset_m - b.offset_m) * outward);
let previous: { offset: number; gap: number } | null = null;
for (const point of beyond) {
const ground = groundElevationAt(section, point.offset_m);
if (ground === null) continue;
const gap = point.elevation_m - ground;
if (previous && previous.gap > 0 && gap <= 0) {
const t = previous.gap / (previous.gap - gap || 1);
const offset = previous.offset + (point.offset_m - previous.offset) * t;
const elevation = groundElevationAt(section, offset);
if (elevation !== null) return { offset, elevation };
}
previous = { offset: point.offset_m, gap };
}
const tail = beyond[beyond.length - 1];
if (!tail) return null;
return { offset: tail.offset_m, elevation: tail.elevation_m };
}
/** 독립 기슭막이 단면. 제원·높이가 없거나 성토면 끝을 못 찾으면 null. */
export function computeRevetmentLayout(section: CrossSection): RevetmentLayout | null {
const spec = section.revetment;
const height = Number(spec?.height_m);
if (!spec || !Number.isFinite(height) || height <= 0) return null;
const side: "left" | "right" = spec.side === "우" ? "right" : "left";
const top = fillToeAt(section, side);
if (!top) return null;
const outward = side === "left" ? 1 : -1;
const bottomElevation = top.elevation - height - REVET_EMBED_DEPTH_M;
const frontTop = top.offset + outward * REVET_THICKNESS_M;
const frontBottom = frontTop + outward * REVET_LEAN_RATIO * (top.elevation - bottomElevation);
return {
side,
top,
polygon: [
{ offset: top.offset, elevation: top.elevation },
{ offset: frontTop, elevation: top.elevation },
{ offset: frontBottom, elevation: bottomElevation },
{ offset: top.offset, elevation: bottomElevation },
],
span: {
beforeM: Math.max(spec.anchor_m - spec.start_m, 0),
afterM: Math.max(spec.end_m - spec.anchor_m, 0),
},
};
}
/** 횡단 카드에 벽 단면을 그린다. 그렸으면 true. */
export function appendRevetmentOverlay(
svg: SVGElement,
layout: RevetmentLayout | null,
x: (offset: number) => number,
y: (elevation: number) => number,
): boolean {
if (!layout) return false;
const polygon = document.createElementNS(SVG_NS, "polygon");
polygon.setAttribute(
"points",
layout.polygon.map((point) => `${x(point.offset)},${y(point.elevation)}`).join(" "),
);
polygon.setAttribute("class", "b06-chart__revetment");
svg.append(polygon);
return true;
}
+3
View File
@@ -15,6 +15,7 @@ import {
type CrossAreaKey,
} from "./B06_Section_UI_Cross_Areas";
import { appendFordPavementOverlay } from "./B06_Section_UI_Cross_Ford_Pavement";
import { appendRevetmentOverlay, computeRevetmentLayout } from "./B06_Section_UI_Cross_Revetment";
import {
appendCrossDesignOverlay,
appendPavementOverlay,
@@ -441,6 +442,8 @@ export function createCrossSectionCard(
toDisplayY,
);
if (!fordPaved) appendPavementOverlay(plotLayer, section.design, x, toDisplayY);
// 독립 기슭막이 — 성토면 끝에 서는 벽. 3D도 같은 폴리곤을 스윕한다.
appendRevetmentOverlay(plotLayer, computeRevetmentLayout(section), x, toDisplayY);
appendCrossDesignOverlay(
plotLayer,
section.design,
@@ -164,6 +164,14 @@
stroke-width: 1;
}
/* 독립 기슭막이(2026-08-28): 성토면 끝에 서는 벽 — 배관 세트 기슭막이와 같은 색 계열 */
.b06-chart__revetment {
fill: color-mix(in srgb, #9b6bdc 22%, transparent);
stroke: #9b6bdc;
stroke-width: 1.4;
stroke-linejoin: round;
}
/* 물넘이포장(2026-08-28): 파인 노면 — 기존 계획고 점선 + 바닥 실선 + 진한 회색 빗금 포장 */
.b06-chart__ford-deck-plan {
fill: none;