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:
@@ -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) };
|
||||
|
||||
@@ -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).
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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:
|
||||
|
||||
@@ -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"]
|
||||
|
||||
@@ -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")
|
||||
|
||||
Reference in New Issue
Block a user