diff --git a/A00_Common/design/logic/design_calc_node.ts b/A00_Common/design/logic/design_calc_node.ts index 779411db1..78ed19b3c 100644 --- a/A00_Common/design/logic/design_calc_node.ts +++ b/A00_Common/design/logic/design_calc_node.ts @@ -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) · @@ -26,9 +27,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"; @@ -37,6 +38,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, @@ -85,59 +92,14 @@ interface CalcInputs extends FollowInputs, CarryInputs, TrialInputs, StagesInput samples: CrossGroundSample[]; design_elevation_m: number | null; options: Record; + /** 시설 구간을 얹을 누가거리(`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 = { - 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; - 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, - standard: CrossDesignOptions["standard"], -): CrossDesignOptions { - const options: Record = { 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(); @@ -208,9 +170,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) }; diff --git a/B05_Profile/B05_Profile_Router_Confirm.py b/B05_Profile/B05_Profile_Router_Confirm.py index 53ab71b6c..2a7aa7ec0 100644 --- a/B05_Profile/B05_Profile_Router_Confirm.py +++ b/B05_Profile/B05_Profile_Router_Confirm.py @@ -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). diff --git a/B06_Section/B06_Section_Api_Fetch.ts b/B06_Section/B06_Section_Api_Fetch.ts index 42f2182c0..5832adb4a 100644 --- a/B06_Section/B06_Section_Api_Fetch.ts +++ b/B06_Section/B06_Section_Api_Fetch.ts @@ -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; - curve_radii: Record; -} - -/** - * 계획선 편집 프리뷰 결과 — **바뀌는 것만** 온다. - * 횡단 지반선 원시 샘플은 계획고와 무관해 그대로이므로 싣지 않는다(응답 1MB → 수십 KB). - */ -export interface CrossDesignPreviewResponse { - status: string; - /** 유토곡선이 읽는 설계 필드만 담긴 부분 갱신값(설계선 좌표 등은 오지 않는다). */ - designs: Array<{ chainage_m: number; design: Partial }>; -} - -/** - * 계획선 편집을 반영해 **계획선 + 전 측점 횡단 설계**를 다시 계산해 받는다(서버 저장 없음). - * - * B05에서 계획고를 끄는 동안 횡단 단면적이 함께 움직여야 횡단 기준 유토곡선이 따라온다. - * 측점마다 따로 부르면 수십 번 왕복하므로 한 번에 계산한다. 영속화는 각 페이지의 - * 임시저장·확정이 맡는다. - */ -export async function previewCrossDesigns( - projectId: string, - routeId: number, - edits: ProfileAlignmentEdits, - standardCrossSection?: StandardCrossSection, - options?: { - /** true면 설계 전체(설계선 좌표 포함)를 받는다 — B06 진입 시 stale 일괄 재계산용. */ - fullDesigns?: boolean; - /** 측점별 암 경계 오프셋 세션값(chainage 키 → m). DB 저장분보다 우선한다. */ - rockBoundaryOffsets?: Record; - /** 측점별 소단 제원(chainage 키 → 폭·간격·기울기). 값이 없는 측점은 소단 없음. */ - berms?: Record< - string, - { width_m: number; interval_m: number; slope_deg: number; depth_m?: number } - >; - /** 측점별 암 절토 경사비(chainage 키 → 1:n 의 n). 0 은 「표준값을 씀」이다. */ - cutSlopeRatios?: Record; - /** 측점별 카드 선택(chainage 키 → 지반 · 측구 · 복합경사 …) 세션값 — 저장분 위에 얹음(439). */ - designChoices?: Record; - }, -): Promise { - return requestJson( - `/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; diff --git a/B06_Section/B06_Section_Cross_Refresh.ts b/B06_Section/B06_Section_Cross_Refresh.ts index c8659360c..d41bb1491 100644 --- a/B06_Section/B06_Section_Cross_Refresh.ts +++ b/B06_Section/B06_Section_Cross_Refresh.ts @@ -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 [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>> { - const spans = readBermSpans(projectId, routeId); - const out: Record>> = {}; - 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; - const sides: Partial> = {}; - 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 { - 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, - section.design, - ); - updated.push(section.chainage_m); - } - return updated; -} diff --git a/B06_Section/B06_Section_Engine_Design_Node.py b/B06_Section/B06_Section_Engine_Design_Node.py index 2f67bc54a..8cf5a24a0 100644 --- a/B06_Section/B06_Section_Engine_Design_Node.py +++ b/B06_Section/B06_Section_Engine_Design_Node.py @@ -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"]] diff --git a/B06_Section/B06_Section_Router.py b/B06_Section/B06_Section_Router.py index 76195d937..18b0648c4 100644 --- a/B06_Section/B06_Section_Router.py +++ b/B06_Section/B06_Section_Router.py @@ -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": "횡단 설계 프리뷰 계산 중 오류가 발생했습니다."}, - ) diff --git a/B06_Section/B06_Section_Router_Design.py b/B06_Section/B06_Section_Router_Design.py index 44500ae1a..43f34f940 100644 --- a/B06_Section/B06_Section_Router_Design.py +++ b/B06_Section/B06_Section_Router_Design.py @@ -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 diff --git a/B06_Section/B06_Section_Schema.py b/B06_Section/B06_Section_Schema.py index 6587430e6..d8e0e5e9c 100644 --- a/B06_Section/B06_Section_Schema.py +++ b/B06_Section/B06_Section_Schema.py @@ -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) diff --git a/B06_Section/logic/B06_Section_Cross_Save.ts b/B06_Section/logic/B06_Section_Cross_Save.ts new file mode 100644 index 000000000..b9cf7a1d8 --- /dev/null +++ b/B06_Section/logic/B06_Section_Cross_Save.ts @@ -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; + +/** 파이썬 `compute_cross_design` 키 → TS 짝 키(거울 시험 test_b06_cross_design_mirror 표와 같음). */ +const CROSS_OPTION_KEYS: Record = { + 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; + 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 { + const ranges = facilityRanges(input.pipes, input.structures ?? []); + const spans = input.structures + ? bermSpansFromStructures(input.structures as Parameters[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>, + [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; +} diff --git a/common_util/common_util_calc_runner.py b/common_util/common_util_calc_runner.py index 810d1d7e6..094033949 100644 --- a/common_util/common_util_calc_runner.py +++ b/common_util/common_util_calc_runner.py @@ -75,8 +75,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: diff --git a/resources/tester/helper_266_card_toggle.mjs b/resources/tester/helper_266_card_toggle.mjs index 33f07986e..477d5aec0 100644 --- a/resources/tester/helper_266_card_toggle.mjs +++ b/resources/tester/helper_266_card_toggle.mjs @@ -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)); diff --git a/resources/tester/test_266_card_toggle_mismatch.py b/resources/tester/test_266_card_toggle_mismatch.py index 30e03293d..fa3afc410 100644 --- a/resources/tester/test_266_card_toggle_mismatch.py +++ b/resources/tester/test_266_card_toggle_mismatch.py @@ -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"] diff --git a/resources/tester/test_475_3_seat_carry.py b/resources/tester/test_475_3_seat_carry.py index c23b0bf56..d5c233ec9 100644 --- a/resources/tester/test_475_3_seat_carry.py +++ b/resources/tester/test_475_3_seat_carry.py @@ -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() diff --git a/resources/tester/test_masshaul_single_source.py b/resources/tester/test_masshaul_single_source.py index 4ecb605d0..93fc804a7 100644 --- a/resources/tester/test_masshaul_single_source.py +++ b/resources/tester/test_masshaul_single_source.py @@ -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")