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
+20 -52
View File
@@ -12,7 +12,8 @@
* excavation?: { pipes: [{ diameter_mm }] · walls: [{ height_m · average_thickness_m ·
* has_foundation }] } ·
* cross_design?: { standard: 표준 횡단(config 위에 저장분 덮은 한 벌) ·
* stations: [{ samples · design_elevation_m · options: 파이썬 `compute_cross_design` 키 }] } ·
* stations: [{ samples · design_elevation_m · options: 파이썬 `compute_cross_design` 키 ·
* chainage_m? }] · facilities?: { pipes · structures }(시설 구간 · 511-B B-4) } ·
* berm?: { structures: 구조물 정본 목록 · chainages: [측점 m] } ·
* rebase? · carry?(노선 바뀐 [저장] · `design_calc_node_carry.ts`) ·
* stages?(묶음 단계 · `design_calc_node_stages.ts` · 511-B) } }
@@ -25,9 +26,9 @@
import { readFileSync, writeFileSync } from "node:fs";
import { bermSpansFromStructures, bermSpecAt } from "@util/common_util_berm_spans";
import 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 { pipeTrenchWidthM, wallTrenchAreaM2, wallTrenchWidthM } from "@util/common_util_excavation";
import { computeMassHaul, massHaulPayload } from "@util/common_util_mass_haul";
import type { MassHaulResult } from "@util/common_util_mass_haul";
@@ -36,6 +37,12 @@ import type { RoutePoint } from "../../../B05_Profile/B05_Profile_Api_Fetch";
import { buildCorridor } from "../../../B05_Profile/B05_Profile_UI_Corridor_Build";
import { corridorHash, serialize } from "../../../B05_Profile/B05_Profile_UI_Corridor_Envelope";
import type { SectionDetailResponse } from "../../../B06_Section/B06_Section_Api_Fetch";
import {
facilityAt,
facilityRanges,
type FacilityStructure,
} from "../../../B06_Section/logic/B06_Section_Cross_Enforce";
import { crossOptions } from "../../../B06_Section/logic/B06_Section_Cross_Save";
import {
applyStructureAreaRows,
extraWallRows,
@@ -83,59 +90,14 @@ interface CalcInputs extends FollowInputs, CarryInputs, TrialInputs, StagesInput
samples: CrossGroundSample[];
design_elevation_m: number | null;
options: Record<string, unknown>;
/** 시설 구간을 얹을 누가거리(`facilities` 와 함께 · 511-B B-4) */
chainage_m?: number;
}>;
/** 관 지점 · 구조물 정본 → 측점마다 포장 · 기울임 · 세월교 하강(`facilityRanges` · C3) */
facilities?: { pipes: CulvertPipePoint[]; structures: FacilityStructure[] };
};
}
/** 파이썬 `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,
};
}
function crossOptions(
raw: Record<string, unknown>,
standard: CrossDesignOptions["standard"],
): CrossDesignOptions {
const options: Record<string, unknown> = { 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;
}
/** 한 번 돌림 — 같은 id 는 한 번만 셈(memo) · 앞 id 는 `need` 로 · 단계 id 는 약속(기다림). */
async function runDesignCalc(run: readonly string[], inputs: CalcInputs) {
const memo = new Map<string, unknown>();
@@ -205,9 +167,15 @@ async function runDesignCalc(run: readonly string[], inputs: CalcInputs) {
// 측점마다 TS 짝(`computeCrossDesign`) — 못 세우면 그 자리 { error }(파이썬은 ValueError 로 되던짐).
"cross.design": () => {
const batch = inputs.cross_design;
const ranges = batch?.facilities
? facilityRanges(batch.facilities.pipes, batch.facilities.structures)
: null;
return (batch?.stations ?? []).map((station) => {
try {
const options = crossOptions(station.options, batch!.standard);
const options = {
...crossOptions(station.options, batch!.standard),
...(ranges && station.chainage_m != null ? facilityAt(ranges, station.chainage_m) : {}),
};
return computeCrossDesign(station.samples, station.design_elevation_m, options);
} catch (error) {
return { error: error instanceof Error ? error.message : String(error) };
+15 -7
View File
@@ -110,11 +110,11 @@ async def sync_uphill_overrides_into_designs(
"""
# 지연 import — B06 라우터 모듈 로드는 이 함수가 실제 불릴 때만 필요하다.
from B06_Section.B06_Section_Engine_Design import (
compute_cross_design,
curve_widening_args,
stored_shoulder_extend,
stored_tilt,
)
from B06_Section.B06_Section_Engine_Design_Node import DesignJob, compute_cross_designs
from B06_Section.B06_Section_Repository import get_cross_section_designs
from B06_Section.B06_Section_Repository_Bulk import merge_cross_section_designs
from B06_Section.B06_Section_Router_Design import (
@@ -164,20 +164,17 @@ async def sync_uphill_overrides_into_designs(
longitudinal_json = json.loads(longitudinal_path.read_text(encoding="utf-8"))
preloaded = (longitudinal_path, longitudinal_json, pavement_suggestions(longitudinal_json))
ford_drops = ford_surface_drops(root)
out: list[tuple[float, dict[str, Any]]] = []
picked: list[tuple[float, dict[str, Any], bool, DesignJob]] = []
for chainage, design, next_mode, side in jobs:
samples, design_elevation, pavement_suggested, cross_record = read_cross_design_inputs(
root, longitudinal_file_path, float(chainage), preloaded
)
next_design = compute_cross_design(
samples,
design_elevation,
options = dict(
ground_type=str(design.get("ground_type", "soil")),
section_mode=next_mode,
ditch_side=side,
ditch_type=str(design.get("ditch_type") or "standard"), # 측구 끔 = None 저장분
paved=bool(design.get("paved", False)),
standard=stored_standard,
rock_boundary_offset_m=design.get("rock_boundary_offset_m"),
two_stage_slope=bool(design.get("two_stage_slope", False)),
cut_slope_ratio=stored_cut_slope(design), # 측점별 깎기 경사(429 ⑤)
@@ -189,13 +186,24 @@ async def sync_uphill_overrides_into_designs(
**stored_shoulder_extend(design), # 노견 확장(139-6)
**stored_tilt(design), # 물넘이 · 맹암거 자리 측구 없음 · 기울기(286-2 · 287-4)
)
picked.append(
(float(chainage), design, pavement_suggested, (samples, design_elevation, options))
)
# 측점 묶음 Node 한 번(TS 짝 · 511-B B-4) · 못 세운 측점 = 저장분 그대로(옛 ValueError 자리)
results = compute_cross_designs([job for *_rest, job in picked], stored_standard)
out: list[tuple[float, dict[str, Any]]] = []
for (chainage, design, pavement_suggested, _job), next_design in zip(
picked, results, strict=True
):
if next_design is None:
continue
next_design["status"] = design.get("status", "provisional")
next_design["pavement_suggested"] = design.get("pavement_suggested", pavement_suggested)
# 사용자 조작값 승계(조절 · 소단 · 깎기 경사 · 글) — `enforce_design_lines` 와 같음
for key in USER_TOUCHED_KEYS:
if design.get(key) is not None:
next_design[key] = design[key]
out.append((float(chainage), next_design))
out.append((chainage, next_design))
return out
# 쓰기도 한 문장으로 — 행마다 내면 원격 DB 왕복이 측점 수만큼 난다(건당 24.5ms).
-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;
}
+5 -2
View File
@@ -71,8 +71,11 @@ def run_calc(ids: Sequence[str], inputs: dict[str, Any]) -> dict[str, Any] | Non
unknown = [item for item in ids if item not in NODE_IDS]
if unknown:
raise ValueError(f"Node 실행기 아닌 id: {', '.join(unknown)}")
output = run_bundle_json(BUNDLE, NPM_SCRIPT, {"run": list(ids), "inputs": inputs})
return output.get("results") if isinstance(output, dict) else None
# 늘 떠 있는 Node(511-B B-4 · 부름당 새 프로세스 0.1~0.2 s 덞) · 못 띄우면 새 프로세스 한 번
answer = _SERVED.call({"run": list(ids), "inputs": inputs})
if answer is None:
answer = run_bundle_json(BUNDLE, NPM_SCRIPT, {"run": list(ids), "inputs": inputs})
return answer.get("results") if isinstance(answer, dict) else None
class _Served:
+2 -2
View File
@@ -136,7 +136,7 @@ async function toggles(projectId, shift) {
const out = { same: await toggles("p266a", 0), mismatch: await toggles("p266b", 0.002) };
/* 서버 폴백(선형 저장분 없음) — 카드 선택이 요청에 실림 · 열쇠 = 측점 자리 */
/* 선형 저장분 없음 — 옛 서버 폴백 걷음(511-B B-4) · 부름 0 */
{
const projectId = "p266c";
m.writeState("std-cross", job.standard, projectId);
@@ -149,7 +149,7 @@ const out = { same: await toggles("p266a", 0), mismatch: await toggles("p266b",
ditch_choice: false,
});
await m.refreshSavedCross({ projectId, routeId: R, detail, edits: EDITS }, [], () => undefined);
out.server = { at, choices: previews.at(-1)?.design_choices ?? null };
out.server = { at, calls: previews.length };
}
process.stdout.write(JSON.stringify(out));
@@ -5,7 +5,7 @@
복합경사 · 측구 · 지반 단추마다 그 측점 설계선 · 면적이 바뀜.
- 묶음 측점 = 상세 측점이면 돌려줌 · 1 mm 넘게 다르면 null(묶음 엔진) — 그래도 카드 자료는 고침.
- 사면 이동 한 걸음도 같음 · 걸음마다 받는 쪽(카드 · 종단 · 유토 다시 그림) 한 번(441 · 요구 268-2 · 4).
- 서버 폴백(선형 저장분 없음)에도 카드 선택이 실림(열쇠 = 측점 자리) · 서버는 저장분 위에 얹음.
- 선형 저장분 없음 = 서버 폴백 없음(511-B B-4 · `/cross-design/preview` 걷음).
짝: `helper_266_card_toggle.mjs` · 진입점 `helper_266_card_toggle_entry.ts` ·
지형 · 노선 틀 = `test_346_2_pipeline_cross_page.py`.
"""
@@ -29,7 +29,6 @@ for path in (ROOT, HERE):
from test_346_2_pipeline_cross_page import _fake, _line, _sections # noqa: E402
from B06_Section.B06_Section_Router import _with_design_choices # noqa: E402
from common_util.common_util_surface_sampler import DtmGridSampler # noqa: E402
from config.config_system import STANDARD_ROCK_BOUNDARY_DEFAULT_OFFSET_M # noqa: E402
from config.config_system_design import STANDARD_CROSS_SECTION # noqa: E402
@@ -121,19 +120,6 @@ def test_측점_어긋남_카드_자료는_고침_돌려줄_값만_null(r):
_check_toggles(r["mismatch"], None)
def test_서버_폴백에_카드_선택_실음(r):
key = f"{r['server']['at']:.3f}"
choice = (r["server"]["choices"] or {}).get(key)
assert choice is not None, r["server"]
assert choice["ground_type"] == "soil"
assert choice["two_stage_slope"] is True
assert choice["ditch_choice"] is False
def test_서버_카드_선택이_저장분_위에_얹힘():
stored = [{"chainage_m": 40.0, "design": {"ground_type": "ripping_rock", "berm": {"w": 1}}}]
rows = _with_design_choices(stored, {"40.000": {"ground_type": "soil"}, "60.123": {"x": 1}})
by_key = {round(float(r["chainage_m"]), 3): r["design"] for r in rows} # 서버 읽는 꼴
assert by_key[40.0] == {"ground_type": "soil", "berm": {"w": 1}}
assert by_key[60.123] == {"x": 1}
assert _with_design_choices(stored, None) == stored
def test_선형_저장분_없음_서버_폴백_없음(r):
# 511-B B-4 — 옛 데이터 서버 프리뷰(`/cross-design/preview`) 걷음 · 부름 0
assert r["server"]["calls"] == 0, r["server"]
+5 -5
View File
@@ -201,13 +201,13 @@ def test_기본_설계가_넘긴_손_단면_유형으로_셈(tmp_path, monkeypat
import B06_Section.B06_Section_Router_Design as design_module
seen: list[tuple[str, object]] = []
real = design_module.compute_cross_design
real = design_module.compute_cross_designs # 511-B B-4 — 측점 묶음 Node 한 번
def spy(*args, **kwargs):
seen.append((kwargs["section_mode"], kwargs.get("ditch_side")))
return real(*args, **kwargs)
def spy(jobs, *args, **kwargs):
seen.extend((options["section_mode"], options.get("ditch_side")) for *_, options in jobs)
return real(jobs, *args, **kwargs)
monkeypatch.setattr(design_module, "compute_cross_design", spy)
monkeypatch.setattr(design_module, "compute_cross_designs", spy)
route = tmp_path / "routes" / "r1"
(route / "longitudinal").mkdir(parents=True)
(route / "cross_sections").mkdir()
@@ -46,7 +46,7 @@ def test_b06_context_uses_workflow_route():
def test_cross_refresh_is_single_entry():
"""횡단 재계산은 한 창구뿐 — 두 화면이 같은 인자를 실어 보낸다."""
assert "export async function refreshCrossDesigns" in REFRESH
assert "readStandardCrossSession(projectId)" in REFRESH
assert "effectiveStandardCross(projectId)" in REFRESH # 옛 서버 폴백 걷음(511-B B-4)
assert "readRockBoundarySession(projectId, routeId)" in REFRESH
# 부르는 곳 = 계산 묶음 cross 단계 하나(design_shared savedCross · 387-4) — 화면은 묶음에 부탁
shared = _read("A00_Common/design/design_shared.ts")