feat(511-B B-5 노선 · 좌표 맞춤 · 남은 측점 계획 Node · route 줄 엶): Router_Replan 688 → 612(폴리라인 파일 쓰기 = B05_Profile_Route_Polyline_Write.py) · 노선 저장 폴리라인(편집 갈래) = 늘 떠 있는 Node route.polyline(buildEditedPolyline + nodeEdits · 파이썬은 csv 4자리 · 곡선 as_dict 반올림만 · 단순화 갈래 · Node 못 돌림 = 파이썬 짝) · 위반 글 반올림 = 파이썬 꼴(fixed1 · R 12.25 → 12.2) · 저장 좌표 맞춤(F1) = Node facility.follow + fill(fillStructureCoordinates · 옛 좌표 지킴 · 0.1 mm · placeOnLine 노선 위 자리와 한 벌 pointAtChainage) · 남은 측점 계획(F3 · 이월 열쇠 주인 · 옛 측점 옮기기) = Node stations.plan · 등록부 R1 R3 F3 node: · 판 route 줄 엶(「서버」 = 잠잠 뒤 서버 route.polyline · 못 내면 화면 셈) · 저장 노선 전수 6 벌 = 파이썬 길 바이트 0 차 · 알려진 차 = 교각 딱 5° 배수 원호 점 ±1(브레인 승인) · 시험 test_511_b5_route_node — 요구 320-4

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-22 14:59:14 +09:00
co-authored by Claude Opus 5.5
parent a7e2da2b9e
commit 99cb8f0ae9
27 changed files with 827 additions and 138 deletions
@@ -28,6 +28,7 @@ import { SOURCE_INPUT } from "./logic/design_pipeline_types";
export { pipelineWork } from "./logic/design_pipeline";
import { createStationsStage, snapOptions, stationDiff } from "./logic/design_pipeline_stations";
import { stationsOnServer } from "./design_stations_server";
import { routeOnServer } from "./design_route_server";
import type {
ChangeSource,
FollowPatch,
@@ -278,6 +279,7 @@ function createPagePipeline(
route: createRouteStage({
minRadiusM: () => plan?.min_radius_m ?? 0,
savedLine: () => plan && { line: savedLine()!, curves: savedCurves() },
remote: routeOnServer, // 계산 자리 route 「서버」(511-B B-5)
}),
follow: (ctx) => {
const kept = replay && ctx.inputs.route ? readAlignmentDraft(DRAFT_ROUTE_ID) : null;
+35
View File
@@ -0,0 +1,35 @@
/* =============================================================================
* A00_Common/design/design_route_server.ts
* 계산 자리 route 「서버」(PLAN 511-B B-5) — 묶음 route 단계 노선(선 · 곡선)을 서버 Node
* `route.polyline`(같은 `buildEditedPolyline` · 노선 저장과 같은 셈)으로. 판이 both 면 null(화면 셈) ·
* 서버 값만 · 잠잠 뒤 · 끊김 · 실패 = undefined(단계가 화면 셈으로).
* ========================================================================== */
import type { DraftArgs } from "../../B05_Profile/logic/B05_Profile_Pipeline_RouteGround";
import type { EditedPolyline } from "../../B05_Profile/logic/B05_Profile_UI_RouteEdit_Curve";
import { calcOnServerNow } from "./design_calc_sites";
import { calcOnServerOnly } from "./design_calc_server";
export function routeOnServer(
args: DraftArgs,
signal: AbortSignal,
): Promise<EditedPolyline | undefined> | null {
if (!calcOnServerNow("route")) return null;
const [points, curveOn, curveRadius, minRadiusM] = args;
const route_polyline = {
points,
curve_flags: curveOn,
radii: curveRadius,
min_radius_m: minRadiusM,
};
return calcOnServerOnly<EditedPolyline>(
"route",
"route.polyline",
{
run: ["route.polyline"],
inputs: { route_polyline },
pick: (results) => results["route.polyline"],
},
signal,
);
}
+6 -2
View File
@@ -17,7 +17,8 @@
* berm?: { structures: 구조물 정본 목록 · chainages: [측점 m] } ·
* rebase? · carry?(노선 바뀐 [저장] · `design_calc_node_carry.ts`) ·
* stages?(묶음 단계 · `design_calc_node_stages.ts` · 511-B) ·
* stations?(측점 · `design_calc_node_sections.ts` · 511-B B-2) } }
* stations?(측점 · `design_calc_node_sections.ts` · 511-B B-2) ·
* route_polyline?(노선 · `design_calc_node_route.ts` · 511-B B-5) } }
* 출력 { results: { [id]: 값 }, available: [id…] }
* — 차례는 요청 차례가 아니라 기댐 차례(누가토량 앞에 구조물 면적을 상세에 얹음 ·
* server-calc 묶음과 같은 차례).
@@ -51,13 +52,15 @@ 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 { routeRunners, type RouteInputs } from "./design_calc_node_route";
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, SectionsInputs {
interface CalcInputs
extends FollowInputs, CarryInputs, TrialInputs, StagesInputs, SectionsInputs, RouteInputs {
detail?: SectionDetailResponse;
route_points?: RoutePoint[];
context?: Partial<
@@ -113,6 +116,7 @@ async function runDesignCalc(run: readonly string[], inputs: CalcInputs) {
...trialRunners(inputs), // 계산 자리 「서버」 시험 길(511-A)
...stageRunners(inputs), // 묶음 단계 노선 ~ 횡단(511-B B-1)
...sectionsRunners(inputs), // 측점 목록 · 서버 측점 생성(511-B B-2)
...routeRunners(inputs), // 노선 폴리라인 · 곡선표(511-B B-5)
// 구조물 측점 면적 · 관 길이를 상세에 얹음(자리에서) — 뒤 누가토량이 고친 면적 위에서 쌓임.
"structure.layouts": () => {
const areas = structureAreaRows(sections);
@@ -7,12 +7,15 @@
* base_pvi(옮기기 전) · base(옮긴 꼭짓점) · born · held } → 꼭짓점 목록(`seatBornNodes`)
* facility.follow 입력 follow { items: [{ placement · chainage_m · start_m · end_m · x · y }] · line ·
* max_drift_m · interval_m(site 맞춤 간격) } → [{ item · drift_m · far }](`followStructures`)
* — follow.fill { previous?: { structure_id: 항목 } · only_missing } 있으면 = 저장 좌표 맞춤(같은 F1 ·
* 511-B B-5) → { xy: [{ x · y } | null(그대로)] · filled }(`fillStructureCoordinates` · items 에 structure_id)
* facility.plus_follow 입력 plus { rows: [{ chainage_m · x · y }] · line: [[x, y]](새 노선) } →
* { rows: [{ chainage_m · x · y }](`followPlusRows` · 정수 m · 같은 m 하나) · moved: 옮긴 수 }
* ========================================================================== */
import { seatBornNodes } from "../../../B05_Profile/logic/B05_Profile_Grade_Seat";
import {
fillStructureCoordinates,
followPlusRows,
followStructures,
type FollowingStructure,
@@ -27,10 +30,11 @@ const xyOf = (line: Line) => line.map(([x, y]) => ({ x, y }));
export interface FollowInputs {
follow?: {
items: FollowingStructure[];
items: Array<FollowingStructure & { structure_id?: string | null }>;
line: Line;
max_drift_m?: number;
interval_m?: number | null;
fill?: { previous?: Record<string, FollowingStructure> | null; only_missing?: boolean };
};
plus?: { rows: Array<{ chainage_m: number; x?: number | null; y?: number | null }>; line: Line };
seat?: {
@@ -61,6 +65,14 @@ export function followRunners(inputs: FollowInputs): Record<string, () => unknow
"facility.follow": () => {
const follow = inputs.follow;
if (!follow) return null;
if (follow.fill)
return fillStructureCoordinates(
follow.items,
xyOf(follow.line),
follow.fill.previous ?? null,
follow.fill.only_missing ?? false,
follow.interval_m ?? null,
);
return followStructures(
follow.items,
xyOf(follow.line),
@@ -0,0 +1,59 @@
/* =============================================================================
* A00_Common/design/logic/design_calc_node_route.ts
* 계산 Node 입구(`design_calc_node.ts`)의 노선 몫(PLAN 511-B B-5) — 화면 노선 편집과 같은 TS.
*
* route.polyline 입력 route_polyline { points · curve_flags · radii · locks · arcs · min_radius_m } →
* { cleaned: 겹친 점(0.5 m) 뺀 노드 · nodes: 노드 요약(cleaned 자리) · vertices: 선 ·
* curves: 곡선(node_first · last = cleaned 자리) · edits: 곡선표 편집표 }
* — 서버 노선 저장 `_write_planned_polyline`(편집 갈래 · 파이썬은 파일 꼴 반올림만)
* ========================================================================== */
import {
buildEditedPolyline,
DUPLICATE_TOLERANCE_M,
type Vertex,
} from "../../../B05_Profile/logic/B05_Profile_UI_RouteEdit_Curve";
import { nodeEdits } from "../../../B05_Profile/logic/B05_Profile_UI_RouteEdit_Edits";
export interface RouteInputs {
route_polyline?: {
points: Vertex[];
curve_flags?: Array<boolean | null> | null;
radii?: Array<number | null> | null;
locks?: Array<string | null> | null;
arcs?: Array<number | null> | null;
min_radius_m: number;
};
}
/** id → 셈(입구 `RUNNERS` 에 얹음). */
export function routeRunners(inputs: RouteInputs): Record<string, () => unknown> {
return {
"route.polyline": () => {
const at = inputs.route_polyline;
if (!at) throw new Error("노선 입력(route_polyline) 없음");
const flags = at.curve_flags ?? null;
const radii = at.radii ?? null;
// 겹친 점을 편집값과 함께 거름 — 곡선 자리 번호 = 저장 노드 자리(파이썬 `build_planned_polyline`)
const cleaned: Vertex[] = [];
const on: boolean[] = [];
const forced: Array<number | null> = [];
at.points.forEach((point, index) => {
const last = cleaned[cleaned.length - 1];
if (last && Math.hypot(point[0] - last[0], point[1] - last[1]) <= DUPLICATE_TOLERANCE_M)
return;
cleaned.push(point);
on.push(flags && index < flags.length ? Boolean(flags[index]) : true);
forced.push(radii && index < radii.length ? radii[index] : null);
});
const line = buildEditedPolyline(cleaned, on, forced, at.min_radius_m);
return {
cleaned,
nodes: line.nodes,
vertices: line.vertices,
curves: line.curves,
edits: nodeEdits(at.points, flags, radii, at.locks ?? null, at.arcs ?? null),
};
},
};
}
@@ -8,11 +8,18 @@
* 노선 저장 옛 노선 열쇠
* stations.sections 같은 입력 + grid(DTM 격자 이진 경로) · 표본 간격 → 서버 `generate_sections` 꼴
* (`generateSections`) — 서버 측점 생성 길만(창구 `/calc` 는 막음 · 경로 입력)
* stations.plan 입력 stations { types · definition? · plan: [{ pipes · structures · plus }] } → 묶음마다
* 측점 계획(`planStations`) — 서버 남은 부름(이월 열쇠 주인 · 옛 측점 옮기기 · 511-B B-5)
* 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 {
planStations,
type PlanPipe,
type PlanStructure,
type 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";
@@ -35,6 +42,7 @@ export interface SectionsInputs {
cross_half_width_m?: number;
cross_sample_interval_m?: number;
long_sample_interval_m?: number;
plan?: Array<{ pipes?: PlanPipe[]; structures?: PlanStructure[]; plus?: number[] }>;
};
}
@@ -62,6 +70,18 @@ export function sectionsRunners(inputs: SectionsInputs): Record<string, () => un
const { at, options } = args();
return buildStations(at.line, at.curves ?? [], at.pipes ?? [], at.structures ?? [], options);
},
"stations.plan": () => {
const { at } = args();
return (at.plan ?? []).map((group) =>
planStations(
group.pipes ?? [],
group.structures ?? [],
at.types,
undefined,
group.plus ?? [],
),
);
},
"stations.sections": () => {
const { at, options } = args();
if (!at.grid) throw new Error("DTM 격자 없음 — DTM 아닌 지표면은 파이썬 길");
@@ -64,11 +64,10 @@ export const CALC_REGISTRY: readonly CalcEntry[] = [
site: "browser_now",
stage: "route",
ts: `${B05L}B05_Profile_UI_RouteEdit_Curve.ts#buildEditedPolyline`,
runner: `py:${U}common_util_route_polyline.py#build_planned_polyline`,
until: "472-4",
runner: "node:route.polyline",
pair: `${U}common_util_route_polyline.py#build_planned_polyline`,
mirror: ["test_route_polyline_browser_mirror"],
note: "파이썬 짝은 추천 엔진 · B04 배수 · 노선 확인이 부름 — 짝 남음",
mirror: ["test_route_polyline_browser_mirror", "test_511_b5_route_node"],
note: "서버 노선 저장(편집 갈래 · `_write_planned_polyline`) = Node · 파이썬 짝은 첫 폴리라인(단순화) · 추천 엔진 · B04 배수 · 노선 열기가 부름 — 짝 남음 · 알려진 차 = 교각 딱 5° 배수면 원호 점 ±1(libm 끝자리 · 실데이터 0 차)",
},
{
id: "route.handle",
@@ -92,10 +91,10 @@ export const CALC_REGISTRY: readonly CalcEntry[] = [
site: "browser_now",
stage: "route",
ts: `${B05L}B05_Profile_UI_RouteEdit_Edits.ts#applyCurveLimits`,
runner: `py:${B05}B05_Profile_Route_NodeEdits.py#node_edits`,
until: "472-4",
runner: "node:route.polyline",
pair: `${B05}B05_Profile_Route_NodeEdits.py#node_edits`,
mirror: ["test_430_route_edit_rules"],
mirror: ["test_430_route_edit_rules", "test_511_b5_route_node"],
note: "곡선표 편집표 = Node `route.polyline` 의 `nodeEdits` · 파이썬 짝 = Node 못 돌릴 때",
},
{
id: "route.station_fit",
@@ -30,8 +30,9 @@ export const FACILITY_ROWS: readonly CalcEntry[] = [
"test_459_route_seat_mirror",
"test_380_3_site_follow",
"test_472_4_follow_node",
"test_511_b5_route_node",
],
note: "파이썬 `common_util_route_follow.follow_structures` = Node 부름 겉 · 저장 때 좌표 맞춤 `fill_structure_coordinates` 는 파이썬 남음(옛 좌표 지킴 · 0.1 mm 반올림 — TS `placeOnLine` 과 규칙 다름)",
note: "파이썬 `common_util_route_follow.follow_structures` = Node 부름 겉 · 저장 때 좌표 맞춤 `fill_structure_coordinates` = 같은 Node 입구 + fill(`fillStructureCoordinates` · 옛 좌표 지킴 · 0.1 mm · `placeOnLine` 노선 위 자리와 한 벌 · 511-B B-5)",
},
{
id: "facility.plus_follow",
@@ -70,11 +71,10 @@ export const FACILITY_ROWS: readonly CalcEntry[] = [
site: "browser_now",
stage: "stations",
ts: `${U}common_util_structure_station.ts#planStations`,
runner: `py:${U}common_util_structure_station.py#plan_stations`,
until: "472-4",
runner: "node:stations.plan",
pair: `${U}common_util_structure_station.py#plan_stations`,
mirror: ["test_322_3_plan_mirror", "test_234_1_structure_station"],
note: "파이썬 짝은 B07 · 배수가 넓게 부름",
mirror: ["test_322_3_plan_mirror", "test_234_1_structure_station", "test_511_b5_route_node"],
note: "서버 측점 생성 = Node `stations.sections` · 남은 부름(이월 열쇠 주인 · 옛 측점 옮기기) = Node `stations.plan` · 파이썬 짝은 B06 형상 얹기 · B07 · 배수 · Node 못 돌릴 때",
},
{
id: "facility.attach",
@@ -37,6 +37,13 @@ def plan_pipe(pipe: Any) -> dict[str, Any]:
}
def plan_structure(item: Any) -> dict[str, Any]:
"""구조물 정본 → TS `PlanStructure`(JSON 꼴)."""
if hasattr(item, "model_dump"):
return item.model_dump(mode="json")
return dict(item) if isinstance(item, dict) else dict(vars(item))
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
@@ -62,10 +69,7 @@ def stations_input(
"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
],
"structures": [plan_structure(item) for item in structures],
"types": types,
"definition": definition,
"plus": [float(at) for at in plus],
@@ -90,6 +94,38 @@ def node_stations(stations: dict[str, Any]) -> list[dict[str, Any]] | None:
return answer["results"]["stations.list"]
def node_plan_stations(
groups: list[tuple[list[Any], list[Any], list[float]]],
) -> list[list[dict[str, Any]]] | None:
"""(관 · 구조물 · 추가 측점) 묶음마다 측점 계획(`planStations` · 파이썬 `plan_stations` 짝) —
한 Node 부름 · 못 돌림 None(부르는 쪽이 파이썬 짝)."""
from common_util.common_util_calc_runner import run_calc
if not groups:
return []
types, definition = structure_table()
plan = [
{
"pipes": [plan_pipe(pipe) for pipe in pipes],
"structures": [plan_structure(item) for item in structures],
"plus": [float(at) for at in plus],
}
for pipes, structures, plus in groups
]
try:
results = run_calc(
["stations.plan"],
{"stations": {"types": types, "definition": definition, "plan": plan}},
)
except Exception: # noqa: BLE001 — 파이썬 짝으로 이어 감
logger.exception("Node 측점 계획 실패 — 파이썬 길")
return None
if results is None:
logger.warning("Node 측점 계획 못 돌림 — 파이썬 길")
return None
return results["stations.plan"]
def node_sections(
polyline: list[list[float]],
sampler: Any,
@@ -0,0 +1,184 @@
"""계획노선 폴리라인 파일 쓰기(`B05_Profile_Router_Replan` 에서 나눔 · PLAN 511-B B-5).
노드 · 곡선 켬끔 · R(편집 갈래) → 선 · 노드 · 곡선표(+ 편집표) = 늘 떠 있는 Node `route.polyline`
(화면 `buildEditedPolyline` · `nodeEdits` 와 같은 TS) · 파이썬은 파일 꼴(csv 4자리 · 곡선 as_dict
반올림)만. 단순화 갈래(예상노선 첫 폴리라인) · Node 못 돌림 = 파이썬 `build_planned_polyline` ·
`node_edits`(짝 · 로그 남김).
"""
from __future__ import annotations
import json
import logging
from pathlib import Path
from typing import Any
from B05_Profile.B05_Profile_Route_NodeEdits import node_edits
from common_util.common_util_route_geometry import write_route_csv
from common_util.common_util_route_polyline import (
PlannedPolyline,
RouteCurve,
RouteNode,
build_planned_polyline,
)
logger = logging.getLogger(__name__)
def _nodes_path(path: Path) -> Path:
"""그 폴리라인을 낳은 **노드** 파일 자리 — `planned_route.csv` → `planned_route_nodes.csv`."""
return path.with_name(f"{path.stem}_nodes.csv")
def _curves_path(path: Path) -> Path:
"""그 폴리라인의 **곡선 성분** 자리 — `planned_route.csv` → `planned_route_curves.json`.
왜 따로 남기나(2026-09-07 사용자 확정) — 계획노선은 「직선 > 곡선 > 직선」이고 사용자가
잡는 것은 **곡선 시작·끝점**이며 반지름도 직접 바꾼다. 정점 목록만으로는 어디부터
어디까지가 한 곡선인지, 그 반지름이 얼마인지 알 수 없어 편집·도면이 같은 값을 못 본다.
"""
return path.with_name(f"{path.stem}_curves.json")
def _read_curves(path: Path, key: str = "curves") -> list[dict]:
"""저장해 둔 곡선 성분(`key` = "edits" 면 넣은 편집값 · 430). 없거나 못 읽으면 빈 목록."""
target = _curves_path(path)
if not target.is_file():
return []
try:
data = json.loads(target.read_text(encoding="utf-8"))
except (OSError, json.JSONDecodeError):
logger.warning("계획노선 곡선 성분을 읽지 못했습니다: %s", target)
return []
curves = data.get(key) if isinstance(data, dict) else None
return curves if isinstance(curves, list) else []
def node_polyline(
points: list[tuple[float, float]],
radius_m: float,
curve_flags: list[bool] | None,
radii: list[float | None] | None,
locks: list[str | None] | None,
arcs: list[float | None] | None,
) -> tuple[PlannedPolyline, list[dict[str, Any]]] | None:
"""편집 갈래 폴리라인 + 편집표 = Node `route.polyline` — 못 돌림 None."""
from common_util.common_util_calc_runner import run_calc
route = {
"points": [[float(x), float(y)] for x, y in points],
"curve_flags": curve_flags,
"radii": radii,
"locks": locks,
"arcs": arcs,
"min_radius_m": float(radius_m),
}
try:
out = (run_calc(["route.polyline"], {"route_polyline": route}) or {}).get("route.polyline")
except Exception: # noqa: BLE001 — 노선 저장은 파이썬 짝으로 이어 감
logger.exception("Node 노선 폴리라인 실패 — 파이썬 길")
return None
if not out:
logger.warning("Node 노선 폴리라인 못 돌림 — 파이썬 길")
return None
# JSON 정수(100) → float — 파이썬 길과 같은 글(`100.0`)로 적힘
nodes = [
RouteNode(
x=float(x),
y=float(y),
inner_angle_deg=_num(info["inner_angle_deg"]),
radius_m=_num(info["radius_m"]),
tangent_m=_num(info["tangent_m"]),
violations=list(info["violations"]),
)
for (x, y), info in zip(out["cleaned"], out["nodes"])
]
curves = [
RouteCurve(
apex=_xy(curve["apex"]),
radius_m=float(curve["radius_m"]),
tangent_m=float(curve["tangent_m"]),
inner_angle_deg=float(curve["inner_angle_deg"]),
start=_xy(curve["start"]),
end=_xy(curve["end"]),
node_first=int(curve["node_first"]),
node_last=int(curve["node_last"]),
violations=list(curve["violations"]),
)
for curve in out["curves"]
]
vertices = [_xy(vertex) for vertex in out["vertices"]]
edits = [
{**edit, "radius_m": _num(edit["radius_m"]), "arc_m": _num(edit["arc_m"])}
for edit in out["edits"]
]
return PlannedPolyline(nodes=nodes, vertices=vertices, curves=curves), edits
def _num(value: Any) -> float | None:
return None if value is None else float(value)
def _xy(pair: list[float]) -> tuple[float, float]:
return float(pair[0]), float(pair[1])
def _write_planned_polyline(
path: Path,
points: list[tuple[float, float]],
radius_m: float,
*,
simplify: bool = True,
curve_flags: list[bool] | None = None,
radii: list[float | None] | None = None,
station_snap: bool | None = None,
station_snap_step: float | None = None,
locks: list[str | None] | None = None,
arcs: list[float | None] | None = None,
) -> dict:
"""점 묶음을 폴리라인으로 바꿔 CSV 로 쓴다. 노드 요약을 돌려준다.
`station_snap` — [확인] 때만 곡선표에 적음(요구 224-2). None 이면 칸을 안 둠(끔) ·
`station_snap_step` — 켬 + 간격이 있을 때만 곡선표에 적음(390-5 · 없으면 측점 No.).
`locks` · `arcs` — 편집값이 오면 넣은 값을 곡선표 `edits` 에(430 · `_Route_NodeEdits`).
**노드도 함께 남긴다** — 노드는 폴리라인에서 되뽑을 수 없다. 폴리라인에는 원호 위 점이
섞여 있어 다시 단순화하면 꺾임점이 조금씩 지워지고, 그것을 반복하면 [확인]을 누를
때마다 선이 깎인다(2026-09-07 실측: 정점 136 → 134 → 119). 낳은 값을 그대로 보관한다.
"""
edited = curve_flags is not None or radii is not None
made = (
node_polyline(points, radius_m, curve_flags, radii, locks, arcs)
if edited and not simplify
else None
)
if made is not None:
result, edits = made
else:
result = build_planned_polyline(
points,
min_radius_m=radius_m,
simplify=simplify,
curve_flags=curve_flags,
radii=radii,
)
edits = node_edits(points, curve_flags, radii, locks, arcs) if edited else None
write_route_csv(path, [{"x": x, "y": y} for x, y in result.vertices])
write_route_csv(_nodes_path(path), [{"x": node.x, "y": node.y} for node in result.nodes])
table: dict[str, Any] = {
"min_radius_m": round(radius_m, 3),
"curves": [c.as_dict() for c in result.curves],
}
if station_snap is not None:
table["station_snap"] = bool(station_snap)
if station_snap and station_snap_step is not None:
table["station_snap_step"] = float(station_snap_step)
if edits is not None:
table["edits"] = edits
_curves_path(path).write_text(json.dumps(table, ensure_ascii=False), encoding="utf-8")
return {
"nodes": len(result.nodes),
"curves": result.curve_count,
"violations": result.violation_count,
"vertices": len(result.vertices),
}
+8 -2
View File
@@ -120,15 +120,21 @@ def _facility_owners(
) -> dict[float, tuple[str, str | None, str]]:
"""측점 자리(0.01 m) → (관 · 구조물, id, 역할 anchor · start · end). 같은 자리면 관이 먼저
(`plan_stations` 이름 규칙)."""
from B05_Profile.B05_Profile_Engine_Sections_Node import node_plan_stations
# 측점 계획 = Node(`stations.plan` · 511-B B-5) · 못 돌림 = 파이썬 `plan_stations`
groups = [([pipe], [], []) for pipe in pipes] + [([], [item], []) for item in structures]
planned = node_plan_stations(groups) or [plan_stations(*group) for group in groups]
marks_of = iter(planned)
owners: dict[float, tuple[str, str | None, str]] = {}
for pipe in pipes:
for mark in plan_stations([pipe], []):
for mark in next(marks_of):
owners.setdefault(
round(float(mark["chainage_m"]), 2),
("pipe", getattr(pipe, "id", None), str(mark.get("role") or "anchor")),
)
for item in structures:
for mark in plan_stations([], [item]):
for mark in next(marks_of):
owners.setdefault(
round(float(mark["chainage_m"]), 2),
(
+6 -82
View File
@@ -24,7 +24,6 @@
import asyncio
import functools
import json
import logging
import time
from pathlib import Path
@@ -38,7 +37,12 @@ from B03_FileInput.B03_FileInput_Repository import get_project_storage_relative_
from B05_Profile.B05_Profile_Engine_RouteInitial import read_initial_choice, write_initial_choice
from B05_Profile.B05_Profile_Engine_RouteInitial_Station import snapped_initial
from B05_Profile.B05_Profile_Engine_Sections import load_station_snap, load_station_snap_step
from B05_Profile.B05_Profile_Route_NodeEdits import node_edits
from B05_Profile.B05_Profile_Route_Polyline_Write import ( # noqa: F401 — 부르는 곳이 `replan.` 으로 씀
_curves_path,
_nodes_path,
_read_curves,
_write_planned_polyline,
)
from B05_Profile.B05_Profile_Route_Save import RouteSaveRequest
from B05_Profile.B05_Profile_Schema import ( # noqa: F401 — 부르는 곳이 `replan.` 으로 씀
RouteInitialCriteria,
@@ -191,86 +195,6 @@ async def _road_settings(project_id: UUID) -> tuple[str, int | None, str]:
return grade_class, design_speed, terrain
def _nodes_path(path: Path) -> Path:
"""그 폴리라인을 낳은 **노드** 파일 자리 — `planned_route.csv` → `planned_route_nodes.csv`."""
return path.with_name(f"{path.stem}_nodes.csv")
def _curves_path(path: Path) -> Path:
"""그 폴리라인의 **곡선 성분** 자리 — `planned_route.csv` → `planned_route_curves.json`.
왜 따로 남기나(2026-09-07 사용자 확정) — 계획노선은 「직선 > 곡선 > 직선」이고 사용자가
잡는 것은 **곡선 시작·끝점**이며 반지름도 직접 바꾼다. 정점 목록만으로는 어디부터
어디까지가 한 곡선인지, 그 반지름이 얼마인지 알 수 없어 편집·도면이 같은 값을 못 본다.
"""
return path.with_name(f"{path.stem}_curves.json")
def _read_curves(path: Path, key: str = "curves") -> list[dict]:
"""저장해 둔 곡선 성분(`key` = "edits" 면 넣은 편집값 · 430). 없거나 못 읽으면 빈 목록."""
target = _curves_path(path)
if not target.is_file():
return []
try:
data = json.loads(target.read_text(encoding="utf-8"))
except (OSError, json.JSONDecodeError):
logger.warning("계획노선 곡선 성분을 읽지 못했습니다: %s", target)
return []
curves = data.get(key) if isinstance(data, dict) else None
return curves if isinstance(curves, list) else []
def _write_planned_polyline(
path: Path,
points: list[tuple[float, float]],
radius_m: float,
*,
simplify: bool = True,
curve_flags: list[bool] | None = None,
radii: list[float | None] | None = None,
station_snap: bool | None = None,
station_snap_step: float | None = None,
locks: list[str | None] | None = None,
arcs: list[float | None] | None = None,
) -> dict:
"""점 묶음을 폴리라인으로 바꿔 CSV 로 쓴다. 노드 요약을 돌려준다.
`station_snap` — [확인] 때만 곡선표에 적음(요구 224-2). None 이면 칸을 안 둠(끔) ·
`station_snap_step` — 켬 + 간격이 있을 때만 곡선표에 적음(390-5 · 없으면 측점 No.).
`locks` · `arcs` — 편집값이 오면 넣은 값을 곡선표 `edits` 에(430 · `_Route_NodeEdits`).
**노드도 함께 남긴다** — 노드는 폴리라인에서 되뽑을 수 없다. 폴리라인에는 원호 위 점이
섞여 있어 다시 단순화하면 꺾임점이 조금씩 지워지고, 그것을 반복하면 [확인]을 누를
때마다 선이 깎인다(2026-09-07 실측: 정점 136 → 134 → 119). 낳은 값을 그대로 보관한다.
"""
result = build_planned_polyline(
points,
min_radius_m=radius_m,
simplify=simplify,
curve_flags=curve_flags,
radii=radii,
)
write_route_csv(path, [{"x": x, "y": y} for x, y in result.vertices])
write_route_csv(_nodes_path(path), [{"x": node.x, "y": node.y} for node in result.nodes])
table: dict[str, Any] = {
"min_radius_m": round(radius_m, 3),
"curves": [c.as_dict() for c in result.curves],
}
if station_snap is not None:
table["station_snap"] = bool(station_snap)
if station_snap and station_snap_step is not None:
table["station_snap_step"] = float(station_snap_step)
if curve_flags is not None or radii is not None:
table["edits"] = node_edits(points, curve_flags, radii, locks, arcs)
_curves_path(path).write_text(json.dumps(table, ensure_ascii=False), encoding="utf-8")
return {
"nodes": len(result.nodes),
"curves": result.curve_count,
"violations": result.violation_count,
"vertices": len(result.vertices),
}
def _ensure_planned_initial(project_root: Path, radius_m: float) -> dict | None:
"""계획노선 **초기 폴리라인**이 없거나 낡았으면 예상노선을 폴리라인화해 세운다.
+5 -8
View File
@@ -327,16 +327,13 @@ async def _migrate_structures(project_id: UUID, payload: StructureMigrateRequest
# 정본이 이미 심는 자리(측점 계획 · 323-7) — 이름이 아니라 자리로 가름(옛 이름 되짚기 걷음)
# · 추가 측점(368-2 · 이름 없는 일반측점)도 심는 자리 — 「기타」 구조물로 옮기지 않음
from B05_Profile.B05_Profile_Engine_Sections import route_pipe_points
from B05_Profile.B05_Profile_Engine_Sections_Node import node_plan_stations
from B05_Profile.B05_Profile_Plus_Stations import plus_chainages
planted = [
float(station["chainage_m"])
for station in plan_stations(
route_pipe_points(Path(root)), # 관 = 지금 노선에 얹은 자리(472 덧)
existing,
plus_chainages(root),
)
]
# 관 = 지금 노선에 얹은 자리(472 덧) · 측점 계획 = Node(511-B B-5) · 못 돌림 = 파이썬 짝
group = (route_pipe_points(Path(root)), existing, plus_chainages(root))
marks = (node_plan_stations([group]) or [plan_stations(*group)])[0]
planted = [float(station["chainage_m"]) for station in marks]
legacy, standing = await _saved_irregular_stations(project_id, root, payload.route_id)
# 표식 = "타입@위치". 원천(종단 정본의 비정규 측점)은 원복용으로 그대로 두고,
@@ -42,8 +42,15 @@ export interface RouteStageDeps {
minRadiusM(): number;
/** 초안이 없을 때(= 저장분 그대로) 그릴 노선 · 곡선표 — 없으면 null. */
savedLine(): { line: Vertex[]; curves: DesignCurve[] } | null;
/** 계산 자리 route 「서버」(511-B B-5) — 서버 Node `route.polyline`(같은 `buildEditedPolyline`) 값 ·
* null = 화면 셈 · 끊김 · 실패 = undefined(화면 셈으로). */
remote?(args: DraftArgs, signal: AbortSignal): Promise<BuiltLine | undefined> | null;
}
/** `buildEditedPolyline` 인자(곡선 길이 고정을 R 로 푼 뒤) — 화면 셈 · 서버 셈이 같은 값을 받음. */
export type DraftArgs = Parameters<typeof buildEditedPolyline>;
type BuiltLine = Pick<ReturnType<typeof buildEditedPolyline>, "vertices" | "curves">;
/** 정점마다 누가거리 — 첫 점 0. */
export function routeLineOf(line: Vertex[], curves: DesignCurve[] = []): RouteLine {
const chainage = [0];
@@ -67,10 +74,19 @@ export function draftLine(
draft: RouteDraft,
minRadiusM: number,
): { line: Vertex[]; curves: DesignCurve[] } {
return shapedLine(buildEditedPolyline(...draftArgs(draft, minRadiusM)));
}
/** 초안 → `buildEditedPolyline` 인자(길이를 붙든 곡선은 교각에 맞춰 R 을 다시 잡음). */
export function draftArgs(draft: RouteDraft, minRadiusM: number): DraftArgs {
const nodes = draft.nodes.map((node): Vertex => [node[0], node[1]]);
const radius = [...draft.curveRadius];
applyArcLocks(nodes, [...draft.curveLock], [...draft.curveArc], radius);
const built = buildEditedPolyline(nodes, draft.curveOn, radius, minRadiusM);
return [nodes, draft.curveOn, radius, minRadiusM];
}
/** 그린 선 → 계약 꼴 · 서버 저장 자릿수(화면 셈 · 서버 셈 한 벌). */
function shapedLine(built: BuiltLine): { line: Vertex[]; curves: DesignCurve[] } {
return {
line: built.vertices.map(xy4),
curves: built.curves.map(({ start, apex, end, radius_m }) => ({
@@ -85,9 +101,15 @@ export function draftLine(
export function createRouteStage(deps: RouteStageDeps): StageFn<"route"> {
return (ctx) => {
const draft = ctx.inputs.route;
const drawn = draft ? draftLine(draft, deps.minRadiusM()) : deps.savedLine();
if (!drawn || drawn.line.length < 2) throw new Error("그릴 노선이 없습니다.");
return routeLineOf(drawn.line, drawn.curves);
const done = (drawn: { line: Vertex[]; curves: DesignCurve[] } | null) => {
if (!drawn || drawn.line.length < 2) throw new Error("그릴 노선이 없습니다.");
return routeLineOf(drawn.line, drawn.curves);
};
if (!draft) return done(deps.savedLine());
const args = draftArgs(draft, deps.minRadiusM());
const asked = deps.remote?.(args, ctx.signal);
const local = () => shapedLine(buildEditedPolyline(...args));
return asked ? asked.then((built) => done(built ? shapedLine(built) : local())) : done(local());
};
}
@@ -52,6 +52,13 @@ const RAD_PER_DEG = Math.PI / 180;
/** 짝: `build_planned_polyline` 의 `hairpin_min_radius_m` 기본값(배향곡선 하한). */
export const HAIRPIN_MIN_RADIUS_M = 10.0;
/** 짝: 파이썬 `f"{x:.1f}"` — 딱 반(x = 홀수/4 · 12.25)이면 짝수 쪽(`toFixed` 는 큰 쪽 · 위반 글이 갈림). */
export function fixed1(x: number): string {
if (!Number.isInteger(x * 4) || Number.isInteger(x * 2)) return x.toFixed(1);
const floor = Math.floor(x * 10);
return ((floor % 2 === 0 ? floor : floor + 1) / 10).toFixed(1);
}
/** 두 **직선**(선분 아님)이 만나는 자리. 나란하면 null. */
export function intersect(a1: Vertex, a2: Vertex, b1: Vertex, b2: Vertex): Vertex | null {
const dx1 = a2[0] - a1[0];
@@ -308,12 +315,10 @@ export function buildEditedPolyline(
if (radius <= 0 || geometry === null) continue;
if (radius < minRadiusM) {
node.violations.push(`최소곡선반지름 미달(${radius.toFixed(1)} < ${minRadiusM.toFixed(1)}m)`);
node.violations.push(`최소곡선반지름 미달(${fixed1(radius)} < ${fixed1(minRadiusM)}m)`);
}
if (radius < hairpinMinRadiusM) {
node.violations.push(
`배향곡선 하한 미달(${radius.toFixed(1)} < ${hairpinMinRadiusM.toFixed(1)}m)`,
);
node.violations.push(`배향곡선 하한 미달(${fixed1(radius)} < ${fixed1(hairpinMinRadiusM)}m)`);
}
node.radius_m = radius;
node.tangent_m = tangent;
@@ -330,6 +330,34 @@ export interface RouteNodeEdit {
arc_m: number | null;
}
/** 넣은 편집값 → 곡선표 `edits`(430) — 겹친 점(앞 노드 0.5 m 안)은 빼고 저장 노드와 같은 자리 · 칸이 짧으면
* 켬 · 자동. ⚠ 짝 `B05_Profile_Route_NodeEdits.node_edits` · 서버 노선 저장 = Node `route.polyline`(511-B B-5). */
export function nodeEdits(
points: ReadonlyArray<Vertex>,
flags: ReadonlyArray<boolean | null> | null = null,
radii: ReadonlyArray<number | null> | null = null,
locks: ReadonlyArray<string | null> | null = null,
arcs: ReadonlyArray<number | null> | null = null,
): RouteNodeEdit[] {
const at = <T>(values: ReadonlyArray<T> | null, index: number): T | null =>
values && index < values.length ? values[index] : null;
const kept: Vertex[] = [];
const edits: RouteNodeEdit[] = [];
points.forEach((point, index) => {
const last = kept[kept.length - 1];
if (last && Math.hypot(point[0] - last[0], point[1] - last[1]) <= DUPLICATE_TOLERANCE_M) return;
kept.push(point);
const lock = at(locks, index);
edits.push({
curve: flags && index < flags.length ? Boolean(flags[index]) : true,
radius_m: at(radii, index),
lock: lock === "radius" || lock === "arc" ? lock : null,
arc_m: at(arcs, index),
});
});
return edits;
}
/**
* 서버가 준 노드·곡선을 **꺾임점 하나 = 곡선 하나**로 편다(2026-09-07).
*
+1 -1
View File
@@ -87,7 +87,7 @@ async def run_calc_endpoint(project_id: UUID, request: Request) -> dict | JSONRe
problem = await _server_grid(project_id, inputs)
if problem:
return _error(400, problem)
if "stations.list" in run and isinstance(inputs.get("stations"), dict):
if {"stations.list", "stations.plan"} & set(run) and isinstance(inputs.get("stations"), dict):
# 심는 측점 · 카드 이름 표 = 서버 구조물 정의(서버 측점 생성과 같은 표 · 511-B B-2)
from B05_Profile.B05_Profile_Engine_Sections_Node import structure_table
+2
View File
@@ -46,6 +46,8 @@ NODE_IDS = frozenset(
"stations.carry",
"stations.list",
"stations.sections",
"route.polyline",
"stations.plan",
}
)
# 창구(`/calc`)가 안 받는 id — 서버 파일 경로를 입력으로 받음(측점 생성 · 511-B B-2)
+2 -1
View File
@@ -31,7 +31,8 @@ GROUPS: tuple[dict[str, Any], ...] = (
},
{"key": "carry", "nos": ["R9", "F10"], "switchable": True, "fixed": None, "why": "carry"},
# 묶음 단계(511-B · 단계째 서버 Node `stage.*`) — 짝 모으기 B-3 ~ B-5 끝난 줄부터 엶
{"key": "route", "nos": ["R1", "R3"], "switchable": False, "fixed": "both", "why": "pair_B5"},
# 노선(511-B B-5 · 노선 저장 폴리라인 = Node `route.polyline` · 조작 중 「서버」 = 같은 id)
{"key": "route", "nos": ["R1", "R3"], "switchable": True, "fixed": None, "why": None},
{
"key": "follow",
"nos": ["P5", "P6", "F1", "F2"],
+46 -1
View File
@@ -19,6 +19,7 @@
from __future__ import annotations
import logging
import math
from typing import Any, Iterable, Sequence
@@ -33,6 +34,7 @@ from common_util.common_util_structure_station import (
)
from config.config_system import SECTION_STATION_INTERVAL_M, STRUCTURE_ROUTE_DRIFT_MAX_M
logger = logging.getLogger(__name__)
_POSITION_KEYS = ("chainage_m", "start_m", "end_m")
_FOLLOW_KEYS = ("placement", *_POSITION_KEYS, "x", "y")
#: 노선 밖 판정(m) — 투영이 시점 앞 · 종점 뒤로 이만큼 넘으면 측점 안 세움(459 · TS 짝).
@@ -96,8 +98,51 @@ def fill_structure_coordinates(
`previous` = 저장 전 정본 {structure_id: 항목} — 자리(누가거리 셋)가 그대로면 옛 좌표를 지킨다
(노선이 바뀌어 멀어진 땅 자리를 다음 저장이 노선 위로 덮지 않게). 보낸 좌표가 그 누가거리를
낳으면(지금 노선 따라감 = 제자리) 그 좌표를 살린다(410 · 옮긴 자리 · 따라간 땅 자리) — 낡은
좌표(누가거리만 옮김)는 노선 위로. TS 짝 = `placeOnLine`. `only_missing` = 읽을 때.
좌표(누가거리만 옮김)는 노선 위로. `only_missing` = 읽을 때.
셈 = TS `fillStructureCoordinates`(Node `facility.follow` + fill · 511-B B-5) · Node 못 돌림 =
파이썬 짝 `_fill_structure_coordinates_py`.
"""
items = list(items)
line = _line(xy)
if line is None or not items:
return 0
keys = ("structure_id", *_FOLLOW_KEYS)
rows = [{key: getattr(item, key, None) for key in keys} for item in items]
old = {
sid: {key: getattr(item, key, None) for key in keys}
for sid, item in (previous or {}).items()
}
results = run_calc(
["facility.follow"],
{
"follow": {
"items": rows,
"line": [[x, y] for x, y in line.coords],
"interval_m": SECTION_STATION_INTERVAL_M,
"fill": {"previous": old, "only_missing": only_missing},
}
},
)
if results is None:
logger.warning("구조물 좌표 맞춤 Node 못 돌림 — 파이썬 짝")
return _fill_structure_coordinates_py(
items, xy, previous=previous, only_missing=only_missing
)
out = results["facility.follow"]
for item, at in zip(items, out["xy"], strict=True):
if at is not None:
item.x, item.y = float(at["x"]), float(at["y"])
return int(out["filled"])
def _fill_structure_coordinates_py(
items: Iterable[Any],
xy: Sequence[Sequence[float]],
*,
previous: dict[str, Any] | None = None,
only_missing: bool = False,
) -> int:
"""`fill_structure_coordinates` 파이썬 짝(Node 못 돌릴 때 · 거울 시험 기준)."""
line = _line(xy)
if line is None:
return 0
+66 -12
View File
@@ -11,6 +11,8 @@
* - `projectOnPolyline` = shapely `LineString.project`(가장 가까운 첫 선분) · `distance`.
* - `placeOnLine` 좌표 낡음 판정 ↔ 파이썬 `fill_structure_coordinates`(좌표가 그 누가거리를 낳으면 살림 ·
* 아니면 누가거리의 노선 위 자리 · 410) · 거울 시험 = resources/tester/test_410_follow_xy.py.
* - `fillStructureCoordinates` = 저장 때 좌표 맞춤 그 자체(옛 좌표 지킴 · 0.1 mm · 서버 Node `facility.fill_xy` ·
* 파이썬 짝 = Node 못 돌릴 때) · 노선 위 자리 `pointAtChainage` 는 `placeOnLine` 과 한 벌(511-B B-5).
* - `placeOnLine` 의 좌표 없는 관 · `rebasePipeDraft` 는 TS 만 — 좌표 없는 세션 관 초안(정본 PUT 꼴)을 따라가기
* 전 자리로 · 노선을 저장한 [저장]이 서버가 따라 옮긴 관 위에 초안을 얹음(349-0u).
* - `rebaseStructureDraft` 도 TS 만 — 구조물 초안을 서버가 따라 옮긴 정본 위에(459 · 관 짝).
@@ -129,21 +131,73 @@ export function placeOnLine<S extends FollowingStructure & { id?: string | null
Math.abs(was.chainage_m - item.chainage_m) <= SAME_STATION_M
)
return { ...item, x: was.x, y: was.y };
let walked = 0;
for (let index = 1; index < line.length; index += 1) {
const a = line[index - 1];
const b = line[index];
const span = Math.hypot(b.x - a.x, b.y - a.y);
if (span > 0 && (walked + span >= item.chainage_m || index === line.length - 1)) {
const t = Math.min(1, Math.max(0, (item.chainage_m - walked) / span));
return { ...item, x: a.x + (b.x - a.x) * t, y: a.y + (b.y - a.y) * t };
}
walked += span;
}
return item;
const at = pointAtChainage(line, item.chainage_m);
return at ? { ...item, ...at } : item;
});
}
/** 누가거리의 노선 위 자리 — shapely `LineString.interpolate` 꼴(선분 길이 √ · 넘으면 끝점 · 음수 = 끝에서) +
* 0.1 mm(파이썬 `round(·, 4)` · F1 저장 좌표 맞춤과 같은 값 · 511-B B-5). 노선 없음(점 둘 미만 · 길이 0) = null. */
export function pointAtChainage(line: ReadonlyArray<XY>, chainageM: number): XY | null {
const spans = line.slice(1).map((b, index) => {
const a = line[index];
return Math.sqrt((b.x - a.x) ** 2 + (b.y - a.y) ** 2);
});
const length = spans.reduce((sum, span) => sum + span, 0);
if (!(length > 0)) return null;
const target = chainageM < 0 ? Math.max(0, length + chainageM) : chainageM;
// ponytail: toFixed = 딱 반(홀수/32 m)에서 큰 쪽 · 파이썬 round 는 짝수 쪽 — 실측 좌표엔 안 생김
const round4 = (value: number) => Number(value.toFixed(4));
let walked = 0;
for (let index = 0; index < spans.length; index += 1) {
if (walked + spans[index] > target) {
const a = line[index];
const b = line[index + 1];
const t = Math.max(0, (target - walked) / spans[index]);
return { x: round4((b.x - a.x) * t + a.x), y: round4((b.y - a.y) * t + a.y) };
}
walked += spans[index];
}
const end = line[line.length - 1];
return { x: round4(end.x), y: round4(end.y) };
}
type Positioned = FollowingStructure & { structure_id?: string | null };
/** 저장 때 구조물 좌표 맞춤(F1 · 511-B B-5 · 파이썬 `fill_structure_coordinates` 같은 규칙 · 서버 Node
* `facility.fill_xy`) — 누가거리 없음 · `onlyMissing` 에 좌표 있음 = 그대로 · `previous`(저장 전 정본 ·
* structure_id)와 자리 셋(기준 · 시작 · 끝)이 같고 옛 좌표 있음 = 옛 좌표 · 좌표가 그 누가거리를 낳음 = 그대로 ·
* 그 밖 = 노선 위 자리(`pointAtChainage`). 돌려줌 = 항목마다 새 좌표(안 바뀜 null) · 채운 수. */
export function fillStructureCoordinates(
items: ReadonlyArray<Positioned>,
line: ReadonlyArray<XY>,
previous: Readonly<Record<string, Positioned>> | null = null,
onlyMissing = false,
intervalM: number | null = null,
): { xy: Array<XY | null>; filled: number } {
const total = line
.slice(1)
.reduce((sum, b, index) => sum + Math.hypot(b.x - line[index].x, b.y - line[index].y), 0);
if (line.length < 2 || !(total > 0)) return { xy: items.map(() => null), filled: 0 };
const same = (a: Positioned, b: Positioned) =>
(a.chainage_m ?? null) === (b.chainage_m ?? null) &&
(a.start_m ?? null) === (b.start_m ?? null) &&
(a.end_m ?? null) === (b.end_m ?? null);
let filled = 0;
const xy = items.map((item): XY | null => {
if (item.chainage_m == null) return null;
const placed = item.x != null && item.y != null;
if (onlyMissing && placed) return null;
const old = previous?.[item.structure_id ?? ""];
if (old && same(old, item) && old.x != null) return { x: old.x, y: old.y! };
if (placed && holdsStation(item, line, intervalM)) return null;
const at = pointAtChainage(line, item.chainage_m);
if (at) filled += 1;
return at;
});
return { xy, filled };
}
/** 노선을 저장한 [저장] — 세션 관 초안(목록 한 벌)을 서버가 따라 옮긴 관(`after`) 위에 얹은 목록(누가거리 순).
* 초안 관이 노선 저장 전 정본(`before`) 자리 그대로면(따라감 전 값) · 따라가기만 했으면(땅 위 자리 = 정본 x · y ·
* 누가거리만 화면 계산 · 428) 서버 자리로(구간 끝 · 좌표도) · 사용자가 옮긴 · 새로 놓은 관은 그대로 · 서버가 새로
@@ -5,6 +5,7 @@
"corridor_mesh": "both",
"quantity": "both",
"carry": "both",
"route": "both",
"stations": "both",
"fixed_browser": "both"
}
+2 -1
View File
@@ -63,9 +63,10 @@ def test_묶음_여덟_줄_등록부_전부():
"corridor_mesh",
"quantity",
"carry",
"route",
"stations",
"fixed_browser",
} # 511-A 셋 더 · 511-B B-2 stations
} # 511-A 셋 더 · 511-B B-2 stations · B-5 route
for group in calc_sites.GROUPS:
got = {sites[no] for no in group["nos"]}
if group["fixed"] == "browser":
@@ -50,6 +50,8 @@ NODE_IDS = {
"stations.carry",
"stations.list",
"stations.sections",
"route.polyline",
"stations.plan",
}
pytestmark = pytest.mark.skipif(
@@ -3,7 +3,8 @@
못박는 것
- 단계 id 다섯(`stage.route` · `follow` · `stations` · `profile` · `cross`) = TS 표 =
파이썬 `STAGE_IDS` · `available`(등록부 `node:`) 밖 · 창구(`run_calc_served`)가 받음.
- 판 = 단계 줄(잠금 · 기본 both · 까닭 「짝 모으는 중 · B-n」 · stations = B-2 가 엶) · F9 = 폼 기본값 ·
- 판 = 단계 줄(잠금 · 기본 both · 까닭 「짝 모으는 중 · B-n」 · stations = B-2 · route = B-5 가
엶) · F9 = 폼 기본값 ·
커밋한 판 그대로.
- 고정 자료(`fixtures/golden_472`) — Node 단계가 저장 노선 한 바퀴를 돎
(측점 = 저장 상세 측점 0.01 m).
@@ -76,7 +77,6 @@ def test_단계_id_표_같음_등록부_밖():
def test_판_다섯_줄_잠금_기본값_그대로():
by_key = {g["key"]: g for g in calc_sites.GROUPS}
expected = {
"route": (["R1", "R3"], "pair_B5"),
"follow": (["P5", "P6", "F1", "F2"], "pair_B5"),
"profile": (["P1", "P2", "P3", "P4", "P8"], "pair_B3"),
"cross": (["G2", "G4", "C1", "C2", "C3", "C5", "C6", "F4", "F5", "F6"], "pair_B4"),
@@ -92,6 +92,7 @@ def test_판_다섯_줄_잠금_기본값_그대로():
"corridor_mesh",
"quantity",
"carry",
"route", # B-5 = 엶(511-B B-5)
"stations", # B-2 끝 = 엶(511-B B-2)
"fixed_browser",
}
+245
View File
@@ -0,0 +1,245 @@
"""PLAN 511-B B-5 (요구 320-4) — 노선 · 따라감 서버 몫 = 화면과 같은 TS(늘 떠 있는 Node).
못박는 것
- 노선 저장 폴리라인 파일(선 csv · 노드 csv · 곡선표 + 편집표) — Node `route.polyline` 길 =
파이썬 길 바이트 같음(거울 CASES · 위반 글 딱 반 R 12.25 · 12.75 · 겹친 점 · 짧은 칸 · 고정 ·
고정 길이 · 알려진 차 `KNOWN_ARC` = 직각 원호 점 ±1) ·
검증_sub3 저장 노선(노드 · 편집표) 다시 씀 = 저장된 곡선표 · 노드 그대로.
- 저장 좌표 맞춤(F1) — Node `facility.follow` + fill = 파이썬 짝
`_fill_structure_coordinates_py`(좌표 없음 · 측점 낳음 · 낡은 좌표 · 옛 좌표 지킴 · 자리 바뀜 ·
site · 읽을 때 · 노선 밖).
- 남은 측점 계획(F3) — Node `stations.plan` = 파이썬 `plan_stations`(322-3 CASES · 묶음마다).
- 공유 DB 안 씀 · storage 읽기만(검증_sub3 · 없으면 건너뜀) · 쓰기는 tmp.
./venv/Scripts/python.exe -m pytest resources/tester/test_511_b5_route_node.py -q
"""
from __future__ import annotations
import json
import sys
from pathlib import Path
from types import SimpleNamespace
import pytest
HERE = Path(__file__).resolve().parent
ROOT = HERE.parents[1]
for folder in (ROOT, HERE):
if str(folder) not in sys.path:
sys.path.insert(0, str(folder))
from B05_Profile import B05_Profile_Route_Polyline_Write as writer # noqa: E402
from common_util import common_util_route_follow as follow # noqa: E402
pytestmark = pytest.mark.skipif(
not (ROOT / "config" / "node_modules" / "vite").is_dir(), reason="프론트엔드 의존성 미설치"
)
SUB3 = (
ROOT / "storage" / "1" / "3" / "12953f5a-5ba1-42ad-a427-8ac857a58bc2" / "B05_Profile" / "route"
)
FIXTURE = HERE / "fixtures" / "golden_472"
NAMES = ("planned_route.csv", "planned_route_nodes.csv", "planned_route_curves.json")
# 알려진 차(브레인 승인 · 511-B B-5) — 교각 딱 5° 배수(합성 직각)면 원호 칸 수가 libm 끝자리로 갈림
# (파이썬 89.99999999999992° → 17 칸 · Node 90.0° → 18 칸) · 원호 점 ±1 · 실데이터 저장 노선 0 차
KNOWN_ARC = {
"직각 한 번 · R 자동",
"R 못박음 30m",
"R 이 접선 자리보다 커서 눌림",
"겹친 점(0.3m) 섞임 — 서버와 같이 버려야 함",
"딱 반 R(위반 글 짝수 쪽)",
}
def _files(folder: Path, monkeypatch, node: bool, args: dict) -> tuple[dict, dict]:
folder.mkdir(parents=True, exist_ok=True)
with monkeypatch.context() as patch:
if not node:
patch.setattr(writer, "node_polyline", lambda *a: None)
else: # Node 못 돌리면 파이썬으로 새는 것을 막음
made = writer.node_polyline
patch.setattr(writer, "node_polyline", lambda *a: made(*a) or pytest.fail("Node 없음"))
summary = writer._write_planned_polyline(folder / NAMES[0], simplify=False, **args)
return summary, {name: (folder / name).read_bytes() for name in NAMES}
def _cases() -> list[tuple[str, dict]]:
import test_route_polyline_browser_mirror as mirror
out = [
(
name,
{
"points": points,
"radius_m": mirror.MIN_RADIUS_M,
"curve_flags": flags,
"radii": radii,
},
)
for name, points, flags, radii in mirror.CASES
]
corner = [(0.0, 0.0), (100.0, 0.0), (100.0, 100.0), (200.0, 100.0)]
out += [
(
"딱 반 R(위반 글 짝수 쪽)",
{
"points": corner,
"radius_m": 15.0,
"curve_flags": [True] * 4,
"radii": [None, 12.25, 12.75, None],
},
),
(
"겹친 점 · 짧은 칸 · 고정 · 고정 길이",
{
"points": [(0.0, 0.0), (80.0, 0.0), (80.3, 0.1), (120.0, 60.0), (200.0, 60.0)],
"radius_m": 12.0,
"curve_flags": [True, True, True, False],
"radii": [None, 30.0],
"locks": [None, "radius", "arc", "bad", None],
"arcs": [None, None, 20.5, None],
},
),
(
"점 둘",
{
"points": [(0.0, 0.0), (10.0, 0.0)],
"radius_m": 12.0,
"curve_flags": [True, True],
"radii": [None, None],
},
),
]
return out
@pytest.mark.parametrize("name,args", _cases(), ids=[name for name, _ in _cases()])
def test_노선_폴리라인_Node_파이썬_바이트_같음(tmp_path, monkeypatch, name, args):
(py_sum, py), (node_sum, node) = (
_files(tmp_path / mode, monkeypatch, mode == "node", args) for mode in ("py", "node")
)
if name in KNOWN_ARC and node[NAMES[0]] != py[NAMES[0]]:
# 알려진 차 — 선 csv 원호 점 수만 ±1 · 노드 · 곡선표 · 편집표는 같음
assert abs(node_sum["vertices"] - py_sum["vertices"]) == 1, name
node, py = ({k: v for k, v in f.items() if k != NAMES[0]} for f in (node, py))
node_sum, py_sum = ({**s, "vertices": 0} for s in (node_sum, py_sum))
assert (node_sum, node) == (py_sum, py), name
def test_딱_반_R_위반_글은_파이썬_꼴(tmp_path, monkeypatch):
args = dict(_cases())["딱 반 R(위반 글 짝수 쪽)"]
_, files = _files(tmp_path, monkeypatch, True, args)
text = files[NAMES[2]].decode("utf-8")
assert "최소곡선반지름 미달(12.2 < 15.0m)" in text and "(12.8 < 15.0m)" in text
@pytest.mark.skipif(not (SUB3 / "planned_route_nodes.csv").is_file(), reason="검증_sub3 노선 없음")
def test_검증_sub3_저장_노선_다시_씀_같음(tmp_path, monkeypatch):
from common_util.common_util_route_geometry import read_planned_route_csv
table = json.loads((SUB3 / NAMES[2]).read_text(encoding="utf-8"))
nodes = [(v.x, v.y) for v in read_planned_route_csv(SUB3 / NAMES[1]).vertices]
edits = table["edits"]
args = {
"points": nodes,
"radius_m": table["min_radius_m"],
"curve_flags": [e["curve"] for e in edits],
"radii": [e["radius_m"] for e in edits],
"locks": [e["lock"] for e in edits],
"arcs": [e["arc_m"] for e in edits],
"station_snap": table.get("station_snap"),
"station_snap_step": table.get("station_snap_step"),
}
py = _files(tmp_path / "py", monkeypatch, False, args)
node = _files(tmp_path / "node", monkeypatch, True, args)
assert node == py
again = json.loads(node[1][NAMES[2]])
assert again["curves"] == table["curves"] and again["edits"] == edits
assert node[1][NAMES[1]] == (SUB3 / NAMES[1]).read_bytes()
def _line() -> list[list[float]]:
plan = json.loads((FIXTURE / "plan.json").read_text(encoding="utf-8"))
return plan["planned"]
def _items(line: list[list[float]]) -> tuple[list[SimpleNamespace], dict]:
on = follow.LineString(line)
def at(m: float) -> tuple[float, float]:
p = on.interpolate(m)
return round(p.x, 4), round(p.y, 4)
def s(sid, chainage, x=None, y=None, placement="point", **span):
return SimpleNamespace(
structure_id=sid,
placement=placement,
chainage_m=chainage,
start_m=span.get("start_m"),
end_m=span.get("end_m"),
x=x,
y=y,
)
hold = at(150.0)
items = [
s("bare", 120.0),
s("holds", 150.0, *hold),
s("stale", 210.0, *at(180.0)),
s("kept", 260.0, 1.0, 2.0),
s("moved", 300.0, 3.0, 4.0),
s("site", 340.0, *at(341.3), placement="site"),
s("span", 380.0, start_m=370.0, end_m=395.0),
s("none", None, 5.0, 6.0),
s("beyond", 99999.0),
s("prev-bare", 420.0),
]
previous = {
"kept": s("kept", 260.0, 208000.5, 367800.25),
"moved": s("moved", 290.0, 9.0, 9.0),
"prev-bare": s("prev-bare", 420.0, 7.0, 8.0),
}
return items, previous
@pytest.mark.parametrize("only_missing", [False, True])
def test_좌표_맞춤_Node_파이썬_같음(only_missing):
line = _line()
results = []
for fill in (follow.fill_structure_coordinates, follow._fill_structure_coordinates_py):
items, previous = _items(line)
count = fill(items, line, previous=previous, only_missing=only_missing)
results.append((count, [(item.x, item.y) for item in items]))
assert results[0] == results[1]
assert results[0][0] > 0
def test_좌표_맞춤_Node_길이_실제로_탐(monkeypatch):
called = []
real = follow.run_calc
monkeypatch.setattr(follow, "run_calc", lambda *a: called.append(a[0]) or real(*a))
items, previous = _items(_line())
follow.fill_structure_coordinates(items, _line(), previous=previous)
assert called == [["facility.follow"]]
def test_측점_계획_Node_파이썬_같음():
import test_322_3_plan_mirror as plan_mirror
from B05_Profile.B05_Profile_Engine_Sections_Node import node_plan_stations
from B05_Profile.B05_Profile_Structures_Schema import StructureInstance
from common_util.common_util_drainage_pipes import parse_pipe_points
from common_util.common_util_structure_station import plan_stations
groups = [
(
parse_pipe_points(case["pipes"]),
[StructureInstance.model_validate(s) for s in case["structures"]],
list(case.get("plus", ())),
)
for case in plan_mirror.CASES.values()
]
node = node_plan_stations(groups)
assert node is not None
assert node == [plan_stations(*group) for group in groups]
+4
View File
@@ -115,6 +115,10 @@ export const ui_locales_m1 = {
"끄면 잠잠 뒤 서버 셈 · F10 = 브라우저 부름 없음(판만)",
"Off = server calc after a pause · F10 = no browser call",
],
B01_CalcSites_Why_route: [
"끄면 잠잠 뒤 서버 노선(같은 TS) · 노선 [저장] = 서버 Node",
"Off = server route after a pause (same TS) · route [Save] = server Node",
],
B01_CalcSites_Why_stations: [
"끄면 잠잠 뒤 서버 측점(같은 TS) · [저장] 측점 = 서버 Node · DTM 아닌 지표면은 파이썬",
"Off = server stations after a pause (same TS) · [Save] stations = server Node · non-DTM surface = Python",