feat(B05,B06): 물넘이 횡단 표현 + 포장 구간 수기 지정, 경사 자동 포장 폐지

2026-08-28 사용자 확정 스펙.

물넘이포장 — 노면을 판 자리로 그린다
- 서버가 _ford_pavement_set(월류 폭·월류 높이·바닥 경사)을 만들고 물넘이만
  span 연동을 켜 **범위 안 측점 전부**에 얹는다. 깊이가 없으면 None으로 두어
  화면이 그리지 않는다(수치를 지어내지 않는다).
- 횡단도: 기존 계획고 점선 + 물넘이 바닥 실선 + 진한 회색 빗금 포장.
  깊이는 노선 중심 기준, 바닥은 유입(상단측)이 높게 기운다. 경사를 비우면
  그 측점의 노면 횡단경사를 쓴다.
- 물넘이 폼에 "바닥 경사 유입→유출(%)" 칸을 추가하고, 월류 폭 기본값
  리터럴을 config_frontend 상수로 모아 서버 값과 짝지었다.

포장 — 사용자가 구간으로 지정한다
- 구조물 레지스트리 G군 pavement_concrete 를 되살려 기준측점 + 길이 + 전/후로
  받는다(기슭막이와 같은 폼). 길이 기본값은 0 = 미지정.
- pavement_ranges/paved_at 이 구간을 판정하고, enforce_pavement_ranges 가
  저장분이 비포장이어도 구간 안이면 포장으로 다시 계산한다(사용자 조작값 승계).
- **종단경사 자동 포장 적용을 없앴다** — paved=suggested 3곳 제거. 별표1-2 상한
  초과 경고(pavement_suggested 배지·근거 문구)는 그대로 남는다.
- 포장 구간이 물넘이를 통째로 품으면 모달로 알리고 앞/뒤로 나눠 저장하고,
  끝만 걸치면 값을 고치라고 안내하고 멈춘다.

700줄 제한: _compute_default_designs 를 Router_Design 으로 옮겼다(657/306줄).

