Merge remote-tracking branch 'origin/dev' into sub_laptop_3
This commit is contained in:
@@ -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>;
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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"]]
|
||||
|
||||
|
||||
@@ -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": "횡단 설계 프리뷰 계산 중 오류가 발생했습니다."},
|
||||
)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
Reference in New Issue
Block a user