feat(511-B B-2 서버 측점 생성 = 같은 TS): build_sections(B03 체인 · 노선 저장 · 재생성 · 넓힘 · 추천 초기 종단) = DTM 이면 늘 떠 있는 Node stations.sections(buildStations · sectionsGround · 측점 열쇠 key 더함 · 파이썬은 머리만) · DTM 아닌 지표면 · Node 못 돌림 = 파이썬 generate_sections · stations.list(측점 목록 · 열쇠) · /calc 창구는 stations.sections 안 받음(서버 경로 입력) · 등록부 R5 R6 R7 = node:stations.list · G4 = node:stations.sections · round6 = 십진 반올림(파이썬 round 꼴) · 추천 저장본 지문에 TS 더함 · 고정 자료 Node 길 = 파이썬 길 0 차(다섯 갈래) · golden 그대로 — 요구 320-4

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-22 14:22:25 +09:00
co-authored by Claude Opus 5.5
parent f6abe881a2
commit f7e9751b81
18 changed files with 688 additions and 63 deletions
+5 -2
View File
@@ -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<Parameters<typeof computeHaulPlan>[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);
@@ -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<string, () => 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,
);
},
};
}
@@ -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);
+20 -14
View File
@@ -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 아닌 지표면 = 파이썬 짝",
},
/* ── 종단 ─────────────────────────────────────────────────────────────── */
{
@@ -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"):
+44 -19
View File
@@ -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
@@ -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
@@ -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"],
}
@@ -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<StationsOptions, "registry"> & GroundOptions;
const KIND: Record<string, string> = {
grid: "regular",
curve: "curve",
pipe: "irregular",
structure: "irregular",
};
/** 서버 `generate_sections` 꼴 몸통 — 종단(길이 · 표본 · 측점) · 횡단 · 요약 · 도면 기준고. */
export function generateSections(
line: ReadonlyArray<Vertex>,
curves: ReadonlyArray<DesignCurve>,
pipes: ReadonlyArray<PlanPipe>,
structures: ReadonlyArray<PlanStructure>,
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<string, unknown> = {
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,
};
}
@@ -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);
@@ -239,31 +239,31 @@ export interface StationsOptions {
}
/** 노선 길이(m) — 길이 0 선분은 0(서버 `_clean_polyline`). */
const lineLength = (line: ReadonlyArray<Vertex>): number =>
export const lineLength = (line: ReadonlyArray<Vertex>): 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<Vertex>,
curves: ReadonlyArray<DesignCurve>,
/** 심는 측점 한 자리 — 주인 종류 · id · 역할 · 카드 이름(서버 측점 `structure` 칸). */
export interface PlantedMark {
kind: "pipe" | "structure";
id: string;
role: string;
title: string;
}
/** 심는 측점(관 · 구조물) — 격자 0.005 m 안이면 격자 자리에 얹고 서로 겹치면 앞선 것만(서버 `extras`). */
export function plantedStations(
grid: ReadonlyArray<number>,
total: number,
pipes: ReadonlyArray<PlanPipe>,
structures: ReadonlyArray<PlanStructure>,
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<StationsOptions, "types" | "registry" | "plus">,
): Map<number, PlantedMark> {
const pipeIds = new Set(pipes.map((pipe) => pipe.id ?? `pipe-${pipe.chainage_m.toFixed(2)}`));
const planted = new Map<number, { kind: "pipe" | "structure"; id: string; role: string }>();
const planted = new Map<number, PlantedMark>();
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<Vertex>,
curves: ReadonlyArray<DesignCurve>,
pipes: ReadonlyArray<PlanPipe>,
structures: ReadonlyArray<PlanStructure>,
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(
+3 -1
View File
@@ -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:
+4
View File
@@ -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(
{
+3 -1
View File
@@ -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` 가 같은 값을 봄.
@@ -48,6 +48,8 @@ NODE_IDS = {
"cross.berm_spans",
"facility.rebase",
"stations.carry",
"stations.list",
"stations.sections",
}
pytestmark = pytest.mark.skipif(
+2 -2
View File
@@ -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 길 ──────────────────────────────────
+2 -2
View File
@@ -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_답_파일_길과_같음_죽어도_다시():
@@ -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