검증: pytest 238 passed / 7 skipped(신규 9건). 공용 브라우저 실측 — 물넘이 임시
투입 시 240m 카드에만 파임 3요소가 그려지고(일반 포장 박스 0), 바닥이 계획고보다
0.4m 아래·노면 전폭 4.0m에 1.5% 기울기, 220·260은 비포장 유지. 겹침 규칙은
브라우저에서 모듈을 직접 불러 3분할·안내·취소를 확인했다. 실측용 임시 관 지점은
매번 원래 정본 4건으로 복구했다.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-08-28 12:16:38 +09:00
co-authored by Claude Opus 5
parent 10bb43b380
commit 80c2a265f9
12 changed files with 535 additions and 65 deletions
+29 -1
View File
@@ -296,6 +296,14 @@
"default": null,
"required": true,
"phase": "detail"
},
{
"key": "ford_slope_pct",
"label": "바닥 경사(유입→유출)",
"input": "number",
"unit": "%",
"default": null,
"required": false
}
]
},
@@ -1300,7 +1308,6 @@
},
{
"type_id": "pavement_concrete",
"enabled": false,
"group": "G",
"name": "콘크리트 포장",
"placement": "interval",
@@ -1310,6 +1317,27 @@
},
"drawing_views": ["profile", "cross_section", "quantity"],
"options": [
{
"key": "length_m",
"label": "길이",
"input": "number",
"unit": "m",
"default": 0
},
{
"key": "before_m",
"label": "기준측점 전",
"input": "number",
"unit": "m",
"default": 0
},
{
"key": "after_m",
"label": "기준측점 후",
"input": "number",
"unit": "m",
"default": 0
},
{
"key": "thickness_cm",
"label": "두께",
@@ -26,6 +26,10 @@
* 1m·길이 2m·각도 45°가 기본이며, 설치를 "없음"으로 바꾸면 제원 칸이 접힌다.
* ========================================================================== */
import {
FORD_BRIDGE_DEFAULT_WIDTH_M,
FORD_PAVEMENT_DEFAULT_WIDTH_M,
} from "@config/config_frontend";
import type {
DetailPipeInput,
PipeFacility,
@@ -266,6 +270,11 @@ export function createFacilityOptionsForm(
labeled("월류 폭 (m)", fordWidth),
labeled("월류 높이 (m)", fordHeight),
);
// 물넘이 바닥은 유입(상류)이 높고 유출이 낮게 기운다(2026-08-28 사용자 확정).
// 비우면 그 측점의 **노면 횡단경사**를 그대로 쓴다 — 횡단도가 판단한다.
const fordSlope = numberInput("0.1");
fordSlope.placeholder = "노면 기울기";
const fordSlopeRow = grid(labeled("바닥 경사 유입→유출 (%)", fordSlope));
const fordSummary = document.createElement("p");
fordSummary.className = "b05-drainage__facility-note";
/** 담당 유역 설계유량(㎥/s) — 개략 단면의 입력. 유역이 없으면 null. */
@@ -324,6 +333,7 @@ export function createFacilityOptionsForm(
wingInFields.root,
wingOutFields.root,
fordWidthRow,
fordSlopeRow,
fordRow,
fordSummary,
);
@@ -347,6 +357,8 @@ export function createFacilityOptionsForm(
const isFord = current === "ford_pavement" || current === "ford_bridge";
fordRow.hidden = current !== "ford_bridge";
fordWidthRow.hidden = !isFord;
// 바닥 경사는 물넘이포장만 쓴다 — 세월교는 구체 위 노면이라 파임이 없다.
fordSlopeRow.hidden = current !== "ford_pavement";
fordSummary.hidden = !isFord;
if (isFord) syncFordSummary();
if (isBox) syncBoxSize();
@@ -438,9 +450,12 @@ export function createFacilityOptionsForm(
fordCount.value = isFord ? text("pipe_count") : "";
fordWidth.value = text("ford_width_m");
fordHeight.value = text("ford_height_m");
// 월류 폭 기본값 — 세월교 10m·물넘이 포장 5m(2026-08-18 사용자 확정).
fordSlope.value = text("ford_slope_pct");
// 월류 폭 기본값 — 세월교 10m·물넘이 포장 5m(2026-08-18 사용자 확정, config 정의처).
if (!fordWidth.value && (facility === "ford_pavement" || facility === "ford_bridge")) {
fordWidth.value = facility === "ford_bridge" ? "10" : "5";
fordWidth.value = String(
facility === "ford_bridge" ? FORD_BRIDGE_DEFAULT_WIDTH_M : FORD_PAVEMENT_DEFAULT_WIDTH_M,
);
}
syncVisibility();
},
@@ -472,6 +487,7 @@ export function createFacilityOptionsForm(
} else if (current === "ford_pavement") {
putNumber(options, "ford_width_m", fordWidth.value);
putFordHeight(options);
putNumber(options, "ford_slope_pct", fordSlope.value);
} else if (current === "ford_bridge") {
options.pipe_kind = pipeMaterial.value;
options.pipe_diameter_mm = Number(pipeDiameter.value);
+23 -4
View File
@@ -28,6 +28,7 @@ import {
} from "./B05_Profile_UI_Drainage_Facility";
import { field, numberInput, select, stationFields } from "./B05_Profile_UI_Structures_Fields";
import { renderStructureList } from "./B05_Profile_UI_Structures_List";
import { splitPavementRange } from "./B05_Profile_UI_Structures_Pavement";
import { formatStation } from "./B05_Profile_Util_Station";
/** 구조물군 표시 이름. 리스트 기호(A~G)만으로는 무엇인지 알기 어렵다.
@@ -645,7 +646,7 @@ export function createStructuresSection(callbacks: StructuresCallbacks): Structu
callbacks.onChange([...structures]);
}
function commit(): void {
async function commit(): Promise<void> {
const type = currentType();
if (!type) return;
const step = interval();
@@ -745,11 +746,29 @@ export function createStructuresSection(callbacks: StructuresCallbacks): Structu
geometry: null,
};
// 포장 구간은 물넘이포장과 겹칠 수 없다 — 통째로 품으면 나누고, 끝에 걸치면 멈춘다
// (2026-08-28 사용자 확정). 다른 타입은 원래 구간 한 벌 그대로다.
const spans =
type.type_id === "pavement_concrete" && start !== null && end !== null
? await splitPavementRange(start, end, pipeFacilities)
: [{ start, end }];
if (!spans) return;
const records = spans.map((span) => ({
...base,
start_m: span.start,
end_m: span.end,
chainage_m:
span.start === null || span.end === null
? anchor
: Math.min(Math.max(anchor as number, span.start), span.end),
}));
if (editingId) {
const index = structures.findIndex((entry) => entry.structure_id === editingId);
if (index >= 0) structures[index] = { ...structures[index], ...base };
if (index >= 0) structures[index] = { ...structures[index], ...records[0] };
records.slice(1).forEach((record) => structures.push({ ...record, structure_id: null }));
} else {
structures.push({ ...base, structure_id: null });
records.forEach((record) => structures.push({ ...record, structure_id: null }));
}
loadForm(null);
emit();
@@ -781,7 +800,7 @@ export function createStructuresSection(callbacks: StructuresCallbacks): Structu
callbacks.onSelect(null);
}
});
primary.addEventListener("click", commit);
primary.addEventListener("click", () => void commit());
removeButton.addEventListener("click", () => {
// 임시 배치 중이면 취소와 같다 — 관을 물리고 추가 직전 상태로(2026-08-18).
if (tempPipeChainage !== null) {
@@ -0,0 +1,78 @@
/* =============================================================================
* B05_Profile_UI_Structures_Pavement.ts
* 포장 구간과 물넘이포장의 겹침 정리(2026-08-28 사용자 확정).
*
* 물넘이포장은 그 자체가 콘크리트 노면이라 포장 구간이 그 위를 덮을 수 없다.
* · 포장 구간이 물넘이를 **통째로 품으면** → 알리고 앞·뒤 두 구간으로 나눈다
* (물넘이가 가운데 한 구간 — 합쳐서 3구간).
* · **끝만 걸치면** → 어디까지 겹치는지 알리고 저장을 멈춘다. 사용자가 값을 고친다.
* ========================================================================== */
import { FORD_PAVEMENT_DEFAULT_WIDTH_M } from "@config/config_frontend";
import { showConfirmDialog } from "@ui/ui_template_elements";
import type { PipeFacilityItem } from "./B05_Profile_UI_Structures_Panel";
/** 누가거리 비교 허용오차(m) — 정본이 0.01m로 끊어 쓴다. */
const EPS = 0.005;
export interface Span {
start: number;
end: number;
}
/** 물넘이포장이 차지하는 누가거리 구간 — 기준 측점 ± 월류 폭/2. */
export function fordSpans(pipes: PipeFacilityItem[]): Span[] {
return pipes
.filter((pipe) => pipe.facility === "ford_pavement")
.map((pipe) => {
const raw = Number(pipe.options?.ford_width_m);
const width = Number.isFinite(raw) && raw > 0 ? raw : FORD_PAVEMENT_DEFAULT_WIDTH_M;
return { start: pipe.chainage_m - width / 2, end: pipe.chainage_m + width / 2 };
})
.sort((left, right) => left.start - right.start);
}
const format = (value: number): string => `${value.toFixed(2)}m`;
/**
* 포장 구간에서 물넘이 몫을 덜어낸다.
* 반환: 저장할 구간 목록. `null`이면 저장하지 않는다(사용자가 값을 고쳐야 한다).
*/
export async function splitPavementRange(
start: number,
end: number,
pipes: PipeFacilityItem[],
): Promise<Span[] | null> {
const overlaps = fordSpans(pipes).filter(
(ford) => ford.end > start + EPS && ford.start < end - EPS,
);
if (!overlaps.length) return [{ start, end }];
// 끝만 걸친 물넘이 — 자르면 사용자가 찍은 구간이 조용히 줄어든다. 값을 고치게 안내한다.
const partial = overlaps.find((ford) => ford.start <= start + EPS || ford.end >= end - EPS);
if (partial) {
await showConfirmDialog(
`지정한 포장 구간(${format(start)}~${format(end)})이 물넘이포장` +
`(${format(partial.start)}~${format(partial.end)})의 끝에 걸칩니다.\n` +
"물넘이는 그 자체가 포장이라 겹칠 수 없습니다 — 기준 측점·길이·전후 값을 고쳐 주세요.",
"확인",
);
return null;
}
const segments: Span[] = [];
let cursor = start;
for (const ford of overlaps) {
if (ford.start - cursor > EPS) segments.push({ start: cursor, end: ford.start });
cursor = ford.end;
}
if (end - cursor > EPS) segments.push({ start: cursor, end });
const inside = overlaps.map((ford) => `${format(ford.start)}~${format(ford.end)}`).join(", ");
const proceed = await showConfirmDialog(
`포장 구간 안에 물넘이포장(${inside})이 있습니다.\n` +
`물넘이는 그 자체가 포장이라 그 몫을 빼고 ${segments.length}개 구간으로 나눠 저장합니다.`,
"나눠 저장",
);
return proceed ? segments : null;
}
+6
View File
@@ -67,6 +67,10 @@ export type {
BalloonOffsets,
} from "@util/common_util_mass_haul_types";
/** 물넘이포장 제원 — 정의처는 렌더 모듈이다(사본 금지, 타입 전용 import라 순환 없음). */
import type { FordPavementSpec } from "./B06_Section_UI_Cross_Ford_Pavement";
export type { FordPavementSpec };
export interface SectionContextResponse {
project_id: string;
route_id: number | null;
@@ -276,6 +280,8 @@ export interface CrossSection extends SectionStation {
ford?: FordSet;
/** BOX암거 구체가 걸치는 측점의 세트 제원(있을 때만). */
box?: BoxSet;
/** 물넘이포장이 파는 노면 제원(있을 때만). 월류 폭 안의 측점 전부에 붙는다. */
ford_pavement?: FordPavementSpec;
}
export interface SectionDetailResponse {
+38 -6
View File
@@ -28,6 +28,7 @@ from B05_Profile.B05_Profile_Structures_Schema import structure_type_map
from common_util.common_util_drainage_pipes import (
PIPE_FACILITY_BOX,
PIPE_FACILITY_FORD_BRIDGE,
PIPE_FACILITY_FORD_PAVEMENT,
PIPE_FACILITY_PIPE,
parse_pipe_points,
)
@@ -42,12 +43,17 @@ logger = logging.getLogger(__name__)
# 관 지점과 횡단 측점을 같은 자리로 볼 허용 오차(m). 정본이 누가거리를 0.01m로 끊어 쓴다.
_CHAINAGE_TOLERANCE_M = 0.02
# 세트 종류 -> 횡단 dict 키. 그림이 다른 시설끼리 소비처가 섞이지 않게 나눠 둔다.
_SECTION_KEYS = {"ford": "ford", "box": "box", "ford_pavement": "ford_pavement"}
# 구조물 종류별 **인접 측점 연동**(2026-08-25 사용자 확정). 이름을 넣으면 도로 방향
# 폭(`span_m`)의 절반만큼 앞뒤 측점에도 같은 스펙이 붙어 횡단도가 한 벌 더 그려진다.
# 지금은 전부 끔 — 세월교·BOX암거 모두 소유 측점 한 곳에만 선다. 3D는 그 측점에서
# `span_m` 전체를 스윕하므로 길이가 줄지 않는다(인접 측점 기준으로 자르지 않는다).
# 세월교·BOX암거는 끔 — 소유 측점 한 곳에만 선다. 3D는 그 측점에서 `span_m` 전체를
# 스윕하므로 길이가 줄지 않는다(인접 측점 기준으로 자르지 않는다).
# 물넘이포장만 켠다 — 노면을 월류 폭만큼 파는 시설이라 **범위 안 모든 횡단도**에
# 파임이 그려져야 한다(2026-08-28 사용자 확정).
# ※ 기슭막이 연동은 이 경로가 아니다 — 프론트 `culvertOwnerFor`가 따로 판정한다.
_SPAN_LINKED_TYPES: frozenset[str] = frozenset()
_SPAN_LINKED_TYPES: frozenset[str] = frozenset({"ford_pavement"})
# ⚠ 교차 참조 ① 관 위 최소 토피(m). 임도 배수관 토피 규정이 지식DB에 없어 별표2
# 교량·암거 "복토 시 흙 두께 50㎝ 이상"을 끌어왔다. B05 계획고 하향 차단 기준이다.
@@ -75,6 +81,9 @@ FORD_SLAB_THICKNESS_M = 0.3
FORD_WALL_THICKNESS_M = 0.2
# 월류 폭 기본값(m) — B05 폼이 세월교에 채우는 값과 같다.
FORD_DEFAULT_WIDTH_M = 10.0
# 물넘이포장 월류 폭 기본값(m) — 같은 폼이 물넘이에 채우는 값(2026-08-18 사용자 확정,
# 지식DB 폭 수치 근거 없음).
FORD_PAVEMENT_DEFAULT_WIDTH_M = 5.0
# BOX암거 부재 두께(m) — 지식DB에 암거 부재 두께 기준이 없어 세월교 값을 승계한다
# (2026-08-25 사용자 확정). 측벽·상판·저판은 각각 벽 두께·바닥판 두께를 그대로 쓴다.
@@ -261,6 +270,27 @@ def _ford_set(options: dict[str, Any] | None) -> dict[str, Any]:
}
def _ford_pavement_set(options: dict[str, Any] | None) -> dict[str, Any]:
"""물넘이포장 1개소 — 노면을 월류 폭만큼, 노선 중심에서 월류 높이만큼 판 자리.
구조물을 얹는 것이 아니라 **계획고를 파낸 노면**이라 다른 세트와 형상이 다르다.
바닥은 유입(상류) 쪽이 높고 유출 쪽이 낮게 기운다 — 경사를 사용자가 비워 두면
화면이 노면 일반 기울기로 채운다(2026-08-28 사용자 확정). 파임 폭은 노견까지 전폭.
"""
defaults = _registry_defaults("ford_pavement")
values = dict(options or {})
width = _number(values.get("ford_width_m"), _number(defaults.get("ford_width_m"), None))
depth = _number(values.get("ford_height_m"), None)
slope = _number(values.get("ford_slope_pct"), None)
return {
"type": "ford_pavement",
"span_m": width if width and width > 0 else FORD_PAVEMENT_DEFAULT_WIDTH_M,
# 노선 중심에서 잰 깊이. 없으면 화면이 파임을 그리지 않는다(수치를 지어내지 않는다).
"depth_m": depth if depth and depth > 0 else None,
"slope_pct": slope,
}
def _box_set(options: dict[str, Any] | None) -> dict[str, Any]:
"""BOX암거 1개소의 세트 제원(구체 + 날개벽 연장).
@@ -305,13 +335,15 @@ def load_culvert_sets(project_root: Path) -> dict[float, dict[str, Any]]:
return {}
sets: dict[float, dict[str, Any]] = {}
for point in parse_pipe_points(document.get("points")):
# 물넘이포장은 그림이 다르다 — 배관·세월교·BOX암거만 그린다.
if point.facility == PIPE_FACILITY_PIPE:
spec = _culvert_set(point.options)
elif point.facility == PIPE_FACILITY_FORD_BRIDGE:
spec = _ford_set(point.options)
elif point.facility == PIPE_FACILITY_BOX:
spec = _box_set(point.options)
elif point.facility == PIPE_FACILITY_FORD_PAVEMENT:
# 구조물이 아니라 파인 노면이다 — 스펙 모양도 소비처도 다르다(2026-08-28).
spec = _ford_pavement_set(point.options)
else:
continue
sets[round(float(point.chainage_m), 2)] = spec
@@ -334,9 +366,9 @@ def attach_culvert_sets(project_root: Path, cross_sections: list[dict[str, Any]]
if spec.get("type") in _SPAN_LINKED_TYPES:
reach += (_number(spec.get("span_m"), 0.0) or 0.0) / 2
if abs(chainage - pipe_chainage) <= reach:
# 세월교·BOX암거는 배수관과 그림이 달라 키를 나눈다 — 소비처가 섞이지 않는다.
# 세월교·BOX암거·물넘이는 배수관과 그림이 달라 키를 나눈다 — 소비처가 섞이지 않는다.
kind = spec.get("type")
section["ford" if kind == "ford" else "box" if kind == "box" else "culvert"] = spec
section[_SECTION_KEYS.get(str(kind), "culvert")] = spec
attached += 1
break
return attached
+16 -48
View File
@@ -41,6 +41,10 @@ from B06_Section.B06_Section_Router_Design import (
)
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,
pavement_ranges as _pavement_ranges,
paved_at as _paved_at,
)
from B06_Section.B06_Section_Router_Design import (
default_section_modes as _default_section_modes,
@@ -302,10 +306,20 @@ async def get_section_detail(
if abs(float(section.get("chainage_m", 0.0)) - record["chainage_m"]) < 0.01:
section["design"] = record["design"]
break
# 포장 구간·물넘이 범위는 저장분이 비포장이어도 포장으로 맞춘다(2026-08-28).
await asyncio.to_thread(
_enforce_pavement_ranges,
detail["longitudinal"],
detail["cross_sections"],
project_root,
)
# 지정값이 없는 측점은 기본값(토사/좌절토)으로 즉석 계산해 프리뷰로 채운다.
# (미저장 프리뷰: 실제 저장은 사용자가 카드를 조작하거나 확정할 때 이뤄진다.)
await asyncio.to_thread(
_attach_default_designs, detail["longitudinal"], detail["cross_sections"]
_attach_default_designs,
detail["longitudinal"],
detail["cross_sections"],
project_root,
)
return SectionDetailResponse(**detail, balloon_offsets=_read_balloon_offsets(longitudinal))
except FileNotFoundError as exc:
@@ -510,6 +524,7 @@ async def preview_cross_designs(
designs,
request.standard_cross_section,
request.rock_boundary_offsets,
project_root,
)
await asyncio.to_thread(rebuild)
@@ -548,53 +563,6 @@ async def preview_cross_designs(
)
def _compute_default_designs(
project_root: Path,
longitudinal_file_path: str,
chainages: list[float],
standard: dict[str, Any] | None = None,
) -> list[tuple[float, dict[str, Any]]]:
"""미지정 측점들을 기본값(리핑암 + 암반 경계 0.5m/상단측 절토)으로 계산한 목록을 만든다.
standard가 오면(확정 요청의 패널 편집값) 값으로 표준단면 기하를 계산한다.
계획고 부재 등으로 계산 불가한 측점은 조용히 건너뛴다(확정을 막지 않기 위함).
"""
root = project_root.resolve()
longitudinal_path = (root / longitudinal_file_path).resolve()
if root not in longitudinal_path.parents or not longitudinal_path.is_file():
return []
longitudinal = json.loads(longitudinal_path.read_text(encoding="utf-8"))
cross_dir = longitudinal_path.parent.parent / "cross_sections"
default_modes = _default_section_modes(longitudinal)
pavement = _pavement_suggestions(longitudinal)
results: list[tuple[float, dict[str, Any]]] = []
for chainage_m in chainages:
cross_path = cross_dir / cross_filename(chainage_m)
if not cross_path.is_file():
continue
try:
cross = json.loads(cross_path.read_text(encoding="utf-8"))
samples = cross.get("samples")
if not isinstance(samples, list):
continue
suggested = pavement.get(round(chainage_m, 3), False)
design = compute_cross_design(
samples,
design_elevation_from_longitudinal(longitudinal, chainage_m),
ground_type="ripping_rock",
section_mode=default_modes.get(round(chainage_m, 3), "left_cut"),
paved=suggested,
standard=standard,
rock_boundary_offset_m=STANDARD_ROCK_BOUNDARY_DEFAULT_OFFSET_M,
)
design["status"] = "provisional"
design["pavement_suggested"] = suggested
results.append((chainage_m, design))
except (ValueError, KeyError, OSError, json.JSONDecodeError):
continue
return results
@router.post("/{project_id}/sections/{route_id}/cross-design", response_model=CrossDesignResponse)
async def compute_cross_section_design(
project_id: UUID, route_id: int, request: CrossDesignRequest
+162 -3
View File
@@ -1,14 +1,20 @@
"""B06 라우터의 횡단 설계 파일 읽기와 프리뷰 계산."""
import json
import logging
from pathlib import Path
from typing import Any
from B05_Profile.B05_Profile_Engine_Sections import cross_filename
from B05_Profile.B05_Profile_Structures_Repository import load_structures
from B05_Profile.B05_Profile_Structures_Schema import structure_type_map
from B06_Section.B06_Section_Engine_Culvert import load_culvert_sets
from B06_Section.B06_Section_Engine_Design import compute_cross_design
from common_util.common_util_route_profile import design_elevation_from_longitudinal
from config.config_system import STANDARD_ROCK_BOUNDARY_DEFAULT_OFFSET_M
logger = logging.getLogger(__name__)
PREVIEW_DESIGN_FIELDS = (
"ground_type",
"roadbed_width_m",
@@ -32,6 +38,104 @@ def pavement_suggestions(longitudinal: dict[str, Any]) -> dict[float, bool]:
return mapping
def pavement_ranges(project_root: Path) -> list[tuple[float, float]]:
"""포장으로 볼 누가거리 구간 — 구조물 정본 G군 + 물넘이포장(포장 필수).
포장 지정은 **사용자가 구간으로 준다**(2026-08-28 사용자 확정). 종단경사 자동 판정은
이상 포장을 켜지 않는다 경고 표기(`pavement_suggested`) 남는다.
물넘이포장은 콘크리트 등으로 노면을 만드는 시설이라 범위 안이 항상 포장이다.
읽기 실패는 비치명 목록이면 사용자 지정이 없는 것과 같다.
"""
ranges: list[tuple[float, float]] = []
try:
types = structure_type_map()
for structure in load_structures(str(project_root))[1]:
definition = types.get(structure.type_id)
if definition is None or definition.group != "G":
continue
anchor = structure.chainage_m
start = structure.start_m if structure.start_m is not None else anchor
end = structure.end_m if structure.end_m is not None else anchor
if start is None or end is None:
continue
ranges.append((min(float(start), float(end)), max(float(start), float(end))))
except Exception: # noqa: BLE001 — 정본을 못 읽어도 설계 계산은 이어 간다
logger.exception("B06 포장 구간을 읽지 못했습니다 (사용자 지정 없음으로 본다)")
for chainage, spec in load_culvert_sets(project_root).items():
if spec.get("type") != "ford_pavement":
continue
half = float(spec.get("span_m") or 0.0) / 2.0
ranges.append((chainage - half, chainage + half))
return ranges
def paved_at(chainage_m: float, ranges: list[tuple[float, float]], stored: Any = None) -> bool:
"""이 측점을 포장으로 볼 것인가 — 구간 안이면 강제, 밖이면 사용자 저장값(기본 비포장)."""
for start, end in ranges:
if start - 1e-9 <= chainage_m <= end + 1e-9:
return True
return bool(stored) if isinstance(stored, bool) else False
# 사용자 조작값 — 포장 강제로 다시 계산해도 그대로 승계한다.
_USER_TOUCHED_KEYS = (
"display_half_width_m",
"inlet_structure",
"basin_adjust",
"revet_adjust",
"ford_adjust",
"box_adjust",
"extra_wall_counts",
"revet_link_detached",
"revet_follow_grade",
)
def enforce_pavement_ranges(
longitudinal: dict[str, Any], cross_sections: list[dict[str, Any]], project_root: Path
) -> int:
"""포장 구간·물넘이 범위 안 측점을 포장으로 맞춘다 — 저장분이 비포장이어도 그렇다.
사용자가 구간을 지정하면 횡단도에 **자동 반영**된다(2026-08-28 사용자 확정).
포장 여부는 횡단경사·포장층을 바꾸므로 플래그만 갈아 끼우지 않고 다시 계산한다.
사용자 조작값(반폭·구조물 조정) 그대로 승계한다.
"""
ranges = pavement_ranges(project_root)
if not ranges:
return 0
changed = 0
for section in cross_sections:
design = section.get("design")
if not isinstance(design, dict) or design.get("paved"):
continue
chainage = float(section.get("chainage_m", 0.0))
if not paved_at(chainage, ranges):
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=True,
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"),
)
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 default_section_modes(longitudinal: dict[str, Any]) -> dict[float, str]:
mapping: dict[float, str] = {}
stations = longitudinal.get("stations")
@@ -66,10 +170,13 @@ def read_cross_design_inputs(
def attach_default_designs(
longitudinal: dict[str, Any], cross_sections: list[dict[str, Any]]
longitudinal: dict[str, Any],
cross_sections: list[dict[str, Any]],
project_root: Path | None = None,
) -> None:
modes = default_section_modes(longitudinal)
pavement = pavement_suggestions(longitudinal)
paved_ranges = pavement_ranges(project_root) if project_root else []
for section in cross_sections:
if section.get("design"):
continue
@@ -81,7 +188,8 @@ def attach_default_designs(
design_elevation_from_longitudinal(longitudinal, chainage),
ground_type="ripping_rock",
section_mode=modes.get(round(chainage, 3), "left_cut"),
paved=suggested,
# 포장은 사용자 구간 지정만 켠다 — 경사 제안은 경고로만 남는다(2026-08-28).
paved=paved_at(chainage, paved_ranges),
rock_boundary_offset_m=STANDARD_ROCK_BOUNDARY_DEFAULT_OFFSET_M,
)
design.update(status="provisional", pavement_suggested=suggested)
@@ -96,9 +204,11 @@ def recompute_designs_for_alignment(
stored_designs: list[dict[str, Any]],
standard: dict[str, Any] | None,
rock_boundary_offsets: dict[str, float] | None = None,
project_root: Path | None = None,
) -> None:
modes = default_section_modes(longitudinal)
pavement = pavement_suggestions(longitudinal)
paved_ranges = pavement_ranges(project_root) if project_root else []
stored_by_chainage = {
round(float(record["chainage_m"]), 3): (record.get("design") or {})
for record in stored_designs
@@ -122,7 +232,7 @@ def recompute_designs_for_alignment(
section_mode=str(stored.get("section_mode") or modes.get(key, "left_cut")),
ditch_side=stored.get("ditch_side"),
ditch_type=str(stored.get("ditch_type") or "standard"),
paved=bool(stored.get("paved", suggested)),
paved=paved_at(chainage, paved_ranges, stored.get("paved")),
standard=standard,
rock_boundary_offset_m=session_offsets.get(
key,
@@ -145,3 +255,52 @@ def recompute_designs_for_alignment(
if stored.get("box_adjust") is not None:
design["box_adjust"] = stored["box_adjust"]
section["design"] = design
def compute_default_designs(
project_root: Path,
longitudinal_file_path: str,
chainages: list[float],
standard: dict[str, Any] | None = None,
) -> list[tuple[float, dict[str, Any]]]:
"""미지정 측점들을 기본값(리핑암 + 암반 경계 0.5m/상단측 절토)으로 계산한 목록을 만든다.
standard가 오면(확정 요청의 패널 편집값) 값으로 표준단면 기하를 계산한다.
계획고 부재 등으로 계산 불가한 측점은 조용히 건너뛴다(확정을 막지 않기 위함).
"""
root = project_root.resolve()
longitudinal_path = (root / longitudinal_file_path).resolve()
if root not in longitudinal_path.parents or not longitudinal_path.is_file():
return []
longitudinal = json.loads(longitudinal_path.read_text(encoding="utf-8"))
cross_dir = longitudinal_path.parent.parent / "cross_sections"
default_modes = default_section_modes(longitudinal)
pavement = pavement_suggestions(longitudinal)
paved_ranges = pavement_ranges(root)
results: list[tuple[float, dict[str, Any]]] = []
for chainage_m in chainages:
cross_path = cross_dir / cross_filename(chainage_m)
if not cross_path.is_file():
continue
try:
cross = json.loads(cross_path.read_text(encoding="utf-8"))
samples = cross.get("samples")
if not isinstance(samples, list):
continue
suggested = pavement.get(round(chainage_m, 3), False)
design = compute_cross_design(
samples,
design_elevation_from_longitudinal(longitudinal, chainage_m),
ground_type="ripping_rock",
section_mode=default_modes.get(round(chainage_m, 3), "left_cut"),
# 포장은 사용자 구간 지정만 켠다 — 경사 제안은 경고로만 남는다(2026-08-28).
paved=paved_at(chainage_m, paved_ranges),
standard=standard,
rock_boundary_offset_m=STANDARD_ROCK_BOUNDARY_DEFAULT_OFFSET_M,
)
design["status"] = "provisional"
design["pavement_suggested"] = suggested
results.append((chainage_m, design))
except (ValueError, KeyError, OSError, json.JSONDecodeError):
continue
return results
@@ -0,0 +1,122 @@
/* =============================================================================
* B06_Section_UI_Cross_Ford_Pavement.ts
* (2026-08-28 ).
*
* ** **. .
* · = ****.
* · = ** ** (`depth_m`).
* · **() ** .
* (`cross_slope_pct`) .
* · ****, ****.
* · + .
*
* `section_mode`( = = ) .
* ========================================================================== */
import type { CrossDesign } from "./B06_Section_Api_Fetch";
const SVG_NS = "http://www.w3.org/2000/svg";
/** 빗금 패턴 id는 문서 안에서 유일해야 한다 — 카드마다 하나씩 번호를 준다. */
let hatchSeq = 0;
/** 백엔드 `section.ford_pavement` 제원(치수 결정은 서버 몫 — 여기서는 좌표만 만든다). */
export interface FordPavementSpec {
span_m: number;
/** 노선 중심에서 잰 파임 깊이(m). 없으면 그리지 않는다 — 수치를 지어내지 않는다. */
depth_m: number | null;
/** 유입 → 유출 바닥 경사(%). 비우면 노면 횡단경사를 쓴다. */
slope_pct: number | null;
}
interface Edge {
offset_m: number;
elevation_m: number;
}
/** 유입측(상류) 부호 — 그 방향으로 갈수록 바닥이 높아진다. */
function inflowSign(design: CrossDesign, offsetM: number): number {
const inflowLeft = design.section_mode !== "right_cut";
const towardLeft = offsetM > 0;
return towardLeft === inflowLeft ? 1 : -1;
}
function bottomAt(design: CrossDesign, spec: FordPavementSpec, edge: Edge): 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);
}
function line(points: string[], className: string): SVGPolylineElement {
const polyline = document.createElementNS(SVG_NS, "polyline");
polyline.setAttribute("points", points.join(" "));
polyline.setAttribute("class", className);
return polyline;
}
/**
* . true
* ( ).
*/
export function appendFordPavementOverlay(
svg: SVGElement,
spec: FordPavementSpec | undefined,
design: CrossDesign,
x: (offset: number) => number,
y: (elevation: number) => number,
): boolean {
if (!spec || !spec.depth_m || !design.road_edges) return false;
const { left, right } = design.road_edges;
const bottomLeft = bottomAt(design, spec, left);
const bottomRight = bottomAt(design, spec, right);
// ① 기존 계획고(점선) — 파기 전 노면이 어디였는지 남긴다.
svg.append(
line(
[
`${x(left.offset_m)},${y(left.elevation_m)}`,
`${x(right.offset_m)},${y(right.elevation_m)}`,
],
"b06-chart__ford-deck-plan",
),
);
// ② 포장층 — 바닥선에서 두께만큼 아래로. 일반 포장과 색·빗금으로 구분한다.
const thickness = design.pavement_thickness_m ?? 0.2;
const hatchId = `b06-ford-hatch-${(hatchSeq += 1)}`;
const defs = document.createElementNS(SVG_NS, "defs");
const pattern = document.createElementNS(SVG_NS, "pattern");
pattern.setAttribute("id", hatchId);
pattern.setAttribute("patternUnits", "userSpaceOnUse");
pattern.setAttribute("width", "6");
pattern.setAttribute("height", "6");
const stroke = document.createElementNS(SVG_NS, "path");
stroke.setAttribute("d", "M0,6 L6,0");
stroke.setAttribute("class", "b06-chart__ford-pavement-hatch");
pattern.append(stroke);
defs.append(pattern);
svg.append(defs);
const polygon = document.createElementNS(SVG_NS, "polygon");
polygon.setAttribute(
"points",
[
`${x(left.offset_m)},${y(bottomLeft)}`,
`${x(right.offset_m)},${y(bottomRight)}`,
`${x(right.offset_m)},${y(bottomRight - thickness)}`,
`${x(left.offset_m)},${y(bottomLeft - thickness)}`,
].join(" "),
);
polygon.setAttribute("class", "b06-chart__ford-pavement");
polygon.setAttribute("fill", `url(#${hatchId})`);
svg.append(polygon);
// ③ 물넘이 바닥(실선) — 횡단 기준선이라 가장 위에 올린다.
svg.append(
line(
[`${x(left.offset_m)},${y(bottomLeft)}`, `${x(right.offset_m)},${y(bottomRight)}`],
"b06-chart__ford-deck",
),
);
return true;
}
+11 -1
View File
@@ -14,6 +14,7 @@ import {
buildAreaReadout,
type CrossAreaKey,
} from "./B06_Section_UI_Cross_Areas";
import { appendFordPavementOverlay } from "./B06_Section_UI_Cross_Ford_Pavement";
import {
appendCrossDesignOverlay,
appendPavementOverlay,
@@ -430,7 +431,16 @@ export function createCrossSectionCard(
const boxLayout = computeBoxLayout(section, section.samples, bodies.boxAdjust());
const fordLayout = computeFordLayout(section, section.samples, bodies.fordAdjust());
// 포장층 → 설계선 → 암 경계선 순으로 겹쳐, 설계선이 포장 박스 위에 오게 한다.
appendPavementOverlay(plotLayer, section.design, x, toDisplayY);
// 물넘이포장은 노면 자체가 파인 자리라 포장층도 그 바닥을 따른다 — 그렸으면 일반
// 포장 박스는 건너뛴다(두 겹으로 깔리면 색 구분이 사라진다).
const fordPaved = appendFordPavementOverlay(
plotLayer,
section.ford_pavement,
section.design,
x,
toDisplayY,
);
if (!fordPaved) appendPavementOverlay(plotLayer, section.design, x, toDisplayY);
appendCrossDesignOverlay(
plotLayer,
section.design,
@@ -164,6 +164,32 @@
stroke-width: 1;
}
/* 물넘이포장(2026-08-28): 파인 노면 — 기존 계획고 점선 + 바닥 실선 + 진한 회색 빗금 포장 */
.b06-chart__ford-deck-plan {
fill: none;
stroke: var(--color-text-secondary);
stroke-width: 1;
stroke-dasharray: 5 4;
}
.b06-chart__ford-deck {
fill: none;
stroke: var(--color-text-primary);
stroke-width: 1.8;
stroke-linejoin: round;
}
.b06-chart__ford-pavement {
stroke: var(--color-text-primary);
stroke-width: 1;
}
.b06-chart__ford-pavement-hatch {
stroke: color-mix(in srgb, var(--color-text-primary) 70%, transparent);
stroke-width: 1.2;
fill: none;
}
/* 배수관 세트(2026-08-19): 배관·기슭막이·돌붙임 — 분리 도형 3종 + 집수정 라벨 */
/* 관은 실무 도면처럼 점선 윤곽 2줄 느낌 — 채움은 식별용으로만 아주 옅게. */
.b06-chart__culvert-pipe {
+6
View File
@@ -147,6 +147,12 @@ export const PIPE_DEFAULT_TYPE = "파형강관";
/** 자동 지정 기본 관경 — 별표2 (나) 예외 하한 800mm. 유효직경이 더 크면 바로 위 규격. */
export const PIPE_DEFAULT_DIAMETER_MM = 800;
/** (m) 10m· 5m (2026-08-18 , DB ).
* `B06_Section_Engine_Culvert.FORD_DEFAULT_WIDTH_M`·`FORD_PAVEMENT_DEFAULT_WIDTH_M`
* . */
export const FORD_BRIDGE_DEFAULT_WIDTH_M = 10;
export const FORD_PAVEMENT_DEFAULT_WIDTH_M = 5;
/** KDS 2.7-1 "" .
* `config_system.FORD_MANNING_N` . */
export const FORD_MANNING_N = 0.017;