refactor(511-B B-4 기본 설계 셋 Node · preview 걷음): run_calc = 늘 떠 있는 Node(못 띄우면 새 프로세스) · 기본 설계 셋(체인 · 노선 저장 이월 · [측점 만들기] · GET 빈 측점 · B05 확정 측구 방향 역반영) = 측점 묶음 Node cross.design 한 번 · 포장 · 기울임 · 세월교 하강 = TS facilityRanges(cross.design facilities · Node 못 부르면 파이썬 구간 짝) · /cross-design/preview 걷음(창구 · 스키마 · recompute_designs_for_alignment · 화면 refreshFromServer) · crossOptions = logic/B06_Section_Cross_Save.ts(다음 단계 [저장] 횡단 한 바퀴 짝 · 아직 안 부름) · 시험 266 · 475_3 · masshaul_single_source 기대 맞춤 — 요구 320-4

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-22 14:18:09 +09:00
co-authored by Claude Opus 5.5
parent f6abe881a2
commit f40ff98819
14 changed files with 496 additions and 543 deletions
-62
View File
@@ -18,7 +18,6 @@
import { API_BASE_URL, API_TIMEOUT_MS } from "@config/config_frontend";
import { t as L } from "@ui/ui_template_locale";
import type { CrossDesignChoice } from "./B06_Section_Cross_Design_Session";
import type {
CrossDesign,
CrossSection,
@@ -239,67 +238,6 @@ export async function getCompanyStandard(
);
}
/** 계획선 편집 델타(자동 선형 대비 계획고 델타 + 종단곡선 반경). B05 편집 스토어와 같은 모양. */
export interface ProfileAlignmentEdits {
station_offsets: Record<string, number>;
curve_radii: Record<string, number>;
}
/**
* 계획선 편집 프리뷰 결과 — **바뀌는 것만** 온다.
* 횡단 지반선 원시 샘플은 계획고와 무관해 그대로이므로 싣지 않는다(응답 1MB → 수십 KB).
*/
export interface CrossDesignPreviewResponse {
status: string;
/** 유토곡선이 읽는 설계 필드만 담긴 부분 갱신값(설계선 좌표 등은 오지 않는다). */
designs: Array<{ chainage_m: number; design: Partial<CrossDesign> }>;
}
/**
* 계획선 편집을 반영해 **계획선 + 전 측점 횡단 설계**를 다시 계산해 받는다(서버 저장 없음).
*
* B05에서 계획고를 끄는 동안 횡단 단면적이 함께 움직여야 횡단 기준 유토곡선이 따라온다.
* 측점마다 따로 부르면 수십 번 왕복하므로 한 번에 계산한다. 영속화는 각 페이지의
* 임시저장·확정이 맡는다.
*/
export async function previewCrossDesigns(
projectId: string,
routeId: number,
edits: ProfileAlignmentEdits,
standardCrossSection?: StandardCrossSection,
options?: {
/** true면 설계 전체(설계선 좌표 포함)를 받는다 — B06 진입 시 stale 일괄 재계산용. */
fullDesigns?: boolean;
/** 측점별 암 경계 오프셋 세션값(chainage 키 → m). DB 저장분보다 우선한다. */
rockBoundaryOffsets?: Record<string, number>;
/** 측점별 소단 제원(chainage 키 → 폭·간격·기울기). 값이 없는 측점은 소단 없음. */
berms?: Record<
string,
{ width_m: number; interval_m: number; slope_deg: number; depth_m?: number }
>;
/** 측점별 암 절토 경사비(chainage 키 → 1:n 의 n). 0 은 「표준값을 씀」이다. */
cutSlopeRatios?: Record<string, number>;
/** 측점별 카드 선택(chainage 키 → 지반 · 측구 · 복합경사 …) 세션값 — 저장분 위에 얹음(439). */
designChoices?: Record<string, CrossDesignChoice>;
},
): Promise<CrossDesignPreviewResponse> {
return requestJson<CrossDesignPreviewResponse>(
`/projects/${projectId}/sections/${routeId}/cross-design/preview`,
{
method: "POST",
body: JSON.stringify({
...edits,
standard_cross_section: standardCrossSection ?? null,
full_designs: options?.fullDesigns ?? false,
rock_boundary_offsets: options?.rockBoundaryOffsets ?? null,
berms: options?.berms ?? null,
cut_slope_ratios: options?.cutSlopeRatios ?? null,
design_choices: options?.designChoices ?? null,
}),
},
);
}
/** B07 횡단 수량표 한 장(138-4 · 서버 `cross_quantity_sheet`) — 값 · 글 · 칸 배치. */
export interface CrossQuantitySheet {
quantity_table: Record<string, number | null>;
+3 -85
View File
@@ -10,7 +10,7 @@
*
* 그래서 설계 계산은 `common_util_cross_design.ts`(파이썬 `B06_Section_Engine_Design.py`
* 의 미러)로 옮겼고, 여기서는 **입력을 모아 전 측점을 돌리고 제자리 반영**만 한다.
* 서버 프리뷰는 선형 저장분이 없어 계획고를 못 푸는 **옛 데이터 폴백**으로만 남는다.
* 옛 데이터 서버 프리뷰(`/cross-design/preview`)는 걷음(511-B B-4) — 선형 저장분이 없으면 셈 없음.
*
* ⚠ 두 벌 계산 주의 — TS 미러(`common_util_cross_design*.ts`)와 파이썬 엔진
* (`B06_Section_Engine_Design.py`·`B06_Section_Engine_Areas.py`)은 한 벌이다.
@@ -26,11 +26,9 @@
import { computeCrossDesign } from "@util/common_util_cross_design";
import type { CrossDesignOptions, StandardCrossSectionSpec } from "@util/common_util_cross_design";
import { previewCrossDesigns } from "./B06_Section_Api_Fetch";
import type { CrossSection, SectionDetailResponse } from "./B06_Section_Api_Fetch";
import {
bermSpecAt,
readBermSpans,
readCutSlopeSession,
readRockBoundarySession,
} from "./B06_Section_UI_Page_Persist";
@@ -50,12 +48,7 @@ import {
readBermDepths,
readShoulderExtends,
} from "./B06_Section_Cross_Design_Session";
import {
effectiveStandardCross,
readRockBoundaryDefault,
readStandardCrossSession,
readStoredStandard,
} from "./B06_Section_UI_Standard_Panel";
import { effectiveStandardCross, readRockBoundaryDefault } from "./B06_Section_UI_Standard_Panel";
import {
buildAlignment,
planElevationAt,
@@ -90,11 +83,6 @@ export interface CrossRefreshInput {
edits: CrossRefreshEdits;
/** 화면이 쥔 계획선(측점 한 벌 · 342-1) — 없으면 종단도가 맡긴 선 → 저장분 + `edits`. */
alignment?: ProfileAlignment | null;
/**
* 결과를 **아직 써도 되는지** 묻는다(false면 반영하지 않는다). 로컬 계산은 즉시 끝나
* 늦은 응답이 없지만, 옛 데이터 폴백(서버 프리뷰)에서는 여전히 문지기가 필요하다.
*/
shouldApply?: () => boolean;
}
/** 계산이 만들지 않는 **사용자 값** — 목록 한 벌 = `logic/B06_Section_Design_Keys`(서버 Node 입구도 · 472-6). */
@@ -176,7 +164,7 @@ export async function refreshCrossDesigns(input: CrossRefreshInput): Promise<num
useStationKeys(input.projectId, input.routeId, input.detail); // 세션 고침을 이 측점 목록으로 읽음(344-3)
await withPipeOptions(input.detail, input.projectId, true); // 관 옵션을 고친 뒤도 면적이 따라옴(387-4b)
const before = new Map(sections.map((section) => [section, JSON.stringify(section.design)]));
if (!refreshLocally(input)) await refreshFromServer(input);
refreshLocally(input); // 선형 저장분 · 표준값이 없으면 셈 없음(옛 서버 폴백 걷음 · 511-B B-4)
// 구조물이 선 측점은 폐회로 면적이 정본이다 — **이 자리 한 곳**에서 늘 다시 얹는다(387-4 · 카드 그리기는
// 안 얹음 · 설계가 그대로여도 조정창 4축 · 구조물 제원이 바뀌면 면적이 바뀜 · 6 ms/67 측점).
// 계산 자리 quantity = server 면 잠잠 뒤 서버 값만 · 밀린 부름은 안 얹음(새 부름이 얹음 · 511-A)
@@ -219,33 +207,6 @@ function readRockOffsets(projectId: string, routeId: number): Map<number, number
return offsets;
}
/**
* 소단 구간을 **측점별 제원**으로 편다 — 서버는 측점키로 받기 때문이다.
* 구간 자체는 세션에 그대로 두어 「어디부터 어디까지 놓았나」를 잃지 않는다.
*/
function bermPayload(
projectId: string,
routeId: number,
detail: SectionDetailResponse,
): Record<string, BermSessionSpec & Partial<Record<`berm_depth_${Side}_m`, number>>> {
const spans = readBermSpans(projectId, routeId);
const out: Record<string, BermSessionSpec & Partial<Record<`berm_depth_${Side}_m`, number>>> = {};
if (!spans.length) return out;
const depthAt = bermDepthReader(projectId, routeId);
for (const section of detail.cross_sections) {
const spec = bermSpecAt(spans, section.chainage_m);
if (!spec) continue;
const design = (section.design ?? {}) as Record<string, unknown>;
const sides: Partial<Record<`berm_depth_${Side}_m`, number>> = {};
for (const side of SIDES) {
const own = depthAt(section.chainage_m, design, side);
if (own !== null) sides[`berm_depth_${side}_m`] = own;
}
out[rockKey(section.chainage_m).toFixed(2)] = { ...spec, ...sides };
}
return out;
}
type Side = "left" | "right";
const SIDES: Side[] = ["left", "right"];
@@ -506,46 +467,3 @@ function refreshLocally(input: CrossRefreshInput): number[] | null {
}
return updated;
}
/** 옛 데이터 폴백 — 선형 저장분이 없어 브라우저가 계획고를 풀 수 없을 때만 쓴다. */
async function refreshFromServer(input: CrossRefreshInput): Promise<number[]> {
const { projectId, routeId, detail, edits, shouldApply } = input;
const choices = crossDesignChoices(projectId, routeId);
const response = await previewCrossDesigns(
projectId,
routeId,
edits,
// 세션 고침 → 저장분(세션에 안 베낌 · 272) — 둘 다 없으면 서버 config
readStandardCrossSession(projectId) ?? readStoredStandard(projectId) ?? undefined,
{
fullDesigns: true,
rockBoundaryOffsets: readRockBoundarySession(projectId, routeId),
berms: bermPayload(projectId, routeId, detail),
// 측점별 암 절토 경사도 세션값이라 함께 싣는다 — 안 실으면 서버 폴백에서만
// 사용자 경사가 조용히 표준값으로 되돌아간다(2026-09-07).
cutSlopeRatios: readCutSlopeSession(projectId, routeId),
// 카드 단추 선택(지반 · 측구 · 복합경사)도 — 안 실으면 폴백에서만 단추가 안 먹음(439) · 열쇠 = 측점 자리
designChoices: Object.fromEntries(
detail.cross_sections.flatMap((section) => {
const choice = choices.get(rockKey(section.chainage_m));
return choice ? [[section.chainage_m.toFixed(3), choice]] : [];
}),
),
},
);
if (shouldApply && !shouldApply()) return [];
const designByChainage = new Map(
response.designs.map((entry) => [entry.chainage_m.toFixed(3), entry.design]),
);
const updated: number[] = [];
for (const section of detail.cross_sections) {
const next = designByChainage.get(section.chainage_m.toFixed(3));
if (!next) continue;
section.design = preserveUserFields(
next as NonNullable<CrossSection["design"]>,
section.design,
);
updated.push(section.chainage_m);
}
return updated;
}
+61 -6
View File
@@ -52,10 +52,59 @@ def _python(job: DesignJob, standard: dict[str, Any] | None) -> dict[str, Any] |
return None
# 시설 구간(포장 · 기울임 · 세월교 하강) = TS `facilityRanges` — (프로젝트 뿌리, 측점 누가거리)
Facilities = tuple[Path, list[float]]
def facility_inputs(project_root: Path) -> dict[str, list[dict[str, Any]]]:
"""TS `facilityRanges` 입력 — 관 = 지금 노선에 얹은 관 지점(`route_pipe_points`) · 구조물 정본.
못 읽은 쪽은 빈 목록(파이썬 `pavement_ranges` · `ford_surface_drops` 의 비치명 태도 그대로)."""
from dataclasses import asdict
from B05_Profile.B05_Profile_Engine_Sections import route_pipe_points
from B05_Profile.B05_Profile_Structures_Repository import load_structures
try:
pipes = [asdict(point) for point in route_pipe_points(project_root)]
except Exception: # noqa: BLE001 — 정본을 못 읽어도 설계 계산은 이어 간다
logger.exception("B06 관 지점을 읽지 못했습니다 (시설 구간 없음으로 본다)")
pipes = []
try:
structures = [s.model_dump(mode="json") for s in load_structures(str(project_root))[1]]
except Exception: # noqa: BLE001
logger.exception("B06 구조물 정본을 읽지 못했습니다 (포장 · 맹암거 구간 없음으로 본다)")
structures = []
return {"pipes": pipes, "structures": structures}
def _python_facility(root: Path) -> Any:
"""Node 를 못 부를 때 — 파이썬 구간 짝(`Router_Design_Ranges` · `Engine_Tilt`)."""
from B06_Section.B06_Section_Engine_Tilt import tilt_args, tilt_ranges
from B06_Section.B06_Section_Router_Design_Ranges import (
ford_drop_at,
ford_surface_drops,
paved_at,
pavement_ranges,
)
paved, tilts, drops = pavement_ranges(root), tilt_ranges(root), ford_surface_drops(root)
return lambda c: {
"paved": paved_at(c, paved),
"surface_drop_m": ford_drop_at(c, drops),
**tilt_args(c, tilts),
}
def compute_cross_designs(
jobs: list[DesignJob], standard: dict[str, Any] | None
jobs: list[DesignJob],
standard: dict[str, Any] | None,
facilities: Facilities | None = None,
) -> list[dict[str, Any] | None]:
"""측점 묶음 횡단 설계 — 차례 그대로 결과 · None = 못 세움(파이썬 ValueError 자리)."""
"""측점 묶음 횡단 설계 — 차례 그대로 결과 · None = 못 세움(파이썬 ValueError 자리).
`facilities` = (뿌리, 누가거리) 면 포장 · 기울임 · 세월교 하강을 TS 구간이 측점마다 얹음
(511-B B-4 · C3)."""
if not jobs:
return []
stations = [
@@ -69,12 +118,18 @@ def compute_cross_designs(
}
for samples, elevation, options in jobs
]
results = run_calc(
["cross.design"],
{"cross_design": {"standard": full_standard(standard), "stations": stations}},
)
batch: dict[str, Any] = {"standard": full_standard(standard), "stations": stations}
if facilities is not None:
root, chainages = facilities
batch["facilities"] = facility_inputs(root)
for station, chainage in zip(stations, chainages, strict=True):
station["chainage_m"] = chainage
results = run_calc(["cross.design"], {"cross_design": batch})
if results is None:
logger.warning("Node 횡단 설계 실패 — 파이썬 짝으로 셈(%d 측점)", len(jobs))
if facilities is not None:
at = _python_facility(facilities[0])
jobs = [(s, z, {**o, **at(c)}) for (s, z, o), c in zip(jobs, facilities[1])]
return [_python(job, standard) for job in jobs]
return [None if "error" in row else row for row in results["cross.design"]]
-110
View File
@@ -14,7 +14,6 @@ from fastapi import APIRouter, Depends, Request
from fastapi.responses import JSONResponse, Response
from B03_FileInput.B03_FileInput_Repository import get_project_storage_relative_path
from B05_Profile.B05_Profile_Engine_Grade_Profile import rebuild_alignment_profile
from B05_Profile.B05_Profile_Engine_Sections import (
cross_files_in_order,
relabel_pipe_stations,
@@ -35,15 +34,9 @@ from B06_Section.B06_Section_Repository import (
insert_cross_sections,
list_recent_company_projects,
)
from B06_Section.B06_Section_Router_Design import (
PREVIEW_DESIGN_FIELDS as _PREVIEW_DESIGN_FIELDS,
)
from B06_Section.B06_Section_Router_Design import (
attach_default_designs as _attach_default_designs,
)
from B06_Section.B06_Section_Router_Design import (
recompute_designs_for_alignment as _recompute_designs_for_alignment,
)
from B06_Section.B06_Section_Router_Design import (
stored_standard_cross_section as _stored_standard_cross_section,
)
@@ -55,8 +48,6 @@ from B06_Section.B06_Section_Schema import (
CompanyStandardListResponse,
CompanyStandardProject,
CompanyStandardResponse,
CrossDesignPreviewRequest,
CrossDesignPreviewResponse,
HaulEquipmentLimit,
SectionContextResponse,
SectionDetailResponse,
@@ -482,104 +473,3 @@ async def regenerate_route_sections(
status_code=500,
content={"status": "error", "message": "종횡단 재생성 처리 중 오류가 발생했습니다."},
)
def _with_design_choices(
designs: list[dict[str, Any]], choices: dict[str, dict[str, Any]] | None
) -> list[dict[str, Any]]:
"""카드 선택 세션값을 저장분 위에 얹은 행 목록(439) — 뒤 행이 같은 측점 앞 행을 덮음."""
stored = {round(float(r["chainage_m"]), 3): r.get("design") or {} for r in designs}
picked = {round(float(key), 3): choice for key, choice in (choices or {}).items()}
return designs + [
{"chainage_m": key, "design": {**stored.get(key, {}), **choice}}
for key, choice in picked.items()
]
@router.post(
"/{project_id}/sections/{route_id}/cross-design/preview",
response_model=CrossDesignPreviewResponse,
)
async def preview_cross_designs(
project_id: UUID, route_id: int, request: CrossDesignPreviewRequest
) -> CrossDesignPreviewResponse | JSONResponse:
"""계획선 편집 델타로 계획선과 전 측점 횡단 설계를 다시 계산해 돌려준다(저장 없음).
⚠ **평상시 조작 경로가 아니다**(2026-09-03 이후). 계획선을 만지는 동안의 재계산은
브라우저가 직접 한다(`common_util/common_util_cross_design.ts`) — 조작 중 계산이
서버로 나가면 왕복이 조작 속도를 지배하기 때문이다(사용자 확정). 이 엔드포인트는
선형 저장분이 없어 브라우저가 계획고를 풀 수 없는 **옛 데이터 폴백**으로만 남는다
(`B06_Section_Cross_Refresh.refreshCrossDesigns`).
영속화는 각 페이지의 임시저장·확정이 맡는다.
"""
pool = get_db_pool()
try:
async with pool.acquire() as connection:
longitudinal_row = await get_longitudinal_section(connection, project_id, route_id)
if not longitudinal_row:
return JSONResponse(
status_code=404,
content={"status": "error", "message": "종횡단 상세 결과가 없습니다."},
)
stored_path = await get_project_storage_relative_path(connection, project_id)
designs = await get_cross_section_designs(connection, route_id)
source = await get_route_generation_source(connection, project_id, route_id)
designs = _with_design_choices(designs, request.design_choices)
project_root = Path(resolve_stored_project_path(stored_path))
detail = await asyncio.to_thread(
_read_section_detail,
project_root,
str(longitudinal_row["longitudinal_file_path"]),
(source or {}).get("route_data_path"),
)
def rebuild() -> None:
alignment, profile = rebuild_alignment_profile(detail["longitudinal"], request.edits())
detail["longitudinal"]["profile_alignment"] = alignment
detail["longitudinal"]["design_profiles"] = [profile]
_recompute_designs_for_alignment(
detail["longitudinal"],
detail["cross_sections"],
designs,
request.standard_cross_section,
request.rock_boundary_offsets,
project_root,
request.berms,
request.cut_slope_ratios,
)
await asyncio.to_thread(rebuild)
# 계획고를 끄는 동안 오가는 값이라 기본은 **유토곡선이 실제로 읽는 필드만** 싣는다.
# 지반선 샘플·설계선 좌표(design_line)·선형 구조를 다 담으면 1MB가 넘어 편집이 굼떠진다
# (2026-08-03 사용자 지적). 계획선 자체는 화면이 이미 같은 규칙으로 계산해 들고 있다.
# `full_designs`(B06 진입 시 stale 일괄 재계산)일 때만 설계 전체를 1회 싣는다 —
# 측점별 순차 호출 N번보다 왕복 1번이 훨씬 싸다(2026-08-04 사용자 확인).
return CrossDesignPreviewResponse(
designs=[
{
"chainage_m": section.get("chainage_m"),
"design": section["design"]
if request.full_designs
else {
key: section["design"].get(key)
for key in _PREVIEW_DESIGN_FIELDS
if key in section["design"]
},
}
for section in detail["cross_sections"]
if section.get("design")
],
)
except FileNotFoundError as exc:
return JSONResponse(status_code=404, content={"status": "error", "message": str(exc)})
except (OSError, ValueError, json.JSONDecodeError) as exc:
return JSONResponse(status_code=400, content={"status": "error", "message": str(exc)})
except Exception:
logger.exception(
"B06 횡단 설계 프리뷰 실패: project_id=%s route_id=%s", project_id, route_id
)
return JSONResponse(
status_code=500,
content={"status": "error", "message": "횡단 설계 프리뷰 계산 중 오류가 발생했습니다."},
)
+43 -146
View File
@@ -11,7 +11,6 @@ from typing import Any
from B05_Profile.B05_Profile_Engine_Sections import find_cross_file
from B06_Section.B06_Section_Engine_Design import (
ROUTE_CARRY_KEYS,
compute_cross_design,
curve_widening_args,
stored_shoulder_extend,
stored_tilt,
@@ -42,20 +41,6 @@ from common_util.common_util_cross_berm import (
from common_util.common_util_route_profile import design_elevation_from_longitudinal
from config.config_system import STANDARD_ROCK_BOUNDARY_DEFAULT_OFFSET_M
PREVIEW_DESIGN_FIELDS = (
"ground_type",
"roadbed_width_m",
"cut_area_m2",
"cut_soil_area_m2",
"cut_rock_area_m2",
"cut_rock_kind",
"fill_area_m2",
"fill_ground_slope",
"design_elevation_m",
"slope_unclosed",
)
# 사용자 조작값 — 다시 계산해도 그대로 승계한다. **이 목록이 서버의 유일한 한 벌이다.**
#
# 브라우저 쪽 짝은 `B06_Section_Cross_Refresh.ts` 의 `PRESERVED_KEYS` 다(+ `status`·
@@ -375,34 +360,37 @@ def attach_default_designs(
project_root: Path | None = None,
standard: dict[str, Any] | None = None,
) -> None:
"""설계 없는 측점 = 기본값 프리뷰 — Node 묶음 한 번(TS 짝 · 시설 구간 TS · 511-B B-4)."""
modes = default_section_modes(longitudinal)
pavement = pavement_suggestions(longitudinal)
paved_ranges = pavement_ranges(project_root) if project_root else []
tilts = tilt_ranges(project_root)
ford_drops = ford_surface_drops(project_root)
jobs: list[tuple[dict[str, Any], float, DesignJob]] = []
for section in cross_sections:
if section.get("design"):
continue
try:
chainage = float(section.get("chainage_m", 0.0))
suggested = pavement.get(round(chainage, 3), False)
design = compute_cross_design(
section.get("samples", []),
design_elevation_from_longitudinal(longitudinal, chainage),
ground_type="ripping_rock",
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,
surface_drop_m=ford_drop_at(chainage, ford_drops),
**curve_widening_args(section),
**tilt_args(chainage, tilts),
)
design.update(status="provisional", pavement_suggested=suggested)
section["design"] = design
except (ValueError, KeyError):
chainage = float(section.get("chainage_m", 0.0))
options = dict(
ground_type="ripping_rock",
section_mode=modes.get(round(chainage, 3), "left_cut"),
# 포장은 사용자 구간 지정만 켠다 — 경사 제안은 경고로만 남는다(2026-08-28) · 구간 = TS
rock_boundary_offset_m=STANDARD_ROCK_BOUNDARY_DEFAULT_OFFSET_M,
**curve_widening_args(section),
)
elevation = design_elevation_from_longitudinal(longitudinal, chainage)
jobs.append((section, chainage, (section.get("samples", []), elevation, options)))
if not jobs:
return
designs = compute_cross_designs(
[job for *_rest, job in jobs],
standard,
(project_root, [c for _s, c, _j in jobs]) if project_root else None,
)
for (section, chainage, _job), design in zip(jobs, designs, strict=True):
if design is None:
continue
design.update(
status="provisional", pavement_suggested=pavement.get(round(chainage, 3), False)
)
section["design"] = design
def stored_cut_slope(stored: dict[str, Any]) -> float | None:
@@ -445,96 +433,6 @@ def stored_berm(stored: dict[str, Any]) -> BermSpec | None:
return berm_from(stored.get("berm"), stored)
def recompute_designs_for_alignment(
longitudinal: dict[str, Any],
cross_sections: list[dict[str, Any]],
stored_designs: list[dict[str, Any]],
standard: dict[str, Any] | None,
rock_boundary_offsets: dict[str, float] | None = None,
project_root: Path | None = None,
berms: dict[str, dict[str, float]] | None = None,
cut_slope_ratios: dict[str, float] | None = None,
) -> None:
modes = default_section_modes(longitudinal)
pavement = pavement_suggestions(longitudinal)
paved_ranges = pavement_ranges(project_root) if project_root else []
tilts = tilt_ranges(project_root)
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
}
# 측점별 암 절토 경사(2026-09-07) — 0 은 「표준값을 씀」이라 저장분을 덮어 지운다.
session_cut_slopes: dict[float, float] = {}
for raw_key, ratio in (cut_slope_ratios or {}).items():
try:
session_cut_slopes[round(float(raw_key), 3)] = float(ratio)
except (TypeError, ValueError):
continue
session_offsets: dict[float, float] = {}
for raw_key, offset in (rock_boundary_offsets or {}).items():
try:
session_offsets[round(float(raw_key), 3)] = float(offset)
except (TypeError, ValueError):
continue
# 측점별 소단 제원 — 값이 없는 측점은 소단 없음(종전 설계 그대로).
session_berms: dict[float, BermSpec] = {}
for raw_key, spec in (berms or {}).items():
try:
parsed = berm_from(spec, spec if isinstance(spec, dict) else {}) # 쪽 깊이도(194-1)
if parsed is not None:
session_berms[round(float(raw_key), 3)] = parsed
except (TypeError, ValueError):
continue
for section in cross_sections:
chainage = float(section.get("chainage_m", 0.0))
key = round(chainage, 3)
stored = stored_by_chainage.get(key) or {}
suggested = pavement.get(key, False)
try:
design = compute_cross_design(
section.get("samples", []),
design_elevation_from_longitudinal(longitudinal, chainage),
ground_type=str(stored.get("ground_type") or "ripping_rock"),
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=paved_at(chainage, paved_ranges),
standard=standard,
rock_boundary_offset_m=session_offsets.get(
key,
stored.get("rock_boundary_offset_m", STANDARD_ROCK_BOUNDARY_DEFAULT_OFFSET_M),
),
two_stage_slope=bool(stored.get("two_stage_slope", False)),
cut_slope_ratio=(
(session_cut_slopes[key] or None)
if key in session_cut_slopes
else stored_cut_slope(stored)
),
ditch_enabled=stored.get("ditch_enabled"),
ditch_choice=stored.get("ditch_choice"),
surface_drop_m=ford_drop_at(chainage, ford_drops),
berm=session_berms.get(key) or stored_berm(stored),
**curve_widening_args(section),
**stored_shoulder_extend(stored), # 노견 확장(139-6)
**tilt_args(chainage, tilts),
)
except (ValueError, KeyError):
continue
design.update(status="provisional", pavement_suggested=suggested)
if stored.get("display_half_width_m") is not None:
design["display_half_width_m"] = stored["display_half_width_m"]
if stored.get("inlet_structure") is not None:
design["inlet_structure"] = stored["inlet_structure"]
if stored.get("basin_adjust") is not None:
design["basin_adjust"] = stored["basin_adjust"]
if stored.get("ford_adjust") is not None:
design["ford_adjust"] = stored["ford_adjust"]
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,
@@ -562,10 +460,7 @@ def compute_default_designs(
cross_dir = longitudinal_path.parent.parent / "cross_sections"
default_modes = default_section_modes(longitudinal)
pavement = pavement_suggestions(longitudinal)
paved_ranges = pavement_ranges(root)
tilts = tilt_ranges(root)
ford_drops = ford_surface_drops(root)
results: list[tuple[float, dict[str, Any]]] = []
entries: list[tuple[float, dict[str, Any], DesignJob]] = []
for chainage_m in chainages:
cross = (crosses or {}).get(round(chainage_m, 3))
cross_path = find_cross_file(cross_dir, chainage_m)
@@ -577,12 +472,9 @@ def compute_default_designs(
samples = cross.get("samples")
if not isinstance(samples, list):
continue
suggested = pavement.get(round(chainage_m, 3), False)
user = (carried or {}).get(round(chainage_m, 3)) or {}
rock_offset = user.get("rock_boundary_offset_m")
design = compute_cross_design(
samples,
design_elevation_from_longitudinal(longitudinal, chainage_m),
options = dict(
ground_type=str(user.get("ground_type") or "ripping_rock"),
# 손 상단측(같은 자리로 넘긴 단면 유형 · 측구 방향 · 요구 275-3) · 없으면 상단측
section_mode=user.get("section_mode")
@@ -594,27 +486,32 @@ def compute_default_designs(
),
ditch_side=user.get("ditch_side"),
ditch_type=str(user.get("ditch_type") or "standard"),
# 포장은 사용자 구간 지정만 켠다 — 경사 제안은 경고로만 남는다(2026-08-28).
paved=paved_at(chainage_m, paved_ranges),
standard=standard,
# 포장 · 기울임 · 세월교 하강 = 구간 · 관 지점(TS `facilityRanges` · 511-B B-4)
rock_boundary_offset_m=(
STANDARD_ROCK_BOUNDARY_DEFAULT_OFFSET_M if rock_offset is None else rock_offset
),
two_stage_slope=bool(user.get("two_stage_slope", False)),
cut_slope_ratio=stored_cut_slope(user),
ditch_choice=user.get("ditch_choice"),
surface_drop_m=ford_drop_at(chainage_m, ford_drops),
berm=stored_berm(user),
**curve_widening_args(cross),
**stored_shoulder_extend(user), # 노선 저장이 넘긴 사면 쉬프트(417)
**tilt_args(chainage_m, tilts),
)
for key in ("berm", "cut_slope_ratio_user", *ROUTE_CARRY_KEYS):
if user.get(key) is not None:
design[key] = user[key]
design["status"] = "provisional"
design["pavement_suggested"] = suggested
results.append((chainage_m, design))
elevation = design_elevation_from_longitudinal(longitudinal, chainage_m)
except (ValueError, KeyError, OSError, json.JSONDecodeError):
continue
entries.append((chainage_m, user, (samples, elevation, options)))
designs = compute_cross_designs(
[job for *_rest, job in entries], standard, (root, [c for c, _u, _j in entries])
)
results: list[tuple[float, dict[str, Any]]] = []
for (chainage_m, user, _job), design in zip(entries, designs, strict=True):
if design is None:
continue # 계획고 부재 등 — 조용히 건너뜀(확정을 막지 않음)
for key in ("berm", "cut_slope_ratio_user", *ROUTE_CARRY_KEYS):
if user.get(key) is not None:
design[key] = user[key]
design["status"] = "provisional"
design["pavement_suggested"] = pavement.get(round(chainage_m, 3), False)
results.append((chainage_m, design))
return results
-47
View File
@@ -367,50 +367,3 @@ class SectionDetailResponse(BaseModel):
# longitudinal_sections.data.mass_haul.balloon_offsets에 확정 시점에 저장된 값이며,
# 브라우저가 바뀌어도 같은 자리에 뜨도록 진입 시 프론트 캐시의 씨앗으로 내려보낸다.
balloon_offsets: dict[str, list[float]] | None = None
class CrossDesignPreviewRequest(BaseModel):
"""계획선 편집 델타로 **전 측점 횡단 설계를 다시 계산**해 달라는 요청(저장 없음).
B05에서 계획고를 끌면 측점별 횡단 단면적이 함께 바뀌어야 횡단 기준 유토곡선이
따라 움직인다. 측점마다 따로 부르면 수십 번 왕복하므로 한 번에 계산한다.
사용자가 이미 지정한 지반유형·단면유형·측구·암 경계는 그대로 유지하고
**계획고만 새 선형으로 바꿔** 재계산한다.
"""
model_config = ConfigDict(extra="forbid")
station_offsets: dict[str, float] = Field(default_factory=dict)
curve_radii: dict[str, float] = Field(default_factory=dict)
# B06 설정 패널 편집값(있으면 그 표준단면으로 계산).
standard_cross_section: dict[str, Any] | None = None
# True면 설계 전체(설계선 좌표 포함)를 돌려준다 — B06 진입 시 stale 설계 일괄 재계산용.
# B05 편집 프리뷰(기본 False)는 유토곡선 필드만 받아 응답을 가볍게 유지한다.
full_designs: bool = False
# 측점별 암 경계 오프셋 세션값(chainage 키 → m). B06이 확정 전 세션에만 들고 있는
# 오프셋을 재계산에 반영하기 위한 값 — 없으면 DB 저장분을 쓴다.
rock_boundary_offsets: dict[str, float] | None = None
# 측점별 소단 제원(chainage 키 → {width_m, interval_m, slope_deg, depth_m}). 위와 같은 성격으로,
# 사용자가 구간에 놓은 소단을 확정 전에도 재계산에 반영한다(계획서 3-9).
# 값이 없는 측점은 소단 없음 — 종전 설계 그대로다.
berms: dict[str, dict[str, float]] | None = None
# 측점별 암 절토 경사비(chainage 키 → 1:n 의 n). 위와 같은 성격 — 확정 전 세션값을
# 재계산에 반영한다(2026-09-07). **0 은 「표준값을 씀」**(되돌리기)이다.
cut_slope_ratios: dict[str, float] | None = None
# 측점별 카드 선택(chainage 키 → 지반 · 단면 · 측구 · 복합경사). 위와 같은 성격 — 저장분 위에 얹음(439).
design_choices: dict[str, dict[str, Any]] | None = None
def edits(self) -> dict[str, Any]:
return {"station_offsets": self.station_offsets, "curve_radii": self.curve_radii}
class CrossDesignPreviewResponse(BaseModel):
"""계획선 편집 프리뷰 결과 — **바뀌는 것만** 담는다.
계획고를 끄는 동안 오가는 값이라 **유토곡선이 실제로 읽는 설계 필드만** 담는다.
지반선 샘플·설계선 좌표·선형 구조까지 실으면 1MB가 넘어 편집이 굼떠지고
(2026-08-03 사용자 지적), 계획선 자체는 화면이 같은 규칙으로 이미 계산해 들고 있다.
"""
status: str = "ok"
designs: list[dict[str, Any]] = Field(default_factory=list)
+337
View File
@@ -0,0 +1,337 @@
/* =============================================================================
* B06_Section_Cross_Save.ts
* [저장] · [확정] 서버 횡단 한 바퀴(PLAN 511-B B-4 · 서버 Node `cross.enforce`) — 저장 입력으로 모든 측점 설계선
* 다시 냄 → 미교차 넓힘(DTM 격자) → 포장 구간 → 세월교 노면 하강. 차례 · 승계 · 입력 = 파이썬 그대로.
*
* ⚠ ① 짝 — `B06_Section_Router_Design.py` `enforce_design_lines` · `enforce_pavement_ranges` ·
* `enforce_ford_surface_drops` + `B06_Section_Engine_SampleExtend.py` `extend_unclosed_sections`
* (서버 `_enforce_stored_designs` 차례 · DTM 아닌 지표면은 파이썬 길) · 저장분 입력 읽기
* (`stored_cut_slope` · `berm_from` · `stored_shoulder_extend` · `stored_tilt` · `curve_widening_args`) ·
* 계획고 = `design_elevation_from_longitudinal`. 거울 시험 `test_511_b4_cross_save_mirror.py`.
* crossOptions = 파이썬 `compute_cross_design` 키 → TS 짝 키(Node `cross.design` 도 이것).
* 순수 — 화면 · fetch · 세션 없음.
* ========================================================================== */
import { bermSpansFromStructures, bermSpecAt } from "@util/common_util_berm_spans";
import {
BERM_DEFAULT_DEPTH_M,
BERM_DEFAULT_INTERVAL_M,
BERM_DEFAULT_SLOPE_DEG,
BERM_DEFAULT_WIDTH_M,
type BermSpec,
} from "@util/common_util_cross_berm";
import { computeCrossDesign } from "@util/common_util_cross_design";
import type { CrossDesignOptions, CrossGroundSample } from "@util/common_util_cross_design";
import type { CulvertPipePoint } from "@util/common_util_culvert_sets";
import type { DtmGrid } from "@util/common_util_surface_sampler";
import {
extendUnclosed,
type CrossGroundPoint,
type ExtendInfo,
type UnclosedDesign,
} from "../../B05_Profile/logic/B05_Profile_Sections_Ground";
import { USER_TOUCHED_KEYS } from "./B06_Section_Design_Keys";
import { facilityAt, facilityRanges, type FacilityStructure } from "./B06_Section_Cross_Enforce";
type Raw = Record<string, unknown>;
/** 파이썬 `compute_cross_design` 키 → TS 짝 키(거울 시험 test_b06_cross_design_mirror 표와 같음). */
const CROSS_OPTION_KEYS: Record<string, keyof CrossDesignOptions> = {
ground_type: "groundType",
section_mode: "sectionMode",
ditch_side: "ditchSide",
ditch_type: "ditchType",
paved: "paved",
rock_boundary_offset_m: "rockBoundaryOffsetM",
two_stage_slope: "twoStageSlope",
cut_slope_ratio: "cutSlopeRatio",
ditch_enabled: "ditchEnabled",
ditch_choice: "ditchChoice",
surface_drop_m: "surfaceDropM",
plan_radius_m: "planRadiusM",
curve_outer_side: "curveOuterSide",
curve_widening_m: "curveWideningM",
shoulder_extend_left_m: "shoulderExtendLeftM",
shoulder_extend_right_m: "shoulderExtendRightM",
tilted: "tilted",
tilt_pct: "tiltPct",
};
/** 파이썬 BermSpec(NamedTuple → dict) → TS BermSpec. */
function bermOf(value: unknown): BermSpec | null {
if (!value || typeof value !== "object") return null;
const berm = value as Record<string, number | null>;
return {
widthM: berm.width_m as number,
intervalM: berm.interval_m as number,
slopeDeg: berm.slope_deg as number,
depthM: berm.depth_m as number,
depthLeftM: berm.depth_left_m ?? null,
depthRightM: berm.depth_right_m ?? null,
};
}
/** 파이썬 키 입력 → 엔진 입력(모르는 키 = 던짐). */
export function crossOptions(
raw: Raw,
standard: CrossDesignOptions["standard"],
): CrossDesignOptions {
const options: Raw = { standard };
for (const [key, value] of Object.entries(raw)) {
if (key === "berm") options.berm = bermOf(value);
else if (key in CROSS_OPTION_KEYS) options[CROSS_OPTION_KEYS[key]] = value;
else throw new Error(`모르는 횡단 설계 키: ${key}`);
}
return options as unknown as CrossDesignOptions;
}
const isNum = (value: unknown): value is number =>
typeof value === "number" && Number.isFinite(value);
/** 파이썬 `stored_cut_slope` — 측점별 암 절토 경사 · 없거나 0 = null. */
const storedCutSlope = (design: Raw): number | null =>
isNum(design.cut_slope_ratio_user) && design.cut_slope_ratio_user > 0
? design.cut_slope_ratio_user
: null;
/** 파이썬 `berm_from` — 소단 제원 dict + 쪽마다 옮긴 깊이 → BermSpec(python 키). */
function bermFrom(spec: unknown, sides: Raw): Raw | null {
if (!spec || typeof spec !== "object" || Array.isArray(spec)) return null;
const own = (side: string): number | null => {
const value = sides[`berm_depth_${side}_m`];
return isNum(value) ? Math.max(value, 0) : null;
};
const s = spec as Raw;
const field = (key: string, fallback: number): number => Number(s[key] ?? fallback);
const out = {
width_m: field("width_m", BERM_DEFAULT_WIDTH_M),
interval_m: field("interval_m", BERM_DEFAULT_INTERVAL_M),
slope_deg: field("slope_deg", BERM_DEFAULT_SLOPE_DEG),
depth_m: Math.max(field("depth_m", BERM_DEFAULT_DEPTH_M), 0),
};
if (!Object.values(out).every(Number.isFinite)) return null; // 파이썬 TypeError · ValueError
return { ...out, depth_left_m: own("left"), depth_right_m: own("right") };
}
const storedBerm = (design: Raw): Raw | null => bermFrom(design.berm, design);
/** 파이썬 `stored_shoulder_extend` — 없거나 음수 = 0. */
const storedShoulder = (design: Raw): Raw =>
Object.fromEntries(
(["left", "right"] as const).map((side) => {
const value = design[`shoulder_extend_${side}_m`];
return [`shoulder_extend_${side}_m`, isNum(value) ? Math.max(value, 0) : 0];
}),
);
/** 파이썬 `stored_tilt`. */
const storedTilt = (design: Raw): { tilted: boolean; tilt_pct: number | null } => ({
tilted: Boolean(design.tilted),
tilt_pct: isNum(design.tilt_pct) ? design.tilt_pct : null,
});
/** 파이썬 `curve_widening_args`. */
const curveArgs = (section: Raw): Raw => ({
plan_radius_m: section.plan_radius_m ?? null,
curve_outer_side: section.curve_outer_side ?? null,
curve_widening_m: section.curve_widening_m ?? null,
});
/** 파이썬 `design_elevation_from_longitudinal` — 계획선 표본 선형 보간 · 범위 밖 끝값 · 없으면 null. */
export function elevationAt(
samples: ReadonlyArray<{ chainage_m?: unknown; elevation_m?: unknown }>,
chainageM: number,
): number | null {
const rows = samples.filter((s) => isNum(s.elevation_m) && isNum(s.chainage_m)) as Array<{
chainage_m: number;
elevation_m: number;
}>;
if (!rows.length) return null;
if (chainageM <= rows[0].chainage_m) return rows[0].elevation_m;
const last = rows[rows.length - 1];
if (chainageM >= last.chainage_m) return last.elevation_m;
for (let index = 1; index < rows.length; index += 1) {
const [previous, current] = [rows[index - 1], rows[index]];
if (chainageM > current.chainage_m) continue;
const span = current.chainage_m - previous.chainage_m;
if (span <= 0) return current.elevation_m;
const ratio = (chainageM - previous.chainage_m) / span;
return previous.elevation_m + (current.elevation_m - previous.elevation_m) * ratio;
}
return last.elevation_m;
}
/** 저장분 공통 입력(파이썬 넷이 같은 꼴로 모음) — 포장 · 하강 · 기울임은 부르는 자리가 덮음. */
function storedOptions(design: Raw, section: Raw, rockDefaultM: number): Raw {
return {
ground_type: String(design.ground_type || "ripping_rock"),
section_mode: String(design.section_mode || "left_cut"),
ditch_side: design.ditch_side ?? null,
ditch_type: String(design.ditch_type || "standard"),
paved: Boolean(design.paved),
rock_boundary_offset_m:
"rock_boundary_offset_m" in design ? design.rock_boundary_offset_m : rockDefaultM,
two_stage_slope: Boolean(design.two_stage_slope),
cut_slope_ratio: storedCutSlope(design),
ditch_enabled: design.ditch_enabled ?? null,
ditch_choice: design.ditch_choice ?? null,
berm: storedBerm(design),
...curveArgs(section),
...storedShoulder(design),
};
}
/** 측점 하나(서버 상세 꼴) — 쓰는 칸만. */
export interface SaveSection {
chainage_m: number;
design?: Raw | null;
samples?: CrossGroundSample[];
frame?: { origin?: { x: number; y: number }; left_xy?: [number, number] };
plan_radius_m?: number | null;
curve_outer_side?: string | null;
curve_widening_m?: number | null;
}
export interface SaveCrossInput {
/** 종단 계획선 표본(`design_profiles[0].samples`). */
profile: ReadonlyArray<{ chainage_m?: unknown; elevation_m?: unknown }>;
sections: SaveSection[];
standard: CrossDesignOptions["standard"];
rockDefaultM: number;
pipes: readonly CulvertPipePoint[];
/** 구조물 정본 — null = 못 읽음(소단 = 저장분 그대로 · 포장 · 맹암거 구간 없음). */
structures: readonly FacilityStructure[] | null;
}
/** 측점마다 결과 — 설계 없는 측점 null · 넓혔으면 표본 · 넓힘 기록. */
export interface SaveCrossRow {
design: Raw;
samples?: CrossGroundSample[];
extended?: ExtendInfo;
}
/** [저장] 횡단 한 바퀴 — 파이썬 `_recompute` 의 설계선 · 넓힘 · 포장 · 하강 넷(차례 그대로 · 제자리 아님). */
export function enforceSavedCross(
input: SaveCrossInput,
grid: () => DtmGrid | null,
): Array<SaveCrossRow | null> {
const ranges = facilityRanges(input.pipes, input.structures ?? []);
const spans = input.structures
? bermSpansFromStructures(input.structures as Parameters<typeof bermSpansFromStructures>[0])
: null;
const rows = input.sections.map((section): SaveCrossRow | null =>
section.design && typeof section.design === "object" ? { design: section.design } : null,
);
const elevation = (section: SaveSection) => elevationAt(input.profile, section.chainage_m);
const compute = (samples: CrossGroundSample[] | undefined, z: number | null, raw: Raw) => {
try {
return computeCrossDesign(
samples ?? [],
z,
crossOptions(raw, input.standard),
) as unknown as Raw;
} catch {
return null; // 파이썬 ValueError · Node { error } 자리 — 저장분 그대로
}
};
/** 다시 세운 설계에 사용자 값 · 상태 승계(파이썬 `apply_designs`). */
const keep = (next: Raw, from: Raw): Raw => {
for (const key of ["status", "pavement_suggested", ...USER_TOUCHED_KEYS])
if (from[key] !== undefined && from[key] !== null) next[key] = from[key];
return next;
};
const at = (section: SaveSection) => facilityAt(ranges, section.chainage_m);
const sectionRaw = (section: SaveSection) => section as unknown as Raw;
// ① 모든 측점 설계선(`enforce_design_lines`) — 소단 = 구조물 정본 구간 · 하강 · 기울임 = 지금 시설
input.sections.forEach((section, index) => {
const row = rows[index];
if (!row) return;
const design = spans
? { ...row.design, berm: bermSpecAt(spans, section.chainage_m) }
: row.design;
const facility = at(section);
const next = compute(section.samples, elevation(section), {
...storedOptions(design, sectionRaw(section), input.rockDefaultM),
surface_drop_m: facility.surfaceDropM,
tilted: facility.tilted,
tilt_pct: facility.tiltPct,
});
if (next) row.design = keep(next, design);
});
// ② 미교차 넓힘(`extend_unclosed_sections`) — 열린 쪽만 +5 m · DTM 격자 · 넓힌 표본으로 다시 셈
const open = rows.some((row) => row?.design.slope_unclosed);
const map = open ? grid() : null;
input.sections.forEach((section, index) => {
const row = rows[index];
const origin = section.frame?.origin;
const left = section.frame?.left_xy;
if (!map || !row?.design.slope_unclosed || !origin || !left) return;
const design = row.design;
const z = elevation(section);
const raw = {
...storedOptions(design, sectionRaw(section), input.rockDefaultM),
surface_drop_m: at(section).surfaceDropM,
...storedTilt(design),
};
let wide;
try {
wide = extendUnclosed(
(section.samples ?? []) as Array<Partial<CrossGroundPoint>>,
[origin.x, origin.y],
left,
(samples) => {
const next = compute(samples as CrossGroundSample[], z, raw);
if (!next) throw new Error("넓힘 셈 못 함");
return next as unknown as UnclosedDesign;
},
map,
);
} catch {
return; // 파이썬 ValueError · KeyError — 그 측점 그대로
}
if (!wide || (!wide.info.left_m && !wide.info.right_m)) return;
const samples = wide.samples as CrossGroundSample[];
const next = compute(samples, z, raw);
if (!next) return;
rows[index] = { design: keep(next, design), samples, extended: wide.info };
});
const samplesOf = (index: number) => rows[index]?.samples ?? input.sections[index].samples;
// ③ 포장 구간(`enforce_pavement_ranges`) — 포장 · 기울임이 저장분과 다른 측점만
input.sections.forEach((section, index) => {
const row = rows[index];
if (!row) return;
const design = row.design;
const facility = at(section);
const tilt = storedTilt(design);
if (
Boolean(design.paved) === facility.paved &&
tilt.tilted === facility.tilted &&
tilt.tilt_pct === facility.tiltPct
)
return;
const next = compute(samplesOf(index), elevation(section), {
...storedOptions(design, sectionRaw(section), input.rockDefaultM),
paved: facility.paved,
surface_drop_m: facility.surfaceDropM,
tilted: facility.tilted,
tilt_pct: facility.tiltPct,
});
if (next) row.design = keep(next, design);
});
// ④ 세월교 노면 하강(`enforce_ford_surface_drops`) — 하강이 저장분과 다른 측점만
input.sections.forEach((section, index) => {
const row = rows[index];
if (!row) return;
const design = row.design;
const wanted = at(section).surfaceDropM;
if (Math.abs(wanted - Number(design.surface_drop_m || 0)) < 1e-6) return;
const next = compute(samplesOf(index), elevation(section), {
...storedOptions(design, sectionRaw(section), input.rockDefaultM),
surface_drop_m: wanted,
...storedTilt(design),
});
if (next) row.design = keep(next, design);
});
return rows;
}