diff --git a/A00_Common/design/logic/design_calc_node.ts b/A00_Common/design/logic/design_calc_node.ts index ca8f19746..779411db1 100644 --- a/A00_Common/design/logic/design_calc_node.ts +++ b/A00_Common/design/logic/design_calc_node.ts @@ -15,7 +15,8 @@ * stations: [{ samples · design_elevation_m · options: 파이썬 `compute_cross_design` 키 }] } · * berm?: { structures: 구조물 정본 목록 · chainages: [측점 m] } · * rebase? · carry?(노선 바뀐 [저장] · `design_calc_node_carry.ts`) · - * stages?(묶음 단계 · `design_calc_node_stages.ts` · 511-B) } } + * stages?(묶음 단계 · `design_calc_node_stages.ts` · 511-B) · + * stations?(측점 · `design_calc_node_sections.ts` · 511-B B-2) } } * 출력 { results: { [id]: 값 }, available: [id…] } * — 차례는 요청 차례가 아니라 기댐 차례(누가토량 앞에 구조물 면적을 상세에 얹음 · * server-calc 묶음과 같은 차례). @@ -43,12 +44,13 @@ import { } from "../../../B06_Section/B06_Section_Structure_Layouts"; import { carryRunners, type CarryInputs } from "./design_calc_node_carry"; import { followRunners, type FollowInputs } from "./design_calc_node_follow"; +import { sectionsRunners, type SectionsInputs } from "./design_calc_node_sections"; import { STAGE_IDS, stageRunners, type StagesInputs } from "./design_calc_node_stages"; import { TRIAL_IDS, trialRunners, type TrialInputs } from "./design_calc_node_trial"; type HaulOptions = NonNullable[2]>; -interface CalcInputs extends FollowInputs, CarryInputs, TrialInputs, StagesInputs { +interface CalcInputs extends FollowInputs, CarryInputs, TrialInputs, StagesInputs, SectionsInputs { detail?: SectionDetailResponse; route_points?: RoutePoint[]; context?: Partial< @@ -148,6 +150,7 @@ async function runDesignCalc(run: readonly string[], inputs: CalcInputs) { ...carryRunners(inputs), // 노선 바뀐 [저장] 다시 얹기 — 관 · 구조물 초안 · 횡단 고침(472-6 6-B) ...trialRunners(inputs), // 계산 자리 「서버」 시험 길(511-A) ...stageRunners(inputs), // 묶음 단계 노선 ~ 횡단(511-B B-1) + ...sectionsRunners(inputs), // 측점 목록 · 서버 측점 생성(511-B B-2) // 구조물 측점 면적 · 관 길이를 상세에 얹음(자리에서) — 뒤 누가토량이 고친 면적 위에서 쌓임. "structure.layouts": () => { const areas = structureAreaRows(sections); diff --git a/A00_Common/design/logic/design_calc_node_sections.ts b/A00_Common/design/logic/design_calc_node_sections.ts new file mode 100644 index 000000000..e9c6463bb --- /dev/null +++ b/A00_Common/design/logic/design_calc_node_sections.ts @@ -0,0 +1,79 @@ +/* ============================================================================= + * A00_Common/design/logic/design_calc_node_sections.ts + * 계산 Node 입구(`design_calc_node.ts`)의 측점 몫(PLAN 511-B B-2) — 화면 묶음 stations 단계와 같은 TS. + * + * stations.list 입력 stations { line · curves · pipes · structures · types · definition? · plus · + * interval_m · include_endpoint? · station_snap · station_snap_step } → + * 측점 목록(`buildStations` · 열쇠 · 종류 · 반경 · 확폭 · 지반 null) — 계산 자리 「서버」 · + * 노선 저장 옛 노선 열쇠 + * stations.sections 같은 입력 + grid(DTM 격자 이진 경로) · 표본 간격 → 서버 `generate_sections` 꼴 + * (`generateSections`) — 서버 측점 생성 길만(창구 `/calc` 는 막음 · 경로 입력) + * definition = 구조물 정의(`{types, …정의}` · 화면 `fetchStructureTypes` 와 같은 꼴) — 심는 측점 · 카드 이름 표. + * ========================================================================== */ + +import { useCatalogDefinition, type CatalogDefinition } from "@util/common_util_structure_catalog"; +import type { PlanPipe, PlanStructure, PlanType } from "@util/common_util_structure_station"; +import { buildStations } from "../../../B05_Profile/logic/B05_Profile_Sections_Stations"; +import { generateSections } from "../../../B05_Profile/logic/B05_Profile_Sections_Generate"; +import type { DesignCurve, Vertex } from "./design_pipeline_types"; +import { gridAt } from "./design_calc_node_stages"; + +export interface SectionsInputs { + stations?: { + line: Vertex[]; + curves?: DesignCurve[]; + pipes?: PlanPipe[]; + structures?: PlanStructure[]; + types: PlanType[]; + definition?: CatalogDefinition | null; + plus?: number[]; + interval_m?: number; + include_endpoint?: boolean; + station_snap?: boolean; + station_snap_step?: number | null; + grid?: string; + cross_half_width_m?: number; + cross_sample_interval_m?: number; + long_sample_interval_m?: number; + }; +} + +/** id → 셈(입구 `RUNNERS` 에 얹음). */ +export function sectionsRunners(inputs: SectionsInputs): Record unknown> { + const args = () => { + const at = inputs.stations; + if (!at) throw new Error("측점 입력(stations) 없음"); + if (at.definition !== undefined) useCatalogDefinition(at.definition); + const options = { + intervalM: at.interval_m, + includeEndpoint: at.include_endpoint, + stationSnap: at.station_snap ?? false, + stationSnapStep: at.station_snap_step ?? null, + types: at.types, + plus: at.plus ?? [], + crossHalfWidthM: at.cross_half_width_m, + crossSampleIntervalM: at.cross_sample_interval_m, + longSampleIntervalM: at.long_sample_interval_m, + }; + return { at, options }; + }; + return { + "stations.list": () => { + const { at, options } = args(); + return buildStations(at.line, at.curves ?? [], at.pipes ?? [], at.structures ?? [], options); + }, + "stations.sections": () => { + const { at, options } = args(); + if (!at.grid) throw new Error("DTM 격자 없음 — DTM 아닌 지표면은 파이썬 길"); + const grid = gridAt(at.grid); + return generateSections( + at.line, + at.curves ?? [], + at.pipes ?? [], + at.structures ?? [], + grid, + options, + ); + }, + }; +} diff --git a/A00_Common/design/logic/design_calc_node_stages.ts b/A00_Common/design/logic/design_calc_node_stages.ts index 235cd3c30..c394c75d1 100644 --- a/A00_Common/design/logic/design_calc_node_stages.ts +++ b/A00_Common/design/logic/design_calc_node_stages.ts @@ -77,7 +77,7 @@ export const STAGE_IDS = STAGE_NAMES.map((name) => `stage.${name}`); let gridMemo: { path: string; mtime: number; grid: DtmGrid } | null = null; /** DTM 격자 이진 — 같은 경로 · 같은 수정 시각이면 앞에 읽은 것(늘 떠 있는 Node). */ -function gridAt(path: string): DtmGrid { +export function gridAt(path: string): DtmGrid { const mtime = statSync(path).mtimeMs; if (gridMemo?.path !== path || gridMemo.mtime !== mtime) { const bytes = readFileSync(path); diff --git a/A00_Common/design/logic/design_calc_registry.ts b/A00_Common/design/logic/design_calc_registry.ts index 79b0e5d37..70d415e33 100644 --- a/A00_Common/design/logic/design_calc_registry.ts +++ b/A00_Common/design/logic/design_calc_registry.ts @@ -121,10 +121,14 @@ export const CALC_REGISTRY: readonly CalcEntry[] = [ site: "browser_now", stage: "stations", ts: `${B05L}B05_Profile_UI_RouteEdit_Frame.ts#stationFrameAt`, - runner: `py:${B05}B05_Profile_Engine_Sections_Core.py#generate_sections`, - until: "472-4", + runner: "node:stations.list", pair: `${B05}B05_Profile_Engine_Sections_Core.py#generate_sections`, - mirror: ["test_b05_routeedit_frame_mirror", "test_224_2_station_snap"], + mirror: [ + "test_b05_routeedit_frame_mirror", + "test_224_2_station_snap", + "test_511_b2_stations_node", + ], + note: "서버 측점 생성 = Node `stations.sections`(DTM) · 파이썬 짝 = DTM 아닌 지표면 · 추천 엔진 · B06 측점 맞춤", }, { id: "stations.list", @@ -143,11 +147,14 @@ export const CALC_REGISTRY: readonly CalcEntry[] = [ site: "browser_now", stage: "stations", ts: `${B05L}B05_Profile_Sections_Stations.ts#buildStations`, - runner: `py:${B05}B05_Profile_Engine_Sections_Core.py#generate_sections`, - until: "472-4", + runner: "node:stations.list", pair: `${B05}B05_Profile_Engine_Sections_Core.py#generate_sections`, - mirror: ["test_344_3_pipeline_stations_mirror", "test_407_curve_station"], - note: "파이썬 짝은 B03 체인 · B07 IP 표 · 추천도 부름", + mirror: [ + "test_344_3_pipeline_stations_mirror", + "test_407_curve_station", + "test_511_b2_stations_node", + ], + note: "서버 측점 생성 길(B03 체인 · 노선 저장 · 재생성 · 추천 초기 종단) = Node `stations.sections`(DTM) · 파이썬 짝 = DTM 아닌 지표면 · B07 IP 표 · 추천 엔진", }, { id: "stations.key", @@ -158,11 +165,10 @@ export const CALC_REGISTRY: readonly CalcEntry[] = [ site: "browser_now", stage: "stations", ts: `${U}common_util_station_key.ts#stationKey`, - runner: `py:${U}common_util_station_key.py#station_key`, - until: "472-4", + runner: "node:stations.list", pair: `${U}common_util_station_key.py#station_key`, - mirror: ["test_344_3_station_key_mirror"], - note: "서버 열쇠(Route_Save_Parts.station_keys)는 주인 = 파이썬 plan_stations(F3 · 카탈로그) · 부르는 곳 = 노선 저장 이월(③) — ③ · F3 와 같이 Node 로", + mirror: ["test_344_3_station_key_mirror", "test_511_b2_stations_node"], + note: "노선 저장 이월 열쇠 = Node 측점 출력(`key` · 옛 노선 = `stations.list`) · 파이썬 짝 = 옛 열쇠 꼴 읽기(at: · 누가거리)", }, { id: "stations.swap", @@ -245,10 +251,10 @@ export const CALC_REGISTRY: readonly CalcEntry[] = [ site: "browser_now", stage: "cross", ts: `${B05L}B05_Profile_Pipeline_Detail.ts#uphillSide`, - runner: `py:${B05}B05_Profile_Engine_Sections_Core.py#generate_sections`, - until: "472-4", + runner: "node:stations.sections", pair: `${B05}B05_Profile_Engine_Sections_Core.py#generate_sections`, - mirror: ["test_346_2_pipeline_cross_page"], + mirror: ["test_346_2_pipeline_cross_page", "test_511_b2_stations_node"], + note: "서버 측점 생성(DTM) = Node `stations.sections` 의 상단측 · DTM 아닌 지표면 = 파이썬 짝", }, /* ── 종단 ─────────────────────────────────────────────────────────────── */ { diff --git a/B05_Profile/B05_Profile_Engine_InitialProfile.py b/B05_Profile/B05_Profile_Engine_InitialProfile.py index 7ecc75a21..b9b035e71 100644 --- a/B05_Profile/B05_Profile_Engine_InitialProfile.py +++ b/B05_Profile/B05_Profile_Engine_InitialProfile.py @@ -40,6 +40,13 @@ SOURCE_GLOBS = ( "common_util/common_util_structure_station.py", "common_util/common_util_drainage_pipes.py", "config/config_system_design.py", + # 측점 · 지반 = Node `stations.sections`(511-B B-2) — 묶음을 낳는 TS + "B05_Profile/logic/B05_Profile_Sections_*.ts", + "B05_Profile/logic/B05_Profile_UI_RouteEdit_Frame.ts", + "common_util/common_util_structure_station.ts", + "common_util/common_util_structure_catalog.ts", + "common_util/common_util_surface_sampler.ts", + "A00_Common/design/logic/design_calc_node_sections.ts", ) # 직선 위 꼭짓점(438 직선화) 판정 — 앞뒤 기울기 차(비율) _STRAIGHT_EPS = 1e-5 @@ -126,12 +133,19 @@ def initial_longitudinal( ) -> dict[str, Any]: """[적용] 체인이 남길 종단(측점 · 계획선) — 측점 생성 · 균형 얹기와 같은 조각.""" from B05_Profile.B05_Profile_Engine_Grade_Profile import rebuild_alignment_profile - from B05_Profile.B05_Profile_Engine_Sections import build_sections, resolve_extra_stations + from B05_Profile.B05_Profile_Engine_Sections import ( + build_sections, + resolve_extra_stations, + station_facilities, + ) from B05_Profile.B05_Profile_Router_RouteInitial import seat_base_pvi polyline = [[x, y, 0.0] for x, y in line] pipes, structures, plus = facilities - extras = resolve_extra_stations(project_root, pipes, polyline, structures=structures, plus=plus) + planted = station_facilities(project_root, pipes, polyline, structures=structures, plus=plus) + extras = resolve_extra_stations( + project_root, planted[0], structures=planted[1], plus=planted[2] + ) result, _summary = build_sections( polyline, sampler, @@ -144,6 +158,7 @@ def initial_longitudinal( design_curves=curves, station_snap=True, # [적용] = 스냅 켬 · 측점 No.(`saveRecommendedRoute`) station_snap_step=None, + facilities=planted, ) longitudinal = result["longitudinal"] if target is not None and longitudinal.get("profile_alignment"): diff --git a/B05_Profile/B05_Profile_Engine_Sections.py b/B05_Profile/B05_Profile_Engine_Sections.py index 4c91bf6dc..31884c6ba 100644 --- a/B05_Profile/B05_Profile_Engine_Sections.py +++ b/B05_Profile/B05_Profile_Engine_Sections.py @@ -34,6 +34,7 @@ from B05_Profile.B05_Profile_Engine_Sections_Names import ( # noqa: F401 — prune_stale_cross_files, valid_cross_names, ) +from B05_Profile.B05_Profile_Engine_Sections_Node import node_sections from B05_Profile.B05_Profile_Structures_Repository import load_structures from B05_Profile.B05_Profile_Structures_Schema import ( structure_type_map, @@ -257,6 +258,24 @@ def resolve_extra_stations( `xy` = 지금 노선 — 노선 밖(시점 앞 · 종점 뒤 · `route_outside`) 관 · 구조물은 뺌(459). `structures` · `plus` = 파일 대신 따라간 사본(476 추천 초기 종단 · 파일 안 씀). """ + return tuple( + (float(station["chainage_m"]), str(station["title"])) + for station in plan_stations( + *station_facilities(project_root, pipes, xy, structures=structures, plus=plus) + ) + ) + + +def station_facilities( + project_root: Path, + pipes: list[PipePoint], + xy: Sequence[Sequence[float]] | None = None, + *, + structures: list[Any] | None = None, + plus: list[float] | None = None, +) -> tuple[list[PipePoint], list[Any], list[float]]: + """측점을 심는 관 · 구조물 · 추가 측점(노선 밖 뺌) — 파이썬 `plan_stations` · + Node 측점 생성이 같은 입력.""" from B05_Profile.B05_Profile_Plus_Stations import plus_chainages from common_util.common_util_route_follow import route_outside @@ -273,12 +292,7 @@ def resolve_extra_stations( for s in structures if not route_outside(getattr(s, "x", None), getattr(s, "y", None), xy) ] - return tuple( - (float(station["chainage_m"]), str(station["title"])) - for station in plan_stations( - pipes, structures, plus_chainages(project_root) if plus is None else plus - ) - ) + return pipes, structures, plus_chainages(project_root) if plus is None else list(plus) def _append_design_profiles( @@ -408,21 +422,28 @@ def build_sections( design_curves: list[dict[str, Any]], station_snap: bool, station_snap_step: float | None, + facilities: tuple[list[Any], list[Any], list[float]] | None = None, ) -> tuple[dict[str, Any], dict[str, Any] | None]: """측점 틀 · 지반 · 1차 계획선(438) · 포장 제안 — 파일 안 씀 · (결과, 계획선 요약). 종횡단 생성(`run_section_generation`)과 추천 후보 초기 종단(476 · `..._RouteInitial_Initial`) - 이 같은 셈 한 벌.""" - result = generate_sections( - polyline, - sampler, - replace(options or SectionGenerationOptions(), extra_stations=extras), - source_snapshot=source_snapshot, - crs=crs, - design_curves=design_curves, - station_snap=station_snap, - station_snap_step=station_snap_step, - ) + 이 같은 셈 한 벌. 측점 · 지반 = 화면과 같은 TS(Node `stations.sections` · 511-B B-2 · + `facilities` = `station_facilities`) · DTM 아닌 지표면 · Node 못 돌림 = + 파이썬 `generate_sections`.""" + generation = replace(options or SectionGenerationOptions(), extra_stations=extras) + shared = { + "crs": crs, + "design_curves": design_curves, + "station_snap": station_snap, + "station_snap_step": station_snap_step, + } + result = ( + node_sections( + polyline, sampler, facilities, generation, source_snapshot=source_snapshot, **shared + ) + if facilities is not None + else None + ) or generate_sections(polyline, sampler, generation, source_snapshot=source_snapshot, **shared) grade_summary = _append_design_profiles( result["longitudinal"], grade_options, @@ -475,10 +496,13 @@ def run_section_generation( # 값이라, 되먹이면 B04에서 지운 관이 파일 정본을 이기고 되살아난다. pipes = _load_pipe_points(project_root, polyline) try: - extras = resolve_extra_stations(project_root, pipes, polyline) + facilities = station_facilities(project_root, pipes, polyline) + extras = resolve_extra_stations( + project_root, facilities[0], structures=facilities[1], plus=facilities[2] + ) except Exception: logger.exception("B05 구조물 측점 해석 실패 (규칙 격자만 생성)") - extras = () + facilities, extras = ([], [], []), () logger.info("B05 종횡단: 구조물 측점 %d건 파생(관 %d건)", len(extras), len(pipes)) result, grade_summary = build_sections( @@ -495,6 +519,7 @@ def run_section_generation( design_curves=_load_design_curves(project_root), station_snap=load_station_snap(project_root), station_snap_step=load_station_snap_step(project_root), + facilities=facilities, ) stage_root = project_root / _STAGE_SUBDIR diff --git a/B05_Profile/B05_Profile_Engine_Sections_Core.py b/B05_Profile/B05_Profile_Engine_Sections_Core.py index 8cc6afa33..203ef2d40 100644 --- a/B05_Profile/B05_Profile_Engine_Sections_Core.py +++ b/B05_Profile/B05_Profile_Engine_Sections_Core.py @@ -16,6 +16,9 @@ (`buildStations`). 거울 시험 `resources/tester/test_344_3_pipeline_stations_mirror.py`. ⚠ 파이썬·TS 짝 — `generate_sections` 상단측(`uphill_side`) ↔ `B05_Profile/logic/B05_Profile_Pipeline_Detail.ts` (`uphillSide`). 거울 시험 `resources/tester/test_346_2_pipeline_cross_page.py`. +서버 측점 생성 길(`build_sections`)은 DTM 이면 같은 TS(Node `stations.sections` · 511-B B-2 · +`B05_Profile_Engine_Sections_Node.py`) — 이 짝을 쓰는 곳 = DTM 아닌 지표면 · Node 못 돌림 · +구조물 측점만 뜨기(`sample_structure_sections`) · B06 곡선 측점 판정 · 추천 엔진(`_plan_radii` 등). """ import math diff --git a/B05_Profile/B05_Profile_Engine_Sections_Node.py b/B05_Profile/B05_Profile_Engine_Sections_Node.py new file mode 100644 index 000000000..22748259b --- /dev/null +++ b/B05_Profile/B05_Profile_Engine_Sections_Node.py @@ -0,0 +1,157 @@ +"""서버 측점 생성 = 화면과 같은 TS(PLAN 511-B B-2 · 요구 320-4) — 늘 떠 있는 Node +`stations.sections`. + +`build_sections`(B03 체인 · 노선 저장 · 재생성 · 넓힘 · 추천 초기 종단)가 부름 — 측점 자리 · +종류 · 열쇠 · 반경 · 확폭 · 지반 · 틀 · 상단측 = +`B05_Profile/logic/B05_Profile_Sections_Generate.ts` +(화면 묶음 stations · ground 단계와 같은 함수). 파이썬은 머리(schema_version · source · +coordinate_reference · cad_exchange · options)만 붙임. + +DTM 지표면만(격자 이진 = `dtm_grid_file`) — DTM 아닌 지표면(tin · nurbs · implicit · meshfree) · +Node 못 돌림 = None → 부르는 쪽이 파이썬 `generate_sections`(짝 · 로그 남김). +측점 열쇠(`key`) = 노선 저장 이월 열쇠(`B05_Profile_Route_Save._carry_cross`). +""" + +from __future__ import annotations + +import logging +from typing import Any + +from B05_Profile.B05_Profile_Engine_Sections_Core import ( + SECTION_SCHEMA_VERSION, + SectionGenerationOptions, +) + +logger = logging.getLogger(__name__) + + +def plan_pipe(pipe: Any) -> dict[str, Any]: + """관 정본 → TS `PlanPipe`(누가거리 그대로 · 파이썬 `plan_stations` 가 읽는 칸).""" + return { + "id": getattr(pipe, "id", None), + "chainage_m": float(pipe.chainage_m), + "facility": getattr(pipe, "facility", None) or "pipe", + "start_m": getattr(pipe, "start_m", None), + "end_m": getattr(pipe, "end_m", None), + "options": getattr(pipe, "options", None) or {}, + } + + +def structure_table() -> tuple[list[dict[str, Any]], dict[str, Any]]: + """구조물 종류(`/projects/structure-types` 꼴) · 정의(화면 `useCatalogDefinition` 꼴).""" + from B05_Profile.B05_Profile_Structures_Schema import _read_definition, _stamp + from M02_MasterTemplete.StructOptions.M02_SO_Options import with_visible + + types = [item.model_dump(mode="json") for item in with_visible().types] + return types, {"types": types, **_read_definition(*_stamp())} + + +def stations_input( + xy: list[list[float]], + pipes: list[Any], + structures: list[Any], + plus: list[float], + options: SectionGenerationOptions, + design_curves: list[dict[str, Any]], + station_snap: bool, + station_snap_step: float | None, +) -> dict[str, Any]: + """Node `stations.list` · `stations.sections` 입력(격자 빼고).""" + types, definition = structure_table() + return { + "line": xy, + "curves": design_curves, + "pipes": [plan_pipe(pipe) for pipe in pipes], + "structures": [ + item.model_dump(mode="json") if hasattr(item, "model_dump") else dict(item) + for item in structures + ], + "types": types, + "definition": definition, + "plus": [float(at) for at in plus], + "interval_m": options.station_interval_m, + "include_endpoint": options.include_endpoint, + "station_snap": station_snap, + "station_snap_step": station_snap_step, + "cross_half_width_m": options.cross_half_width_m, + "cross_sample_interval_m": options.cross_sample_interval_m, + "long_sample_interval_m": options.long_sample_interval_m, + } + + +def node_stations(stations: dict[str, Any]) -> list[dict[str, Any]] | None: + """측점 목록(`buildStations` · 열쇠 · 누가거리 · 종류) — 못 돌림 None.""" + from common_util.common_util_calc_runner import run_calc_served + + answer = run_calc_served(["stations.list"], {"stations": stations}) + if not answer or "error" in answer: + logger.warning("Node 측점 목록 실패: %s", (answer or {}).get("error")) + return None + return answer["results"]["stations.list"] + + +def node_sections( + polyline: list[list[float]], + sampler: Any, + facilities: tuple[list[Any], list[Any], list[float]], + options: SectionGenerationOptions, + *, + source_snapshot: dict[str, Any], + crs: str | None, + design_curves: list[dict[str, Any]], + station_snap: bool, + station_snap_step: float | None, +) -> dict[str, Any] | None: + """`generate_sections` 와 같은 꼴(+ 측점 `key`) — DTM 아님 · Node 못 돌림 = None.""" + from common_util.common_util_calc_runner import run_calc_served + from common_util.common_util_surface_sampler import dtm_grid_file + + source = getattr(sampler, "source", None) + if not source: + return None + options.validate() + pipes, structures, plus = facilities + stations = stations_input( + [[float(p[0]), float(p[1])] for p in polyline], + pipes, + structures, + plus, + options, + design_curves, + station_snap, + station_snap_step, + ) + stations["grid"] = str(dtm_grid_file(source)) + answer = run_calc_served(["stations.sections"], {"stations": stations}) + if not answer or "error" in answer: + logger.warning("Node 측점 생성 실패 — 파이썬 길: %s", (answer or {}).get("error")) + return None + out = answer["results"]["stations.sections"] + return { + "schema_version": SECTION_SCHEMA_VERSION, + "status": "completed", + "source": source_snapshot or {}, + "coordinate_reference": { + "crs": crs, + "world_axes": {"x": "project_easting", "y": "project_northing", "z": "elevation"}, + "units": {"horizontal": "m", "vertical": "m", "angle": "degree"}, + }, + "cad_exchange": { + "station_origin": "BP", + "chainage_direction": "BP_to_EP", + "cross_offset_sign": {"negative": "right", "positive": "left"}, + "cross_local_axes": {"x": "offset_m", "y": "elevation_m"}, + "recommended_drawing_datum_m": out["datum"], + }, + "options": { + "station_interval_m": options.station_interval_m, + "cross_half_width_m": options.cross_half_width_m, + "cross_sample_interval_m": options.cross_sample_interval_m, + "long_sample_interval_m": options.long_sample_interval_m, + "include_endpoint": options.include_endpoint, + "extra_stations": [list(entry) for entry in options.extra_stations], + }, + "longitudinal": out["longitudinal"], + "cross_sections": out["cross_sections"], + "summary": out["summary"], + } diff --git a/B05_Profile/logic/B05_Profile_Sections_Generate.ts b/B05_Profile/logic/B05_Profile_Sections_Generate.ts new file mode 100644 index 000000000..ed045ec1c --- /dev/null +++ b/B05_Profile/logic/B05_Profile_Sections_Generate.ts @@ -0,0 +1,107 @@ +/* ============================================================================= + * B05_Profile/logic/B05_Profile_Sections_Generate.ts + * 측점 생성 한 벌(PLAN 511-B B-2) — 노선 + 관 · 구조물 · 추가 측점 + DTM 격자 → 서버 `generate_sections` + * 꼴(종단 표본 · 측점 · 횡단 표본). 서버 측점 생성 길(`B05_Profile_Engine_Sections_Node.py` → Node + * `stations.sections`)이 부름 — 화면 묶음과 같은 측점(`buildStations`) · 같은 지반(`sectionsGround`). + * + * 측점 = `buildStations`(열쇠 · 종류 · 반경 · 확폭) · 지반 · 틀 · 상단측 = `sectionsGround` · 이름 = + * `plantedStations`(카드 이름) · 칸 차례 · 자릿수 = 파이썬 `generate_sections` 그대로 + 측점 열쇠 `key`. + * 머리(schema_version · source · coordinate_reference · cad_exchange · options)는 파이썬이 붙임. + * 순수 — 화면 · fetch · 세션 없음. + * ========================================================================== */ + +import type { DtmGrid } from "../../common_util/common_util_surface_sampler"; +import { stationsAlong } from "./B05_Profile_UI_RouteEdit_Frame"; +import { formatStation } from "../../B06_Section/logic/B06_Section_Station_Swap"; +import { stationIdOf } from "./B05_Profile_Pipeline_Grade"; +import { + buildStations, + lineLength, + plantedStations, + SECTION_INCLUDE_ENDPOINT, + SECTION_STATION_INTERVAL_M, + type StationsOptions, +} from "./B05_Profile_Sections_Stations"; +import { sectionsGround, type GroundOptions } from "./B05_Profile_Sections_Ground"; +import type { PlanPipe, PlanStructure } from "../../common_util/common_util_structure_station"; +import type { DesignCurve, Vertex } from "../../A00_Common/design/logic/design_pipeline_types"; + +export type GenerateOptions = Omit & GroundOptions; + +const KIND: Record = { + grid: "regular", + curve: "curve", + pipe: "irregular", + structure: "irregular", +}; + +/** 서버 `generate_sections` 꼴 몸통 — 종단(길이 · 표본 · 측점) · 횡단 · 요약 · 도면 기준고. */ +export function generateSections( + line: ReadonlyArray, + curves: ReadonlyArray, + pipes: ReadonlyArray, + structures: ReadonlyArray, + grid: DtmGrid, + options: GenerateOptions, +) { + const stations = buildStations(line, curves, pipes, structures, options); + if (stations.length < 2) throw new Error("경로는 최소 2개의 (x, y, z) 좌표로 구성되어야 합니다."); + const gridM = stationsAlong( + line, + options.intervalM ?? SECTION_STATION_INTERVAL_M, + options.includeEndpoint ?? SECTION_INCLUDE_ENDPOINT, + ); + const planted = plantedStations(gridM, lineLength(line), pipes, structures, options); + const chainages = stations.map((station) => station.chainageM); + const ground = sectionsGround(line, chainages, grid, options); + const crossSections = stations.map((station, index) => { + const at = ground.stations[index]; + const mark = [...planted].find(([m]) => Math.abs(m - station.chainageM) < 1e-6)?.[1]; + const kind = index === 0 ? "bp" : station.kind === "end" ? "ep" : KIND[station.kind]; + const head: Record = { + station_id: stationIdOf(station.chainageM), + chainage_m: at.chainage_m, + label: formatStation(station.chainageM) + (kind === "curve" ? " (MC)" : ""), + kind, + center_x: at.center_x, + center_y: at.center_y, + center_z: at.center_z, + azimuth_deg: station.azimuthDeg, + plan_radius_m: station.planRadiusM, + curve_outer_side: station.curveOuterSide, + curve_widening_m: station.curveWideningM, + uphill_side: at.uphill_side, + frame: { + origin: { x: at.center_x, y: at.center_y, z: at.center_z }, + tangent_xy: at.tangent_xy, + left_xy: at.left_xy, + up_xyz: [0.0, 0.0, 1.0], + }, + }; + if (mark) { + head.structure = mark.title; + if (gridM.some((m) => Math.abs(m - station.chainageM) < 1e-6)) head.grid = true; + } + head.key = station.key; + return { head, samples: at.samples }; + }); + const z = ground.samples.flatMap((sample) => (sample.z === null ? [] : [sample.z])); + return { + longitudinal: { + length_m: ground.length_m, + samples: ground.samples, + stations: crossSections.map(({ head }) => head), + }, + cross_sections: crossSections.map(({ head, samples }) => ({ ...head, samples })), + summary: { + station_count: stations.length, + cross_sample_count: crossSections.reduce((sum, { samples }) => sum + samples.length, 0), + invalid_longitudinal_samples: ground.samples.filter((sample) => !sample.valid).length, + invalid_cross_samples: crossSections.reduce( + (sum, { samples }) => sum + samples.filter((sample) => !sample.valid).length, + 0, + ), + }, + datum: z.length ? Math.floor(Math.min(...z) / 10) * 10 : null, + }; +} diff --git a/B05_Profile/logic/B05_Profile_Sections_Ground.ts b/B05_Profile/logic/B05_Profile_Sections_Ground.ts index b0b81e635..2b9fba094 100644 --- a/B05_Profile/logic/B05_Profile_Sections_Ground.ts +++ b/B05_Profile/logic/B05_Profile_Sections_Ground.ts @@ -69,9 +69,9 @@ export interface GroundOptions { longSampleIntervalM?: number; } -/** 파이썬 `round(x, 6)` 자리. */ -export const round6 = (value: number): number => Math.round(value * 1e6) / 1e6; -const round9 = (value: number): number => Math.round(value * 1e9) / 1e9; +/** 파이썬 `round(x, 6)` 자리 — 십진 자릿수 반올림(곱셈 반올림 오차 없음 · 511-B B-2). */ +export const round6 = (value: number): number => Number(value.toFixed(6)); +const round9 = (value: number): number => Number(value.toFixed(9)); function sample(grid: DtmGrid, x: number, y: number): GroundSample { const z = heightAt(grid, x, y); diff --git a/B05_Profile/logic/B05_Profile_Sections_Stations.ts b/B05_Profile/logic/B05_Profile_Sections_Stations.ts index bd95a988c..4d7f85ecc 100644 --- a/B05_Profile/logic/B05_Profile_Sections_Stations.ts +++ b/B05_Profile/logic/B05_Profile_Sections_Stations.ts @@ -239,31 +239,31 @@ export interface StationsOptions { } /** 노선 길이(m) — 길이 0 선분은 0(서버 `_clean_polyline`). */ -const lineLength = (line: ReadonlyArray): number => +export const lineLength = (line: ReadonlyArray): number => line.reduce( (sum, vertex, index) => index ? sum + Math.hypot(vertex[0] - line[index - 1][0], vertex[1] - line[index - 1][1]) : 0, 0, ); -/** 측점 한 벌 — 서버 `generate_sections` 측점 목록과 같은 자리 · 종류 · 틀 · 반경 · 확폭(지반 null). */ -export function buildStations( - line: ReadonlyArray, - curves: ReadonlyArray, +/** 심는 측점 한 자리 — 주인 종류 · id · 역할 · 카드 이름(서버 측점 `structure` 칸). */ +export interface PlantedMark { + kind: "pipe" | "structure"; + id: string; + role: string; + title: string; +} + +/** 심는 측점(관 · 구조물) — 격자 0.005 m 안이면 격자 자리에 얹고 서로 겹치면 앞선 것만(서버 `extras`). */ +export function plantedStations( + grid: ReadonlyArray, + total: number, pipes: ReadonlyArray, structures: ReadonlyArray, - options: StationsOptions, -): PipelineStation[] { - if (line.length < 2) return []; - const intervalM = options.intervalM ?? SECTION_STATION_INTERVAL_M; - const total = lineLength(line); - // 일반측점은 스냅이 옮기지 않음(요구 280-4) — MC 는 아래 곡선 측점으로 더함 - const grid = stationsAlong(line, intervalM, options.includeEndpoint ?? SECTION_INCLUDE_ENDPOINT); - const spans = designSpans(line, curves); - - // 심는 측점(관 · 구조물) — 격자 0.005 m 안이면 격자 자리에 얹고 서로 겹치면 앞선 것만(서버 `extras`). + options: Pick, +): Map { const pipeIds = new Set(pipes.map((pipe) => pipe.id ?? `pipe-${pipe.chainage_m.toFixed(2)}`)); - const planted = new Map(); + const planted = new Map(); for (const station of planStations( pipes, structures, @@ -280,8 +280,27 @@ export function buildStations( kind: pipeIds.has(owner) ? "pipe" : "structure", id: owner, role: station.role, + title: station.title, }); } + return planted; +} + +/** 측점 한 벌 — 서버 `generate_sections` 측점 목록과 같은 자리 · 종류 · 틀 · 반경 · 확폭(지반 null). */ +export function buildStations( + line: ReadonlyArray, + curves: ReadonlyArray, + pipes: ReadonlyArray, + structures: ReadonlyArray, + options: StationsOptions, +): PipelineStation[] { + if (line.length < 2) return []; + const intervalM = options.intervalM ?? SECTION_STATION_INTERVAL_M; + const total = lineLength(line); + // 일반측점은 스냅이 옮기지 않음(요구 280-4) — MC 는 아래 곡선 측점으로 더함 + const grid = stationsAlong(line, intervalM, options.includeEndpoint ?? SECTION_INCLUDE_ENDPOINT); + const spans = designSpans(line, curves); + const planted = plantedStations(grid, total, pipes, structures, options); const withPlanted = [...new Set([...grid, ...planted.keys()])]; // 곡선 측점(252-2) — MC 에 측점이 없으면 더함 · 값 자릿수 = 스냅 간격(스냅 끔 = 정수 m) · 격자 번호 없음 const curved = curveStations( diff --git a/B06_Section/B06_Section_Router_Calc.py b/B06_Section/B06_Section_Router_Calc.py index b7aa5fd68..b367ef28c 100644 --- a/B06_Section/B06_Section_Router_Calc.py +++ b/B06_Section/B06_Section_Router_Calc.py @@ -16,7 +16,7 @@ from uuid import UUID from fastapi import APIRouter, Request from fastapi.responses import JSONResponse -from common_util.common_util_calc_runner import run_calc_served +from common_util.common_util_calc_runner import SERVER_ONLY_IDS, run_calc_served router = APIRouter(prefix="/api/projects", tags=["B06 Calc Server"]) @@ -48,6 +48,8 @@ async def run_calc_endpoint(project_id: UUID, request: Request) -> dict | JSONRe return _error(400, "run = id 목록") if not isinstance(inputs, dict): return _error(400, "inputs = 객체") + if SERVER_ONLY_IDS.intersection(run): + return _error(400, "서버 측점 생성은 창구 밖") try: answer = await asyncio.to_thread(run_calc_served, run, inputs) except ValueError as exc: diff --git a/common_util/common_util_calc_runner.py b/common_util/common_util_calc_runner.py index da8b2b5a7..810d1d7e6 100644 --- a/common_util/common_util_calc_runner.py +++ b/common_util/common_util_calc_runner.py @@ -44,8 +44,12 @@ NODE_IDS = frozenset( "cross.berm_spans", "facility.rebase", "stations.carry", + "stations.list", + "stations.sections", } ) +# 창구(`/calc`)가 안 받는 id — 서버 파일 경로를 입력으로 받음(측점 생성 · 511-B B-2) +SERVER_ONLY_IDS = frozenset({"stations.sections"}) # 시험 길(`design_calc_node_trial.ts` `TRIAL` 표와 같음) — 조작 중 브라우저 함수를 서버가 같은 TS 로 TRIAL_IDS = frozenset( { diff --git a/common_util/common_util_surface_sampler.py b/common_util/common_util_surface_sampler.py index d8b9c44a2..32d86f06f 100644 --- a/common_util/common_util_surface_sampler.py +++ b/common_util/common_util_surface_sampler.py @@ -131,7 +131,9 @@ class DtmGridSampler: # ponytail: 지표면 4벌(sheet 격자 한 벌 약 17 MB) — 서버 프로세스 하나 전제 · 늘면 maxsize 를 셈. @lru_cache(maxsize=4) def _open_dtm(path: str, _mtime_ns: int, _size: int) -> DtmGridSampler: - return DtmGridSampler.from_npz(path) + sampler = DtmGridSampler.from_npz(path) + sampler.source = path # npz 경로 — 서버 측점 생성이 같은 격자를 Node 에 넘김(511-B B-2) + return sampler #: 브라우저로 보내는 격자 꼴(`dtm_grid_bytes`) 머리 — TS `parseDtmGrid` 가 같은 값을 봄. diff --git a/resources/tester/test_472_3_calc_registry.py b/resources/tester/test_472_3_calc_registry.py index c1e3067ba..8461439fc 100644 --- a/resources/tester/test_472_3_calc_registry.py +++ b/resources/tester/test_472_3_calc_registry.py @@ -48,6 +48,8 @@ NODE_IDS = { "cross.berm_spans", "facility.rebase", "stations.carry", + "stations.list", + "stations.sections", } pytestmark = pytest.mark.skipif( diff --git a/resources/tester/test_472_3_calc_runner.py b/resources/tester/test_472_3_calc_runner.py index 9e223bc18..75e718f8b 100644 --- a/resources/tester/test_472_3_calc_runner.py +++ b/resources/tester/test_472_3_calc_runner.py @@ -69,8 +69,8 @@ def test_실행기_값_server_calc_와_같음() -> None: def test_Node_실행기_아닌_id_는_ValueError() -> None: - with pytest.raises(ValueError, match="stations.list"): - runner.run_calc(["masshaul.cumulative", "stations.list"], {}) + with pytest.raises(ValueError, match="ground.widen"): + runner.run_calc(["masshaul.cumulative", "ground.widen"], {}) # ── 472-4 ② — 횡단 설계선 · 소단 구간 Node 길 ────────────────────────────────── diff --git a/resources/tester/test_511_a_calc_server.py b/resources/tester/test_511_a_calc_server.py index 3054674f7..01600c49d 100644 --- a/resources/tester/test_511_a_calc_server.py +++ b/resources/tester/test_511_a_calc_server.py @@ -114,8 +114,8 @@ def test_본문_상한_꼴_모르는_id(client, monkeypatch): assert http.post(url, content=b"{nope").status_code == 400 assert http.post(url, json={"run": "cross.fit"}).status_code == 400 assert http.post(url, json={"run": ["cross.fit"], "inputs": []}).status_code == 400 - bad = http.post(url, json={"run": ["stations.list"], "inputs": {}}) - assert bad.status_code == 400 and "stations.list" in bad.json()["message"] + bad = http.post(url, json={"run": ["ground.widen"], "inputs": {}}) + assert bad.status_code == 400 and "ground.widen" in bad.json()["message"] def test_떠_있는_Node_답_파일_길과_같음_죽어도_다시(): diff --git a/resources/tester/test_511_b2_stations_node.py b/resources/tester/test_511_b2_stations_node.py new file mode 100644 index 000000000..a8a5a389b --- /dev/null +++ b/resources/tester/test_511_b2_stations_node.py @@ -0,0 +1,201 @@ +"""PLAN 511-B B-2 (요구 320-4) — 서버 측점 생성 = 화면과 같은 TS(Node `stations.sections`). + +못박는 것 + - 고정 자료(`fixtures/golden_472`) 노선 · 관 · 구조물 · 추가 측점 — `build_sections` + Node 길 = 파이썬 `generate_sections` 길 0 차(칸 · 차례 · 값 · 측점 열쇠 `key` 만 더함) — + 곡선표 있음 · 없음 · 스냅 10 m · 측점 간격 10 m · 끝 측점 뺌. + - DTM 아닌 지표면 = 파이썬 길(Node 안 부름 · 열쇠 없음). + - 열쇠 = Node `stations.list`(화면 묶음 stations 단계 `buildStations`) 열쇠. + - 창구(`/calc`)는 `stations.sections`(서버 파일 경로 입력)를 안 받음. + - 등록부 R5 · R6 · R7 = node:stations.list · G4 = node:stations.sections(472-4 until 없음). + - 공유 DB · 실제 storage 안 씀(고정 자료 사본). + + ./venv/Scripts/python.exe -m pytest resources/tester/test_511_b2_stations_node.py -q +""" + +from __future__ import annotations + +import sys +from dataclasses import replace +from pathlib import Path + +import numpy as np +import pytest + +HERE = Path(__file__).resolve().parent +ROOT = HERE.parents[1] +for path in (ROOT, HERE): + if str(path) not in sys.path: + sys.path.insert(0, str(path)) + +import helper_472_1_paths as paths # noqa: E402 + +from B05_Profile import B05_Profile_Engine_Sections as sections # noqa: E402 +from B05_Profile import B05_Profile_Engine_Sections_Node as node # noqa: E402 +from B05_Profile.B05_Profile_Engine_Sections_Core import SectionGenerationOptions # noqa: E402 +from common_util import common_util_calc_runner as runner # noqa: E402 +from common_util.common_util_surface_sampler import ( # noqa: E402 + CallableSurfaceSampler, + build_surface_sampler, +) + +pytestmark = pytest.mark.skipif( + not (ROOT / "config" / "node_modules" / "vite").is_dir(), reason="프론트엔드 의존성 미설치" +) + + +@pytest.fixture(scope="module") +def project(tmp_path_factory): + root = paths.stage_project(tmp_path_factory.mktemp("b2") / "project") + summary = paths.read("summary.json")["longitudinal"]["data"] + options = SectionGenerationOptions( + **{k: v for k, v in summary["options"].items() if k != "extra_stations"} + ) + polyline = sections._load_route_polyline(root, paths.ROUTE) + pipes = sections._load_pipe_points(root, polyline) + facilities = sections.station_facilities(root, pipes, polyline) + extras = sections.resolve_extra_stations( + root, facilities[0], structures=facilities[1], plus=facilities[2] + ) + return { + "root": root, + "options": options, + "polyline": polyline, + "pipes": pipes, + "facilities": facilities, + "extras": extras, + "sampler": build_surface_sampler(root / "B04_PreProcess/models", *paths.SURFACE), + "curves": sections._load_design_curves(root), + } + + +def _both(project, sampler=None, **change): + kw = { + "options": project["options"], + "grade_options": None, + "source_snapshot": {"filter": "classification"}, + "crs": "EPSG:5186", + "design_curves": project["curves"], + "station_snap": sections.load_station_snap(project["root"]), + "station_snap_step": sections.load_station_snap_step(project["root"]), + **change, + } + args = (project["polyline"], sampler or project["sampler"], project["pipes"], project["extras"]) + python, _ = sections.build_sections(*args, **kw) + noded, _ = sections.build_sections(*args, facilities=project["facilities"], **kw) + return python, noded + + +def _without_key(value): + if isinstance(value, dict): + return {k: _without_key(v) for k, v in value.items() if k != "key"} + if isinstance(value, list): + return [_without_key(v) for v in value] + return value + + +def _first_diff(a, b, path=""): + """첫 다른 자리(값 · 칸 차례) — 큰 글 비교를 pytest 에 안 넘김. 수 = 같은 값(0 ↔ 0.0).""" + if isinstance(a, dict) and isinstance(b, dict): + if list(a) != list(b): + return f"{path}: 칸 {list(a)} != {list(b)}" + return next((d for k in a if (d := _first_diff(a[k], b[k], f"{path}.{k}"))), None) + if isinstance(a, list) and isinstance(b, list): + if len(a) != len(b): + return f"{path}: 길이 {len(a)} != {len(b)}" + return next( + (d for i, (x, y) in enumerate(zip(a, b)) if (d := _first_diff(x, y, f"{path}[{i}]"))), + None, + ) + return None if a == b else f"{path}: {a!r} != {b!r}" + + +def _same(python, noded): + hit = _first_diff(python, _without_key(noded)) # 칸 차례 · 자릿수까지 + assert hit is None, hit + keys = [s.get("key") for s in noded["longitudinal"]["stations"]] + assert all(keys) and keys == [s["key"] for s in noded["cross_sections"]] + return keys + + +@pytest.mark.parametrize( + "case", + ["as_saved", "no_curves", "snap10", "interval10", "no_endpoint"], +) +def test_Node_길_파이썬_길_0차(project, case): + change = { + "as_saved": {}, + "no_curves": {"design_curves": []}, + "snap10": {"station_snap": True, "station_snap_step": 10.0}, + "interval10": {"options": replace(project["options"], station_interval_m=10.0)}, + "no_endpoint": {"options": replace(project["options"], include_endpoint=False)}, + }[case] + python, noded = _both(project, **change) + keys = _same(python, noded) + assert len(keys) == len(set(keys)) > 10 + if case == "as_saved": + assert len(keys) == 57 and keys[0] == "end:BP" and keys[-1] == "end:EP" + kinds = {s["kind"] for s in python["longitudinal"]["stations"]} + assert {"bp", "ep", "regular", "irregular"} <= kinds + assert any(k.startswith("pipe:") for k in keys) + + +def test_열쇠_Node_측점_목록과_같음(project): + _, noded = _both(project) + opts = project["options"] + stations = node.stations_input( + [[p[0], p[1]] for p in project["polyline"]], + *project["facilities"], + opts, + project["curves"], + sections.load_station_snap(project["root"]), + sections.load_station_snap_step(project["root"]), + ) + listed = node.node_stations(stations) + assert [s["key"] for s in listed] == [s["key"] for s in noded["longitudinal"]["stations"]] + assert [s["chainageM"] for s in listed] == [ + s["chainage_m"] for s in noded["longitudinal"]["stations"] + ] + + +def test_DTM_아닌_지표면_파이썬_길(project, monkeypatch): + called = [] + monkeypatch.setattr(runner, "run_calc_served", lambda *a: called.append(a)) + flat = CallableSurfaceSampler(lambda xy: np.full(len(xy), 100.0)) + python, noded = _both(project, sampler=flat) + assert called == [] and python == noded + assert "key" not in noded["longitudinal"]["stations"][0] + + +def test_Node_못_돌림_파이썬_길(project, monkeypatch): + monkeypatch.setattr(runner, "run_calc_served", lambda *a: None) + python, noded = _both(project) + assert python == noded + + +def test_창구는_서버_측점_생성_안_받음(): + from fastapi import FastAPI + from fastapi.testclient import TestClient + + from B06_Section import B06_Section_Router_Calc as calc_router + + app = FastAPI() + app.include_router(calc_router.router) + http = TestClient(app) + url = "/api/projects/11111111-2222-3333-4444-555555555555/calc" + body = {"run": ["stations.sections"], "inputs": {"stations": {"grid": "C:/x"}}} + bad = http.post(url, json=body) + assert bad.status_code == 400 and "창구 밖" in bad.json()["message"] + assert runner.SERVER_ONLY_IDS <= runner.NODE_IDS + + +def test_등록부_측점_줄_Node(): + src = (ROOT / "A00_Common/design/logic/design_calc_registry.ts").read_text(encoding="utf-8") + for no, run in ( + ("R5", "stations.list"), + ("R6", "stations.list"), + ("R7", "stations.list"), + ("G4", "stations.sections"), + ): + row = src.split(f'no: "{no}"')[1].split("\n },")[0] + assert f'runner: "node:{run}"' in row and "until" not in row, no