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:
@@ -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;
|
||||
|
||||
@@ -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,
|
||||
);
|
||||
}
|
||||
@@ -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),
|
||||
}
|
||||
@@ -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),
|
||||
(
|
||||
|
||||
@@ -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:
|
||||
"""계획노선 **초기 폴리라인**이 없거나 낡았으면 예상노선을 폴리라인화해 세운다.
|
||||
|
||||
|
||||
@@ -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).
|
||||
*
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -46,6 +46,8 @@ NODE_IDS = frozenset(
|
||||
"stations.carry",
|
||||
"stations.list",
|
||||
"stations.sections",
|
||||
"route.polyline",
|
||||
"stations.plan",
|
||||
}
|
||||
)
|
||||
# 창구(`/calc`)가 안 받는 id — 서버 파일 경로를 입력으로 받음(측점 생성 · 511-B B-2)
|
||||
|
||||
@@ -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"],
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
|
||||
@@ -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",
|
||||
}
|
||||
|
||||
@@ -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]
|
||||
@@ -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",
|
||||
|
||||
Reference in New Issue
Block a user