fix(459 노선 바뀜): [저장] 구조물 초안을 서버 따라감 위에 · 추가 측점 초안 동안 좌표 따라감 · 옮긴 시설 측점 427 꼭짓점 세움 · 관 따라감 선 = points
- 구조물: 노선 저장 뒤 rebaseStructureDraft(관 rebasePipeDraft 짝) — 따라가기만 한 것 = 서버 자리 · 사용자가 옮긴 것 = 초안 - 추가 측점: follow 단계가 좌표로 옮긴 정수 m 를 patch.plusStations 로(FOLLOW_KEYS · stations 단계 입력값) · followPlusDraft(가게 쓰기 · 초안 버림 되돌림 · page 부름은 sub7) · follow 만 쓴 초안은 [저장]이 PUT 안 함 - 427: 초안 planline · 서버 _carry_profile 이 바뀐 구간 관 · 구조물 측점(+ 초안 중 사용자가 옮긴 것)에 seatBornNodes 로 다시 세움(route_moved_stations · seat_route_moved 짝) - 서버 관 따라감 = 구조물과 같은 points(사용 범위 자름) · 좌표 없는 옛 관은 옛 노선 자리로 먼저 · 따라감 실패 = follow_failed 알림 - 시험 test_459_route_seat_mirror(거울 · 임시 storage · _place_pipes) · 헬퍼 400 · 449 · design_store_save 가짜 창구 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -29,8 +29,13 @@ import {
|
||||
writePendingPipes,
|
||||
} from "../../B05_Profile/B05_Profile_Api_Pipes_Draft";
|
||||
import { fetchDetailPipePoints } from "../../B04_PreProcess/B04_PreProcess_Api_Fetch";
|
||||
import { rebasePipeDraft } from "../../common_util/common_util_route_follow";
|
||||
import { flushPendingStructures } from "../../B05_Profile/B05_Profile_Api_Structures";
|
||||
import { rebasePipeDraft, rebaseStructureDraft } from "../../common_util/common_util_route_follow";
|
||||
import {
|
||||
fetchStructures,
|
||||
flushPendingStructures,
|
||||
readPendingStructures,
|
||||
writePendingStructures,
|
||||
} from "../../B05_Profile/B05_Profile_Api_Structures";
|
||||
import { flushPlusStations } from "../../B05_Profile/B05_Profile_Api_PlusStations";
|
||||
import {
|
||||
flushUphillOverrides,
|
||||
@@ -209,6 +214,25 @@ async function rebasePendingPipes(projectId: string, before: SavedPipes): Promis
|
||||
writePendingPipes(projectId, rebasePipeDraft(draft, before, moved));
|
||||
}
|
||||
|
||||
type SavedStructures = Awaited<ReturnType<typeof fetchStructures>>["structures"];
|
||||
|
||||
/** 세션 구조물 초안이 있으면 노선 저장 전 정본 — 없으면 · 못 받으면 null(얹기 안 함 · 459). */
|
||||
const savedStructuresIfDraft = (projectId: string): Promise<SavedStructures | null> =>
|
||||
readPendingStructures(projectId)
|
||||
? fetchStructures(projectId)
|
||||
.then((list) => list.structures)
|
||||
.catch(() => null)
|
||||
: Promise.resolve(null);
|
||||
|
||||
/** 노선을 저장함 — 서버가 구조물을 좌표로 따라 옮김. 세션 구조물 초안을 그 위에 얹음(459 · 관 짝) — 구조물
|
||||
* 차례가 서버 따라감을 브라우저 누가거리로 덮지 않게. 못 받으면 초안 그대로. */
|
||||
async function rebasePendingStructures(projectId: string, before: SavedStructures): Promise<void> {
|
||||
const draft = readPendingStructures(projectId);
|
||||
const after = await fetchStructures(projectId).catch(() => null);
|
||||
if (!draft || !after) return;
|
||||
writePendingStructures(projectId, rebaseStructureDraft(draft, before, after.structures));
|
||||
}
|
||||
|
||||
/**
|
||||
* [저장] · [확정] — 세션에 쌓인 조작을 정해진 차례로 정본에 보낸다.
|
||||
* 노선이 없으면 아무 일도 하지 않고 false. 끝까지 나가면 true.
|
||||
@@ -234,10 +258,19 @@ export async function saveDesign(
|
||||
if (step === "route") {
|
||||
const body = await pendingRouteSave(ctx.projectId); // 안 바뀌었으면 null — 안 보냄
|
||||
if (!body) continue;
|
||||
const before = await savedPipesIfDraft(ctx.projectId);
|
||||
const [before, beforeStructures] = await Promise.all([
|
||||
savedPipesIfDraft(ctx.projectId),
|
||||
savedStructuresIfDraft(ctx.projectId),
|
||||
]);
|
||||
routeSaved = await saveRouteDraft(ctx.projectId, body); // 실패 = throw(서버가 되살림)
|
||||
if (routeSaved.route_id != null) routeId = routeSaved.route_id;
|
||||
if (before) await rebasePendingPipes(ctx.projectId, before);
|
||||
if (beforeStructures) await rebasePendingStructures(ctx.projectId, beforeStructures);
|
||||
if (routeSaved.follow_failed?.length)
|
||||
showToast(
|
||||
`${routeSaved.follow_failed.join(" · ")} 따라감 실패 — 옛 누가거리 그대로입니다.`,
|
||||
"error",
|
||||
);
|
||||
continue;
|
||||
}
|
||||
if (step === "cross") {
|
||||
|
||||
@@ -60,7 +60,12 @@ export const STAGE_DEPS: Record<StageName, Dep[]> = {
|
||||
};
|
||||
|
||||
/** follow 가 고칠 수 있는 입력 — 노선 · 횡단은 안 됨(고리). */
|
||||
const FOLLOW_KEYS: ReadonlyArray<keyof FollowPatch> = ["pipes", "structures", "alignment"];
|
||||
const FOLLOW_KEYS: ReadonlyArray<keyof FollowPatch> = [
|
||||
"pipes",
|
||||
"structures",
|
||||
"alignment",
|
||||
"plusStations",
|
||||
];
|
||||
/** 이 단계 뒤 한 번 쉼 — 종단이 먼저 그려지고 횡단이 그다음. */
|
||||
const YIELD_AFTER = new Set<StageName>(["profile"]);
|
||||
export const RUN_LOG_SIZE = 20;
|
||||
|
||||
@@ -74,7 +74,12 @@ export function createStationsStage(
|
||||
route.curves,
|
||||
(ctx.inputs.pipes as PlanPipe[] | null) ?? [],
|
||||
(ctx.inputs.structures as PlanStructure[] | null) ?? [],
|
||||
{ ...rest, types: options.types(), ...snapOptions(ctx.inputs.route, saved?.()) },
|
||||
{
|
||||
...rest,
|
||||
types: options.types(),
|
||||
...snapOptions(ctx.inputs.route, saved?.()),
|
||||
plus: ctx.inputs.plusStations ?? undefined, // follow 가 옮긴 추가 측점(459 · 가게보다 먼저)
|
||||
},
|
||||
);
|
||||
return reuseOutside(next, ctx.prev, ctx.range);
|
||||
};
|
||||
|
||||
@@ -152,6 +152,8 @@ export interface FollowPatch {
|
||||
pipes?: unknown;
|
||||
structures?: unknown;
|
||||
alignment?: AlignmentEdits;
|
||||
/** 추가 측점 — 새 노선 정수 m(459 · 좌표 따라감). */
|
||||
plusStations?: ReadonlyArray<number> | null;
|
||||
}
|
||||
|
||||
/** 측점 종류 — 격자(간격) · 구조물 · 관 · 끝(시점 · 종점) · 곡선(MC · 252-2 · 격자 번호 없음). */
|
||||
|
||||
@@ -70,8 +70,43 @@ export function loadSavedPlusStations(projectId: string): Promise<void> {
|
||||
return loaded.get(projectId)!;
|
||||
}
|
||||
|
||||
/** [저장] 차례 — 초안이 있으면 정본에 씀 · 없으면 저장분만 다시 받음(노선 저장이 좌표로 옮겼을 수 있음). */
|
||||
/** 노선 초안 전 추가 측점 초안(없으면 null) — 초안을 버리면 이것으로(459). */
|
||||
const beforeRoute = new Map<string, number[] | null>();
|
||||
/** follow 만 쓴 초안(사용자 초안 없던 때) — 그대로면 [저장]이 PUT 안 함(서버 따라감이 정본 · 459). */
|
||||
const followedOnly = new Map<string, string>();
|
||||
|
||||
/**
|
||||
* 노선 초안 동안 추가 측점 따라감(459) — 묶음 follow 출력(`plusStations` · 새 노선 정수 m)을 초안에 씀 ·
|
||||
* `next = null` = 노선 초안을 버림(초안 전 값으로). 부르는 곳 = design_pipeline_page follow 구독
|
||||
* (관 · 구조물 `writeFacilities` 곁 · 초안 버림 갈래도).
|
||||
*/
|
||||
export function followPlusDraft(
|
||||
projectId: string,
|
||||
next: ReadonlyArray<number> | null | undefined,
|
||||
): void {
|
||||
if (next === null) {
|
||||
if (!beforeRoute.has(projectId)) return;
|
||||
writePlusDraft(projectId, beforeRoute.get(projectId)!);
|
||||
beforeRoute.delete(projectId);
|
||||
followedOnly.delete(projectId);
|
||||
return;
|
||||
}
|
||||
if (!next) return;
|
||||
if (!beforeRoute.has(projectId)) beforeRoute.set(projectId, readPlusDraft(projectId));
|
||||
writePlusDraft(projectId, [...next]);
|
||||
if (beforeRoute.get(projectId)) followedOnly.delete(projectId);
|
||||
else followedOnly.set(projectId, JSON.stringify(readPlusDraft(projectId)));
|
||||
}
|
||||
|
||||
/** [저장] 차례 — 사용자 초안이 있으면 정본에 씀 · 없으면(follow 만 쓴 초안 포함 · 459) 저장분만 다시 받음
|
||||
* (노선 저장이 좌표로 옮겼을 수 있음). */
|
||||
export async function flushPlusStations(projectId: string): Promise<void> {
|
||||
const auto = followedOnly.get(projectId);
|
||||
followedOnly.delete(projectId);
|
||||
beforeRoute.delete(projectId);
|
||||
const kept = readPlusDraft(projectId);
|
||||
if (kept && auto === JSON.stringify(kept))
|
||||
useDesignStore(projectId, null).set("plusStations", null);
|
||||
const draft = readPlusDraft(projectId);
|
||||
const list = draft
|
||||
? await request(projectId, { method: "PUT", body: JSON.stringify({ chainages: draft }) })
|
||||
|
||||
@@ -135,6 +135,8 @@ export interface RouteReplanResponse {
|
||||
/** 노선 저장 결과(346-3 · 서버 `B05_Profile_Route_Save` 머리) — 관 지킴 · 새 교차. */
|
||||
pipes?: { kept: number; crossing: number };
|
||||
structures?: { moved: number; far: number };
|
||||
/** 따라감 실패한 것(「구조물」 · 「추가 측점」) — 옛 누가거리 그대로(459). */
|
||||
follow_failed?: string[];
|
||||
/** 새 노선에 못 넘긴 횡단 고침(측점이 사라짐) 수. */
|
||||
cross_dropped?: number;
|
||||
/** 배수유역 저장분이 새 노선과 안 맞음(다시 안 셈). */
|
||||
|
||||
@@ -45,6 +45,8 @@ from B05_Profile.B05_Profile_Route_Save_Parts import (
|
||||
add_crossing_pipes,
|
||||
carry_cross_inputs,
|
||||
follow_pipes,
|
||||
route_moved_stations,
|
||||
seat_route_moved,
|
||||
station_keys,
|
||||
vertices_of,
|
||||
)
|
||||
@@ -168,16 +170,24 @@ async def _place_pipes(
|
||||
old_xy: list[list[float]],
|
||||
points: list[dict[str, float]],
|
||||
) -> tuple[list, dict[str, int]]:
|
||||
"""관 따라감 → 새 교차점 기본 관 → 정본 저장(새 노선 지문) · 간격 보충 없음(369)."""
|
||||
"""관 따라감 → 새 교차점 기본 관 → 정본 저장(새 노선 지문) · 간격 보충 없음(369).
|
||||
|
||||
따라갈 선 = 구조물 · 추가 측점과 같은 `points`(사용 범위 자름 · 459) — B04 `_prepare`
|
||||
노선은 범위를 안 잘라 누가거리가 갈림 · 세류선만 거기서. 좌표 없는 옛 관은 옛 노선
|
||||
자리로 먼저 채움(459).
|
||||
"""
|
||||
from B04_PreProcess.B04_PreProcess_Engine_Watershed_Analyze import _base_pipes
|
||||
from B04_PreProcess.B04_PreProcess_Router_Watershed import _prepare
|
||||
from common_util.common_util_drainage_pipes import route_signature, save_pipe_points
|
||||
from common_util.common_util_drainage_pipes import (
|
||||
fill_pipe_coordinates,
|
||||
route_signature,
|
||||
save_pipe_points,
|
||||
)
|
||||
|
||||
prepared = await _prepare(project_id)
|
||||
if isinstance(prepared, JSONResponse): # 도엽 · 노선 파일 없음 — 따라감만
|
||||
vertices, streams = vertices_of([(p["x"], p["y"]) for p in points]), []
|
||||
else:
|
||||
vertices, streams = prepared["vertices"], prepared["streams"]
|
||||
streams = [] if isinstance(prepared, JSONResponse) else prepared["streams"] # 도엽 없음
|
||||
vertices = vertices_of([(p["x"], p["y"]) for p in points])
|
||||
fill_pipe_coordinates(old_pipes, vertices_of(old_xy))
|
||||
pairs = follow_pipes(old_pipes, vertices)
|
||||
total = float(vertices[-1].chainage_m)
|
||||
crossing = add_crossing_pipes(pairs, _base_pipes(vertices, streams), old_xy, total)
|
||||
@@ -226,6 +236,10 @@ async def _carry_profile(
|
||||
edits = carry_edits(old_xy, new_xy, old_base, old_alignment.get("edits"), stations, half)
|
||||
alignment["policy"] = carry_policy(old_xy, new_xy, old_alignment, alignment) # 옛 L · 열쇠(432)
|
||||
alignment["base_pvi"] = base
|
||||
if span is not None: # 옮긴 시설 측점에 427 꼭짓점 다시 세움(459 · 초안 planline 짝)
|
||||
born = route_moved_stations(longitudinal.get("stations") or [], old_xy, new_xy)
|
||||
held = [float(key) for key in (edits.get("station_offsets") or {})]
|
||||
alignment["base_pvi"] = seat_route_moved(alignment, born, held)
|
||||
await asyncio.to_thread(atomic_write_json, path, longitudinal)
|
||||
_alignment, summary, _dropped = await asyncio.to_thread(
|
||||
_apply_alignment_edits, project_root, relative, edits
|
||||
@@ -387,6 +401,7 @@ async def run_route_save_chain(
|
||||
)
|
||||
mark_designing(project_root)
|
||||
try:
|
||||
follow_failed: list[str] = [] # 따라감 실패 = 옛 누가거리 그대로 · 화면이 알림(459)
|
||||
# 구조물 = 땅 위 자리 따라감(옛 노선 파일이 아직 옛 것일 때 — 좌표 없는 옛 자료 채움)
|
||||
try:
|
||||
structures = await asyncio.to_thread(
|
||||
@@ -395,12 +410,14 @@ async def run_route_save_chain(
|
||||
except Exception: # noqa: BLE001 — 따라감 실패 = 옛 누가거리 그대로(비치명)
|
||||
logger.exception("구조물 따라감 실패(옛 누가거리 유지): project_id=%s", project_id)
|
||||
structures = {"moved": 0, "far": 0}
|
||||
follow_failed.append("구조물")
|
||||
try: # 추가 측점도 땅 위 자리 따라감(368-2 · 실패 = 옛 누가거리 그대로)
|
||||
await asyncio.to_thread(
|
||||
follow_plus_stations, project_root, [(p["x"], p["y"]) for p in points]
|
||||
)
|
||||
except Exception: # noqa: BLE001
|
||||
logger.exception("추가 측점 따라감 실패(옛 누가거리 유지): project_id=%s", project_id)
|
||||
follow_failed.append("추가 측점")
|
||||
request = redesign_solve_request(params, selection, surface_model_id)
|
||||
solved: Any = await solve_route_record(project_id, request, generate_sections=False)
|
||||
if isinstance(solved, JSONResponse):
|
||||
@@ -481,6 +498,7 @@ async def run_route_save_chain(
|
||||
"total_length_m": float(solved.total_length_m or 0.0),
|
||||
"pipes": pipes,
|
||||
"structures": structures,
|
||||
"follow_failed": follow_failed,
|
||||
"cross_dropped": dropped,
|
||||
"drainage_stale": True,
|
||||
"seconds": round(marks[-1][1] - marks[0][1], 2),
|
||||
|
||||
@@ -186,3 +186,42 @@ def carry_cross_inputs(
|
||||
if adjust:
|
||||
adjusts[round(new_at, 3)] = adjust
|
||||
return inputs, adjusts, dropped
|
||||
|
||||
|
||||
def route_moved_stations(
|
||||
stations: Sequence[dict[str, Any]],
|
||||
old_xy: Sequence[Sequence[float]],
|
||||
new_xy: Sequence[Sequence[float]],
|
||||
) -> list[float]:
|
||||
"""노선 바뀜으로 옮긴 시설 측점 — 관 · 구조물 측점(`structure` 이름 있음) 중 옛 선과
|
||||
같은 자리가 아닌 것(`line_matcher` 짝 없음 · 459). 같은 자리로 따라간 시설은 뺌(옛 높이
|
||||
그대로). TS 짝 = `routeMovedStations`(B05_Profile/logic/B05_Profile_Pipeline_Grade.ts) ·
|
||||
거울 시험 = test_459_route_seat_mirror.py."""
|
||||
from B05_Profile.B05_Profile_Engine_Grade_Carry import line_matcher
|
||||
|
||||
match = line_matcher([tuple(p) for p in old_xy], [tuple(p) for p in new_xy])
|
||||
return sorted(
|
||||
float(station["chainage_m"])
|
||||
for station in stations
|
||||
if station.get("structure") and match(float(station["chainage_m"])) is None
|
||||
)
|
||||
|
||||
|
||||
def seat_route_moved(
|
||||
alignment: dict[str, Any], born: Sequence[float], held: Sequence[float] = ()
|
||||
) -> list[dict[str, Any]]:
|
||||
"""옮긴 시설 측점(`born`)에 427 규칙으로 꼭짓점을 다시 세움(459 · 요구 270) — 그 자리
|
||||
옛 꼭짓점(옮겨 붙은 지반 · 옛 높이)을 걷고 원지반 지남 · 이웃 1 mm 맞춤 · 균형 종단이면
|
||||
옮기기 전 계획선 높이(`_seat_born_nodes`). `alignment` = 새 지반 표본 · 정책 · 옮겨 붙인
|
||||
`base_pvi` · `held` = 높이 고친 측점. TS 짝 = `seatRouteMoved`."""
|
||||
from B05_Profile.B05_Profile_Router_Confirm import _SAME_STATION_M, _seat_born_nodes
|
||||
|
||||
base = alignment.get("base_pvi") or []
|
||||
if not born:
|
||||
return base
|
||||
kept = [
|
||||
node
|
||||
for node in base
|
||||
if all(abs(float(node["chainage_m"]) - value) >= _SAME_STATION_M for value in born)
|
||||
]
|
||||
return _seat_born_nodes(alignment, kept, list(born), list(held))
|
||||
|
||||
@@ -4,9 +4,12 @@
|
||||
* `A00_Common/design/logic/design_pipeline_types.ts`. 셈은 이미 있는 짝을 부르기만(어댑터):
|
||||
*
|
||||
* follow — 관 · 구조물 좌표 따라감(`common_util_route_follow`) · 계획고 편집 옮김(`Grade_Carry`
|
||||
* `carryEdits` · 옛 노선 `before.route` 가 있을 때만 · 기준 = 마지막 종단 편집 때 한 벌 · 400).
|
||||
* `carryEdits` · 옛 노선 `before.route` 가 있을 때만 · 기준 = 마지막 종단 편집 때 한 벌 · 400) ·
|
||||
* 추가 측점 좌표 따라감(459 · `followPlusStations`).
|
||||
* planline — 노선 초안이 없으면 저장 종단 기준 그대로 · 있으면 새 측점마다 같은 자리는 저장 꼭짓점
|
||||
* 높이 · 바뀐 자리만 새 지반 첫 계획선(`Grade_Ground` · `carryBase`).
|
||||
* 높이 · 바뀐 자리만 새 지반 첫 계획선(`Grade_Ground` · `carryBase`) · 옮긴 시설 측점(바뀐 자리 ·
|
||||
* 사용자가 옮김)에 427 꼭짓점 다시 세움(459 · 짝 = 파이썬 `B05_Profile_Route_Save_Parts`
|
||||
* `route_moved_stations` · `seat_route_moved` · 거울 시험 test_459_route_seat_mirror.py).
|
||||
* profile — `buildAlignment`(첫 계획선 + 계획고 편집) — 종단 보기가 쓰는 같은 함수.
|
||||
*
|
||||
* 화면 · fetch · 세션 없음(순수) — 저장 기준 · 저장 노선은 부르는 쪽이 넣음.
|
||||
@@ -15,24 +18,34 @@
|
||||
import type {
|
||||
FollowPatch,
|
||||
PipelineInputs,
|
||||
PipelineStation,
|
||||
RangePart,
|
||||
RouteLine,
|
||||
StageFn,
|
||||
Vertex,
|
||||
} from "../../A00_Common/design/logic/design_pipeline_types";
|
||||
import {
|
||||
followPlusStations,
|
||||
followStructures,
|
||||
projectPipePoints,
|
||||
type FollowingPoint,
|
||||
type FollowingStructure,
|
||||
} from "../../common_util/common_util_route_follow";
|
||||
import { PLUS_OWNER_PREFIX, SAME_STATION_M } from "../../common_util/common_util_structure_station";
|
||||
import {
|
||||
buildAlignment,
|
||||
emptyEdits,
|
||||
type AlignmentBase,
|
||||
type AlignmentEdits,
|
||||
} from "./B05_Profile_UI_Profile_Alignment";
|
||||
import { carryBase, carryEdits, changedSpan, type XY } from "./B05_Profile_Grade_Carry";
|
||||
import {
|
||||
carryBase,
|
||||
carryEdits,
|
||||
changedSpan,
|
||||
lineMatcher,
|
||||
type XY,
|
||||
} from "./B05_Profile_Grade_Carry";
|
||||
import { seatBornNodes } from "./B05_Profile_Grade_Seat";
|
||||
import { SECTION_STATION_INTERVAL_M } from "./B05_Profile_Sections_Stations";
|
||||
import { groundFollowingBasePvi, groundProfile } from "./B05_Profile_Grade_Ground";
|
||||
import { DEFAULT_CURVE_LENGTH_M } from "./B05_Profile_Profile_CurveLaw";
|
||||
@@ -99,14 +112,17 @@ export function createFollowStage(deps: {
|
||||
}): StageFn<"follow"> {
|
||||
let ref: GradeRef | null = null;
|
||||
let carried: unknown = null; // 마지막으로 낸 편집 — 입력이 이것이면 기준 그대로
|
||||
let moved = new Set<string>(); // 노선 초안 동안 사용자가 옮긴 · 놓은 시설 id(459 · 427 세움)
|
||||
return (ctx) => {
|
||||
if (!ctx.inputs.route) moved = new Set();
|
||||
if (!ctx.inputs.route && deps.saved) return deps.saved();
|
||||
const before = ctx.before.route?.line ?? deps.savedLine();
|
||||
const now = ctx.out.route;
|
||||
if (!before || !now) return {};
|
||||
const line = xy(now.line);
|
||||
const patch: FollowPatch = {};
|
||||
const { pipes, structures, alignment } = ctx.inputs;
|
||||
const { pipes, structures, alignment, plusStations } = ctx.inputs;
|
||||
if (ctx.before.follow) userMoved(ctx.inputs, ctx.before.follow).forEach((id) => moved.add(id));
|
||||
if (Array.isArray(pipes) && pipes.length)
|
||||
patch.pipes = projectPipePoints(pipes as FollowingPoint[], line);
|
||||
if (Array.isArray(structures) && structures.length)
|
||||
@@ -116,6 +132,10 @@ export function createFollowStage(deps: {
|
||||
undefined,
|
||||
SECTION_STATION_INTERVAL_M, // site = 새 노선 일반 측점(380-3)
|
||||
).map((moved) => moved.item);
|
||||
// 추가 측점도 땅 위 자리 따라감(459 · 서버 `follow_plus_stations` 짝) — 가게는 부르는 쪽이 씀
|
||||
if (plusStations?.length)
|
||||
patch.plusStations = followPlusStations(plusStations, xy(before), line);
|
||||
movedIds.set(patch, new Set(moved));
|
||||
const planline = ctx.before.planline;
|
||||
if (alignment !== carried && JSON.stringify(alignment) !== JSON.stringify(carried))
|
||||
ref =
|
||||
@@ -134,6 +154,71 @@ export function createFollowStage(deps: {
|
||||
};
|
||||
}
|
||||
|
||||
/** 같은 꼭짓점 자리(m) — `seatBornNodes` · 파이썬 `_SAME_STATION_M` 과 같은 0.006. */
|
||||
const SEAT_SAME_M = 0.006;
|
||||
|
||||
/** follow 출력마다 그때까지 사용자가 옮긴 시설 id — planline 이 427 세움에 씀(459). */
|
||||
const movedIds = new WeakMap<object, ReadonlySet<string>>();
|
||||
|
||||
type Facility = { id?: string | null; structure_id?: string | null; chainage_m?: number | null };
|
||||
|
||||
/** 입력 관 · 구조물 중 앞 follow 출력과 자리가 다른(사용자가 옮김) · 새로 놓은 것의 id. */
|
||||
function userMoved(
|
||||
inputs: Pick<PipelineInputs, "pipes" | "structures">,
|
||||
followed: FollowPatch,
|
||||
): string[] {
|
||||
const out: string[] = [];
|
||||
for (const key of ["pipes", "structures"] as const) {
|
||||
const now = inputs[key];
|
||||
const was = followed[key];
|
||||
if (!Array.isArray(now) || !Array.isArray(was)) continue;
|
||||
const idOf = (item: Facility) => (key === "pipes" ? item.id : item.structure_id) ?? null;
|
||||
const at = new Map((was as Facility[]).map((item) => [idOf(item), item.chainage_m ?? null]));
|
||||
for (const item of now as Facility[]) {
|
||||
const id = idOf(item);
|
||||
const old = at.get(id);
|
||||
if (id && (old == null || Math.abs((item.chainage_m ?? 0) - old) > SAME_STATION_M))
|
||||
out.push(id);
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/** 노선 바뀜으로 옮긴 시설 측점(459) — 관 · 구조물 측점 중 옛 선과 같은 자리가 아닌 것(`lineMatcher` 짝
|
||||
* 없음) · 사용자가 옮긴 시설(`moved` id). 추가 측점(`plus-`)은 뺌. 파이썬 `route_moved_stations` 짝(그쪽은
|
||||
* 사용자가 옮긴 것을 관 · 구조물 저장이 세움). */
|
||||
export function routeMovedStations(
|
||||
stations: ReadonlyArray<Pick<PipelineStation, "chainageM" | "kind" | "id">>,
|
||||
oldXy: ReadonlyArray<XY>,
|
||||
newXy: ReadonlyArray<XY>,
|
||||
moved: ReadonlySet<string> = new Set(),
|
||||
): number[] {
|
||||
const match = lineMatcher(oldXy, newXy);
|
||||
return stations
|
||||
.filter(
|
||||
(station) =>
|
||||
(station.kind === "pipe" || station.kind === "structure") &&
|
||||
!station.id?.startsWith(PLUS_OWNER_PREFIX) &&
|
||||
((station.id !== null && moved.has(station.id)) || match(station.chainageM) === null),
|
||||
)
|
||||
.map((station) => station.chainageM)
|
||||
.sort((a, b) => a - b);
|
||||
}
|
||||
|
||||
/** 옮긴 시설 측점(`born`)에 427 꼭짓점을 다시 세움 — 그 자리 꼭짓점을 걷고 `seatBornNodes`(원지반 지남 ·
|
||||
* 이웃 1 mm 맞춤 · 균형 종단이면 옮기기 전 계획선 높이). 파이썬 `seat_route_moved` 짝. */
|
||||
export function seatRouteMoved(
|
||||
base: Pick<AlignmentBase, "chainage" | "ground" | "policy" | "basePvi">,
|
||||
born: ReadonlyArray<number>,
|
||||
held: ReadonlyArray<number> = [],
|
||||
): AlignmentBase["basePvi"] {
|
||||
if (!born.length) return base.basePvi;
|
||||
const kept = base.basePvi.filter((node) =>
|
||||
born.every((value) => Math.abs(node.chainage_m - value) >= SEAT_SAME_M),
|
||||
);
|
||||
return seatBornNodes(base, kept, born, held);
|
||||
}
|
||||
|
||||
/** 일반측점 격자 + 끝 — `stationsAt` 없을 때(시험 · 단독 묶음). */
|
||||
const gridOf = (route: RouteLine): number[] => {
|
||||
const grid: number[] = [];
|
||||
@@ -176,10 +261,30 @@ export function createPlanlineStage(deps: PlanlineDeps): StageFn<"planline"> {
|
||||
const savedLine = deps.savedLine();
|
||||
const span = savedLine ? changedSpan(xy(savedLine), xy(route.line)) : null;
|
||||
const savedPvi = deps.storedPvi?.() ?? saved.basePvi;
|
||||
const carried = span
|
||||
? carryBase(xy(savedLine!), xy(route.line), savedPvi, groundPvi)
|
||||
: savedPvi;
|
||||
// 옮긴 시설 측점에 427 꼭짓점 다시 세움(459 · 서버 `_carry_profile` 짝 · 사용자가 옮긴 것은 즉시)
|
||||
const followed = ctx.out.follow;
|
||||
const moved = new Set(followed ? movedIds.get(followed) : []);
|
||||
if (followed) userMoved(ctx.inputs, followed).forEach((id) => moved.add(id));
|
||||
const born = savedLine
|
||||
? routeMovedStations(ground.stations, xy(savedLine), xy(route.line), moved)
|
||||
: [];
|
||||
const held = Object.keys(ctx.inputs.alignment?.station_offsets ?? {}).map(Number);
|
||||
return {
|
||||
policy: saved.policy, // 옛 기본 L 이어받음(서버 `carry_policy` 짝)
|
||||
onlyExplicitCurves: saved.onlyExplicitCurves,
|
||||
basePvi: span ? carryBase(xy(savedLine!), xy(route.line), savedPvi, groundPvi) : savedPvi,
|
||||
basePvi: seatRouteMoved(
|
||||
{
|
||||
chainage: profile.chainage,
|
||||
ground: profile.ground,
|
||||
policy: saved.policy,
|
||||
basePvi: carried,
|
||||
},
|
||||
born,
|
||||
held,
|
||||
),
|
||||
chainage: profile.chainage,
|
||||
ground: profile.ground,
|
||||
stations: ground.stations.map((station) => ({
|
||||
|
||||
@@ -235,6 +235,8 @@ export interface StationsOptions {
|
||||
types: ReadonlyArray<PlanType>;
|
||||
/** 종류 → 옵션 기본값(카드 이름) — `planStations` 그대로. */
|
||||
registry?: Readonly<Record<string, Record<string, unknown>>>;
|
||||
/** 추가 측점(정수 m) — 없으면 `planStations` 기본(전역 `plusStations()`). */
|
||||
plus?: ReadonlyArray<number>;
|
||||
}
|
||||
|
||||
/** 원래 격자 번호 — 스냅으로 옮긴 측점은 사라진 원래 격자 자리 중 가장 가까운 번호를 이어받음. */
|
||||
@@ -299,7 +301,13 @@ export function buildStations(
|
||||
// 심는 측점(관 · 구조물) — 격자 0.005 m 안이면 격자 자리에 얹고 서로 겹치면 앞선 것만(서버 `extras`).
|
||||
const pipeIds = new Set(pipes.map((pipe) => pipe.id ?? `pipe-${pipe.chainage_m.toFixed(2)}`));
|
||||
const planted = new Map<number, { kind: "pipe" | "structure"; id: string; role: string }>();
|
||||
for (const station of planStations(pipes, structures, options.types, options.registry)) {
|
||||
for (const station of planStations(
|
||||
pipes,
|
||||
structures,
|
||||
options.types,
|
||||
options.registry,
|
||||
options.plus,
|
||||
)) {
|
||||
if (station.chainage_m < 0 || station.chainage_m > total) continue;
|
||||
const near = grid.find((value) => Math.abs(value - station.chainage_m) < SNAP_M);
|
||||
const at = near ?? station.chainage_m;
|
||||
|
||||
@@ -13,6 +13,8 @@
|
||||
* 아니면 누가거리의 노선 위 자리 · 410) · 거울 시험 = resources/tester/test_410_follow_xy.py.
|
||||
* - `placeOnLine` 의 좌표 없는 관 · `rebasePipeDraft` 는 TS 만 — 좌표 없는 세션 관 초안(정본 PUT 꼴)을 따라가기
|
||||
* 전 자리로 · 노선을 저장한 [저장]이 서버가 따라 옮긴 관 위에 초안을 얹음(349-0u).
|
||||
* - `rebaseStructureDraft` 도 TS 만 — 구조물 초안을 서버가 따라 옮긴 정본 위에(459 · 관 짝).
|
||||
* - `followPlusStations` ↔ 파이썬 `B05_Profile_Plus_Stations.follow_plus_stations`(459 · 노선 초안 동안).
|
||||
* 거울 시험 = resources/tester/test_344_2_route_pairs.py.
|
||||
* ========================================================================== */
|
||||
|
||||
@@ -142,6 +144,66 @@ export function rebasePipeDraft<P extends FollowingPoint & { id?: string | null
|
||||
return [...kept, ...added].sort((a, b) => a.chainage_m - b.chainage_m);
|
||||
}
|
||||
|
||||
type StructureLike = FollowingStructure & { structure_id?: string | null };
|
||||
|
||||
/** 자리 셋(기준 · 구간 끝)이 같은가 — `shift` 만큼 민 자리로 견줌(구간 길이 = 시설 치수). */
|
||||
const samePositions = (a: FollowingStructure, b: FollowingStructure, shift = 0): boolean =>
|
||||
(["chainage_m", "start_m", "end_m"] as const).every(
|
||||
(key) =>
|
||||
(a[key] == null) === (b[key] == null) &&
|
||||
Math.abs((a[key] ?? 0) + shift - (b[key] ?? 0)) <= SAME_STATION_M,
|
||||
);
|
||||
|
||||
/** 노선을 저장한 [저장] — 세션 구조물 초안을 서버가 따라 옮긴 정본(`after`) 위에 얹은 목록(459 · 관
|
||||
* `rebasePipeDraft` 짝). 따라가기만 한 것(자리 = 노선 저장 전 정본 `before` 그대로 · 또는 땅 위 자리가 같고
|
||||
* 기준 누가거리가 서버 따라감과 같음)은 서버 자리(기준 · 구간 끝 · 좌표) · 사용자가 옮긴 것(마커 · 폼 =
|
||||
* 누가거리만 옮겨 좌표 낡음 포함) · 새로 놓은 것은 초안 그대로 · 옵션 · 메모는 늘 초안 · 짝은 `structure_id`. */
|
||||
export function rebaseStructureDraft<S extends StructureLike>(
|
||||
draft: ReadonlyArray<S>,
|
||||
before: ReadonlyArray<StructureLike>,
|
||||
after: ReadonlyArray<StructureLike>,
|
||||
): S[] {
|
||||
const old = new Map(before.map((item) => [item.structure_id, item]));
|
||||
const moved = new Map(after.map((item) => [item.structure_id, item]));
|
||||
return draft.map((item) => {
|
||||
const was = item.structure_id ? old.get(item.structure_id) : undefined;
|
||||
const now = item.structure_id ? moved.get(item.structure_id) : undefined;
|
||||
if (!was || !now) return item;
|
||||
const followed =
|
||||
item.x != null &&
|
||||
item.y != null &&
|
||||
was.x != null &&
|
||||
was.y != null &&
|
||||
Math.hypot(item.x - was.x, item.y - was.y) <= FOLLOW_SAME_XY_M &&
|
||||
samePositions(was, item, (item.chainage_m ?? 0) - (was.chainage_m ?? 0)) &&
|
||||
Math.abs((item.chainage_m ?? 0) - (now.chainage_m ?? 0)) <= SAME_STATION_M;
|
||||
if (!followed && !samePositions(item, was)) return item;
|
||||
return {
|
||||
...item,
|
||||
chainage_m: now.chainage_m ?? item.chainage_m,
|
||||
start_m: now.start_m ?? null,
|
||||
end_m: now.end_m ?? null,
|
||||
x: now.x ?? item.x,
|
||||
y: now.y ?? item.y,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
/** 추가 측점이 노선을 따라감(459) — 옛 선(`from`) 위 누가거리의 땅 위 자리를 새 선(`to`)에 투영한 정수 m
|
||||
* (같은 m 하나 · 오름차순). 파이썬 `follow_plus_stations` 짝(`follow_structures` 같은 셈). */
|
||||
export function followPlusStations(
|
||||
list: ReadonlyArray<number>,
|
||||
from: ReadonlyArray<XY>,
|
||||
to: ReadonlyArray<XY>,
|
||||
): number[] {
|
||||
const placed = placeOnLine(
|
||||
list.map((chainage_m) => ({ chainage_m })),
|
||||
from,
|
||||
);
|
||||
const moved = followStructures(placed, to).map(({ item }) => item.chainage_m!);
|
||||
return [...new Set(moved)].sort((a, b) => a - b);
|
||||
}
|
||||
|
||||
/** 따라가기만 한 관 = 정본과 같은 땅 위 자리(m) — 서버 좌표가 mm 3자리라 그 반올림 폭. */
|
||||
const FOLLOW_SAME_XY_M = 0.001;
|
||||
|
||||
|
||||
@@ -37,9 +37,17 @@ const ground = loadTs("B05_Profile/logic/B05_Profile_Grade_Ground.ts", {
|
||||
"./B05_Profile_Profile_CurveLaw": curveLaw,
|
||||
"./B05_Profile_UI_Profile_Alignment": alignment,
|
||||
});
|
||||
const seat = loadTs("B05_Profile/logic/B05_Profile_Grade_Seat.ts", {
|
||||
"./B05_Profile_UI_Profile_Alignment": alignment,
|
||||
});
|
||||
const grade = loadTs("B05_Profile/logic/B05_Profile_Pipeline_Grade.ts", {
|
||||
"./B05_Profile_Profile_CurveLaw": curveLaw,
|
||||
"../../common_util/common_util_route_follow": {},
|
||||
"../../common_util/common_util_structure_station": {
|
||||
PLUS_OWNER_PREFIX: "plus-",
|
||||
SAME_STATION_M: 0.005,
|
||||
},
|
||||
"./B05_Profile_Grade_Seat": seat,
|
||||
"./B05_Profile_UI_Profile_Alignment": alignment,
|
||||
"./B05_Profile_Grade_Carry": carry,
|
||||
"./B05_Profile_Sections_Stations": { SECTION_STATION_INTERVAL_M: 20 },
|
||||
|
||||
@@ -71,6 +71,7 @@ STUBS["B04_PreProcess_Api_Fetch"] = {
|
||||
};
|
||||
STUBS["B05_Profile_Api_Structures"] = {
|
||||
flushPendingStructures: async () => failing("structures"),
|
||||
readPendingStructures: () => null, // 구조물 초안 없음 — 노선 저장 뒤 얹기(459) 안 돎
|
||||
};
|
||||
STUBS["B05_Profile_Api_PlusStations"] = { flushPlusStations: async () => undefined };
|
||||
STUBS["B05_Profile_Api_Fetch"] = {
|
||||
|
||||
@@ -0,0 +1,85 @@
|
||||
/* 459 노선 바뀜 — 진짜 TS(`logic/B05_Profile_Pipeline_Grade` 옮긴 시설 측점 · 427 세움 · planline 단계 ·
|
||||
* `common_util_route_follow` 구조물 맞춤 · 추가 측점 따라감)를 Node 로 돌림.
|
||||
* 입력 argv[2] JSON [{ op, ... }, …] · 표준출력 = 결과 한 벌씩.
|
||||
*/
|
||||
const fs = require("fs");
|
||||
const path = require("path");
|
||||
const ts = require(path.join(__dirname, "..", "..", "config", "node_modules", "typescript"));
|
||||
const CATALOG = require("./helper_322_catalog_module.cjs");
|
||||
|
||||
const ROOT = path.join(__dirname, "..", "..");
|
||||
const cache = new Map();
|
||||
|
||||
function loadTs(file) {
|
||||
const full = path.join(ROOT, file);
|
||||
if (cache.has(full)) return cache.get(full);
|
||||
const js = ts.transpileModule(fs.readFileSync(full, "utf8"), {
|
||||
compilerOptions: { module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ES2020 },
|
||||
}).outputText;
|
||||
const box = { exports: {} };
|
||||
cache.set(full, box.exports);
|
||||
const localRequire = (request) => {
|
||||
if (CATALOG.isCatalogRequest(request)) return CATALOG;
|
||||
if (request.endsWith(".json"))
|
||||
return JSON.parse(fs.readFileSync(path.join(path.dirname(full), request), "utf8"));
|
||||
if (request.startsWith(".")) return loadTs(path.join(path.dirname(file), `${request}.ts`));
|
||||
throw new Error(`stub 없음: ${request} (${file})`);
|
||||
};
|
||||
new Function("exports", "module", "require", js)(box.exports, box, localRequire);
|
||||
return box.exports;
|
||||
}
|
||||
|
||||
const grade = loadTs("B05_Profile/logic/B05_Profile_Pipeline_Grade.ts");
|
||||
const carry = loadTs("B05_Profile/logic/B05_Profile_Grade_Carry.ts");
|
||||
const follow = loadTs("common_util/common_util_route_follow.ts");
|
||||
const xy = (line) => line.map(([x, y]) => ({ x, y }));
|
||||
|
||||
const cases = JSON.parse(fs.readFileSync(process.argv[2], "utf8"));
|
||||
const out = cases.map((item) => {
|
||||
switch (item.op) {
|
||||
case "moved":
|
||||
return grade.routeMovedStations(
|
||||
item.stations,
|
||||
xy(item.oldXy),
|
||||
xy(item.newXy),
|
||||
new Set(item.moved ?? []),
|
||||
);
|
||||
case "planline": {
|
||||
// 계산 묶음 planline 단계 그대로 — 저장 기준(storedPvi) · 새 지반 · 측점 · follow 출력
|
||||
const stage = grade.createPlanlineStage({
|
||||
savedBase: () => item.base,
|
||||
savedLine: () => item.oldXy,
|
||||
storedPvi: () => item.base.basePvi,
|
||||
});
|
||||
const ctxOf = (stations, followed) => ({
|
||||
inputs: { route: {}, alignment: null, pipes: item.pipes ?? null, structures: null },
|
||||
out: {
|
||||
route: { line: item.newXy },
|
||||
ground: {
|
||||
stations: stations.map((s) => ({ ...s, ground: null })),
|
||||
longitudinal: { chainageM: item.chainage, z: item.ground },
|
||||
},
|
||||
follow: followed,
|
||||
},
|
||||
});
|
||||
const result = stage(ctxOf(item.stations, item.followed ?? null));
|
||||
// 시설 없는 같은 측점 = 옮겨 붙인 꼭짓점(427 세움 전) — 파이썬 `seat_route_moved` 입력
|
||||
const plain = stage(
|
||||
ctxOf(
|
||||
item.stations.map((s) => ({ ...s, kind: "grid", id: null })),
|
||||
null,
|
||||
),
|
||||
);
|
||||
return { basePvi: result.basePvi, carried: plain.basePvi };
|
||||
}
|
||||
case "carry":
|
||||
return carry.carryBase(xy(item.oldXy), xy(item.newXy), item.oldBase, item.groundPvi);
|
||||
case "rebase":
|
||||
return follow.rebaseStructureDraft(item.draft, item.before, item.after);
|
||||
case "plus":
|
||||
return follow.followPlusStations(item.list, xy(item.from), xy(item.to));
|
||||
default:
|
||||
throw new Error(`op 없음: ${item.op}`);
|
||||
}
|
||||
});
|
||||
process.stdout.write(JSON.stringify(out));
|
||||
@@ -59,9 +59,15 @@ const STUBS = {
|
||||
"../../B04_PreProcess/B04_PreProcess_Api_Fetch": {
|
||||
fetchDetailPipePoints: async () => ({ pipe_points: [] }),
|
||||
},
|
||||
"../../common_util/common_util_route_follow": { rebasePipeDraft: (draft) => draft },
|
||||
"../../common_util/common_util_route_follow": {
|
||||
rebasePipeDraft: (draft) => draft,
|
||||
rebaseStructureDraft: (draft) => draft,
|
||||
},
|
||||
"../../B05_Profile/B05_Profile_Api_Structures": {
|
||||
flushPendingStructures: () => record("structures", failStructures)(),
|
||||
readPendingStructures: () => null, // 구조물 초안 없음 — 노선 저장 뒤 얹기(459)는 안 돎
|
||||
writePendingStructures: () => undefined,
|
||||
fetchStructures: async () => ({ structures: [] }),
|
||||
},
|
||||
"../../B05_Profile/B05_Profile_Api_PlusStations": {
|
||||
flushPlusStations: () => record("plusStations")(),
|
||||
|
||||
@@ -0,0 +1,310 @@
|
||||
"""459 노선 바뀜(요구 270) — [저장] 구조물 맞춤 · 추가 측점 따라감 · 관 꼭짓점 세움 · 관 따라감 선.
|
||||
|
||||
- 옮긴 시설 측점(바뀐 구간 · 사용자가 옮김)에 427 꼭짓점 다시 세움 — 초안 planline(TS) ↔
|
||||
서버 `_carry_profile`(`route_moved_stations` · `seat_route_moved`) 같은 값 · 같은 자리로
|
||||
따라간 시설은 옛 높이.
|
||||
- [저장] 구조물 초안 = 서버 따라감 위에(`rebaseStructureDraft` · 관 `rebasePipeDraft` 짝).
|
||||
- 추가 측점 좌표 따라감 TS `followPlusStations` ↔ 파이썬 `follow_plus_stations`.
|
||||
- 서버 관 따라감 선 = 구조물과 같은 `points`(사용 범위 자름) · 좌표 없는 옛 관은 옛 노선
|
||||
자리 먼저.
|
||||
helper_459_route_seat.cjs 가 진짜 TS 를 Node 로 돌림.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
import math
|
||||
import subprocess
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
import numpy as np
|
||||
import pytest
|
||||
|
||||
HERE = Path(__file__).resolve().parent
|
||||
ROOT = HERE.parents[1]
|
||||
if str(ROOT) not in sys.path:
|
||||
sys.path.insert(0, str(ROOT))
|
||||
|
||||
from B05_Profile.B05_Profile_Engine_Grade import GradeDesignOptions # noqa: E402
|
||||
from B05_Profile.B05_Profile_Engine_Grade_Alignment import ( # noqa: E402
|
||||
AlignmentPolicy,
|
||||
build_curves,
|
||||
evaluate,
|
||||
)
|
||||
from B05_Profile.B05_Profile_Engine_Grade_Profile import ( # noqa: E402
|
||||
design_ground_following_profile,
|
||||
)
|
||||
from B05_Profile.B05_Profile_Plus_Stations import ( # noqa: E402
|
||||
follow_plus_stations,
|
||||
plus_chainages,
|
||||
save_plus_stations,
|
||||
)
|
||||
from B05_Profile.B05_Profile_Route_Save_Parts import ( # noqa: E402
|
||||
route_moved_stations,
|
||||
seat_route_moved,
|
||||
)
|
||||
from common_util.common_util_drainage_pipes import PipePoint # noqa: E402
|
||||
|
||||
OLD_XY = [[0.0, 0.0], [200.0, 0.0], [400.0, 0.0]]
|
||||
NEW_XY = [[0.0, 0.0], [200.0, 0.0], [300.0, 60.0], [420.0, 60.0]]
|
||||
NEW_LEN = 200.0 + math.hypot(100.0, 60.0) + 120.0
|
||||
|
||||
|
||||
def _old_ground(s: float) -> float:
|
||||
return 100.0 + 0.08 * s + 1.5 * math.sin(s / 7.0)
|
||||
|
||||
|
||||
def _new_ground(s: float) -> float:
|
||||
"""200 m 까지 옛 선과 같은 땅 · 뒤는 다른 땅(노선이 옮겨 감)."""
|
||||
if s <= 200.0:
|
||||
return _old_ground(s)
|
||||
return _old_ground(200.0) + 0.05 * (s - 200.0) + 0.8 * math.cos((s - 200.0) / 9.0)
|
||||
|
||||
|
||||
def _old_alignment() -> dict:
|
||||
samples = [
|
||||
{"chainage_m": float(s), "elevation_m": round(_old_ground(s), 6), "valid": True}
|
||||
for s in range(0, 401)
|
||||
]
|
||||
stations = [{"chainage_m": float(s), "kind": "regular"} for s in range(0, 401, 20)]
|
||||
stations.append({"chainage_m": 120.0, "kind": "irregular", "structure": "파형강관 D800"})
|
||||
stations.sort(key=lambda row: row["chainage_m"])
|
||||
options = GradeDesignOptions(
|
||||
max_grade_pct=14.0,
|
||||
max_reverse_grade_pct=5.0,
|
||||
min_vertical_radius_m=100.0,
|
||||
min_curve_length_m=20.0,
|
||||
min_tangent_length_m=20.0,
|
||||
vertical_curve_skip_delta_pct=0.5,
|
||||
design_speed_kph=20,
|
||||
)
|
||||
alignment, _ = design_ground_following_profile(
|
||||
{"samples": samples, "stations": stations}, options, station_interval_m=20.0
|
||||
)
|
||||
return alignment
|
||||
|
||||
|
||||
def _stations(pipe_at: float = 120.0) -> list[dict]:
|
||||
"""새 노선 측점(TS 꼴) — 격자 · 끝 · 관 둘 · 구조물 하나 · 추가 측점 하나."""
|
||||
rows = [{"chainageM": float(s), "kind": "grid", "id": None} for s in range(0, 421, 20)]
|
||||
rows.append({"chainageM": round(NEW_LEN, 3), "kind": "end", "id": None})
|
||||
rows += [
|
||||
{"chainageM": pipe_at, "kind": "pipe", "id": "p120"},
|
||||
{"chainageM": 251.0, "kind": "structure", "id": "s251"},
|
||||
{"chainageM": 333.0, "kind": "pipe", "id": "p333"},
|
||||
{"chainageM": 77.0, "kind": "structure", "id": "plus-77"},
|
||||
]
|
||||
return sorted(rows, key=lambda row: row["chainageM"])
|
||||
|
||||
|
||||
def _server_stations(stations: list[dict]) -> list[dict]:
|
||||
"""같은 측점(서버 종단 꼴) — 관 · 구조물 = `structure` 이름 · 추가 측점 = 빈 이름."""
|
||||
names = {"pipe": "파형강관 D800", "structure": "옹벽"}
|
||||
return [
|
||||
{
|
||||
"chainage_m": row["chainageM"],
|
||||
"kind": "irregular" if row["kind"] in names else "regular",
|
||||
"structure": "" if (row["id"] or "").startswith("plus-") else names.get(row["kind"]),
|
||||
}
|
||||
for row in stations
|
||||
]
|
||||
|
||||
|
||||
def _ts(tmp_path: Path, cases: list[dict]) -> list:
|
||||
source = tmp_path / "in459.json"
|
||||
source.write_text(json.dumps(cases, ensure_ascii=False), encoding="utf-8")
|
||||
proc = subprocess.run( # noqa: S603 — 고정 실행 파일
|
||||
["node", str(HERE / "helper_459_route_seat.cjs"), str(source)],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
encoding="utf-8",
|
||||
cwd=str(ROOT),
|
||||
check=False,
|
||||
)
|
||||
assert proc.returncode == 0, proc.stderr
|
||||
return json.loads(proc.stdout)
|
||||
|
||||
|
||||
def _base(alignment: dict) -> dict:
|
||||
return {
|
||||
"basePvi": alignment["base_pvi"],
|
||||
"chainage": [s["chainage_m"] for s in alignment["samples"]],
|
||||
"ground": [s["ground_elevation_m"] for s in alignment["samples"]],
|
||||
"policy": alignment["policy"],
|
||||
"onlyExplicitCurves": False,
|
||||
"stations": [],
|
||||
}
|
||||
|
||||
|
||||
def _planline_case(alignment: dict, stations: list[dict], **extra) -> dict:
|
||||
chainage = [float(s) for s in range(0, int(NEW_LEN) + 1)]
|
||||
return {
|
||||
"op": "planline",
|
||||
"base": _base(alignment),
|
||||
"oldXy": OLD_XY,
|
||||
"newXy": NEW_XY,
|
||||
"stations": stations,
|
||||
"chainage": chainage,
|
||||
"ground": [round(_new_ground(s), 6) for s in chainage],
|
||||
**extra,
|
||||
}
|
||||
|
||||
|
||||
def _seat_py(alignment: dict, carried: list[dict], born: list[float]) -> list[dict]:
|
||||
samples = [
|
||||
{"chainage_m": float(s), "ground_elevation_m": round(_new_ground(s), 6)}
|
||||
for s in range(0, int(NEW_LEN) + 1)
|
||||
]
|
||||
return seat_route_moved(
|
||||
{"samples": samples, "policy": alignment["policy"], "base_pvi": carried}, born
|
||||
)
|
||||
|
||||
|
||||
def _plan_at(nodes: list[dict], policy: dict, at: float) -> float:
|
||||
s = np.array([n["chainage_m"] for n in nodes], dtype=np.float64)
|
||||
z = np.array([n["elevation_m"] for n in nodes], dtype=np.float64)
|
||||
curves, _ = build_curves(s, z, AlignmentPolicy.from_dict(policy))
|
||||
return float(evaluate(s, z, curves, np.array([at]))[0])
|
||||
|
||||
|
||||
def _same_nodes(mine: list[dict], theirs: list[dict]) -> None:
|
||||
assert [n["chainage_m"] for n in mine] == pytest.approx([n["chainage_m"] for n in theirs])
|
||||
for a, b in zip(mine, theirs, strict=True):
|
||||
assert a["elevation_m"] == pytest.approx(b["elevation_m"], abs=1e-6), a["chainage_m"]
|
||||
|
||||
|
||||
def test_옮긴_시설_측점_TS_파이썬_같음(tmp_path: Path) -> None:
|
||||
stations = _stations()
|
||||
server = route_moved_stations(_server_stations(stations), OLD_XY, NEW_XY)
|
||||
plain, moved = _ts(
|
||||
tmp_path,
|
||||
[
|
||||
{"op": "moved", "stations": stations, "oldXy": OLD_XY, "newXy": NEW_XY},
|
||||
{
|
||||
"op": "moved",
|
||||
"stations": stations,
|
||||
"oldXy": OLD_XY,
|
||||
"newXy": NEW_XY,
|
||||
"moved": ["p120"],
|
||||
},
|
||||
],
|
||||
)
|
||||
assert server == plain == [251.0, 333.0] # 같은 자리 관 120 · 추가 측점 77 은 뺌
|
||||
assert moved == [120.0, 251.0, 333.0] # 사용자가 옮긴 관은 같은 자리여도 세움
|
||||
|
||||
|
||||
def test_초안_planline_은_서버와_같은_꼭짓점(tmp_path: Path) -> None:
|
||||
alignment = _old_alignment()
|
||||
[out] = _ts(tmp_path, [_planline_case(alignment, _stations())])
|
||||
server = _seat_py(alignment, out["carried"], [251.0, 333.0])
|
||||
_same_nodes(out["basePvi"], server)
|
||||
# 옮긴 시설 측점 계획선 = 새 원지반(옛 계획선이 지반 종단) · 같은 자리 관 120 = 옛 높이
|
||||
for at in (251.0, 333.0):
|
||||
assert _plan_at(out["basePvi"], alignment["policy"], at) == pytest.approx(
|
||||
_new_ground(at), abs=1e-4
|
||||
)
|
||||
old = {n["chainage_m"]: n["elevation_m"] for n in out["carried"]}
|
||||
new = {n["chainage_m"]: n["elevation_m"] for n in out["basePvi"]}
|
||||
assert new[120.0] == pytest.approx(old[120.0], abs=1e-9)
|
||||
|
||||
|
||||
def test_노선_초안_중_마커로_옮긴_관도_즉시_세움(tmp_path: Path) -> None:
|
||||
alignment = _old_alignment()
|
||||
case = _planline_case(
|
||||
alignment,
|
||||
_stations(pipe_at=125.0),
|
||||
pipes=[{"id": "p120", "chainage_m": 125.0}],
|
||||
followed={"pipes": [{"id": "p120", "chainage_m": 120.0}]},
|
||||
)
|
||||
[out] = _ts(tmp_path, [case])
|
||||
server = _seat_py(alignment, out["carried"], [125.0, 251.0, 333.0])
|
||||
_same_nodes(out["basePvi"], server)
|
||||
assert _plan_at(out["basePvi"], alignment["policy"], 125.0) == pytest.approx(
|
||||
_old_ground(125.0), abs=1e-4
|
||||
)
|
||||
|
||||
|
||||
def test_구조물_초안은_서버_따라감_위에(tmp_path: Path) -> None:
|
||||
before = [
|
||||
{"structure_id": "a", "chainage_m": 100.0, "x": 100.0, "y": 0.0}, # 따라가기만(브라우저)
|
||||
{"structure_id": "b", "chainage_m": 150.0, "x": 150.0, "y": 0.0}, # 안 따라감(옛 판)
|
||||
{"structure_id": "c", "chainage_m": 180.0, "x": 180.0, "y": 0.0}, # 마커로 옮김(좌표 낡음)
|
||||
{
|
||||
"structure_id": "d",
|
||||
"chainage_m": 300.0,
|
||||
"start_m": 295.0,
|
||||
"end_m": 305.0,
|
||||
"x": 300.0,
|
||||
"y": 0.0,
|
||||
},
|
||||
]
|
||||
after = [
|
||||
{"structure_id": "a", "chainage_m": 110.0, "x": 100.0, "y": 0.0},
|
||||
{"structure_id": "b", "chainage_m": 160.0, "x": 150.0, "y": 0.0},
|
||||
{"structure_id": "c", "chainage_m": 190.0, "x": 180.0, "y": 0.0},
|
||||
{
|
||||
"structure_id": "d",
|
||||
"chainage_m": 310.0,
|
||||
"start_m": 305.0,
|
||||
"end_m": 315.0,
|
||||
"x": 300.0,
|
||||
"y": 0.0,
|
||||
},
|
||||
]
|
||||
draft = [
|
||||
{"structure_id": "a", "chainage_m": 110.0, "x": 100.0, "y": 0.0, "memo": "옵션"},
|
||||
{"structure_id": "b", "chainage_m": 150.0, "x": 150.0, "y": 0.0},
|
||||
{"structure_id": "c", "chainage_m": 197.0, "x": 180.0, "y": 0.0},
|
||||
# 구간 끝만 늘림(따라간 뒤) — 사용자 고침 = 초안 그대로
|
||||
{
|
||||
"structure_id": "d",
|
||||
"chainage_m": 310.0,
|
||||
"start_m": 305.0,
|
||||
"end_m": 320.0,
|
||||
"x": 300.0,
|
||||
"y": 0.0,
|
||||
},
|
||||
{"structure_id": "e", "chainage_m": 50.0}, # 새로 놓음
|
||||
]
|
||||
[out] = _ts(tmp_path, [{"op": "rebase", "draft": draft, "before": before, "after": after}])
|
||||
by = {row["structure_id"]: row for row in out}
|
||||
assert by["a"]["chainage_m"] == 110.0 and by["a"]["memo"] == "옵션"
|
||||
assert by["b"]["chainage_m"] == 160.0 # 옛 판 = 서버 따라감
|
||||
assert by["c"]["chainage_m"] == 197.0 # 사용자가 옮긴 자리 지킴
|
||||
assert (by["d"]["start_m"], by["d"]["end_m"]) == (305.0, 320.0)
|
||||
assert by["e"] == {"structure_id": "e", "chainage_m": 50.0}
|
||||
|
||||
|
||||
@pytest.mark.parametrize("chainages", [[30.0, 120.0, 250.0, 390.0], [199.0, 200.0, 201.0]])
|
||||
def test_추가_측점_따라감_TS_파이썬_같음(tmp_path: Path, chainages: list[float]) -> None:
|
||||
save_plus_stations(tmp_path, chainages, OLD_XY)
|
||||
follow_plus_stations(tmp_path, NEW_XY)
|
||||
[mine] = _ts(tmp_path, [{"op": "plus", "list": chainages, "from": OLD_XY, "to": NEW_XY}])
|
||||
assert mine == plus_chainages(tmp_path)
|
||||
|
||||
|
||||
def test_관_따라감_선은_사용_범위_자른_points_좌표_없는_옛_관도(monkeypatch) -> None:
|
||||
"""B04 노선(범위 안 자름 · 0 m 부터) 대신 `points`(50 m 부터) · 좌표 없는 옛 관 = 옛
|
||||
노선 자리로 채운 뒤."""
|
||||
import B04_PreProcess.B04_PreProcess_Router_Watershed as watershed
|
||||
import common_util.common_util_drainage_pipes as drainage
|
||||
from B05_Profile import B05_Profile_Route_Save as route_save
|
||||
from B05_Profile.B05_Profile_Route_Save_Parts import vertices_of
|
||||
|
||||
async def prepared(_project_id):
|
||||
return {"vertices": vertices_of([(0.0, 0.0), (400.0, 0.0)]), "streams": []}
|
||||
|
||||
saved: list = []
|
||||
monkeypatch.setattr(watershed, "_prepare", prepared)
|
||||
monkeypatch.setattr(drainage, "save_pipe_points", lambda *args: saved.append(args[2]))
|
||||
old = [
|
||||
PipePoint(chainage_m=100.0, x=150.0, y=0.0),
|
||||
PipePoint(chainage_m=200.0), # 좌표 없는 옛 관
|
||||
]
|
||||
points = [{"x": 40.0, "y": 0.0}, {"x": 350.0, "y": 0.0}] # 시점 10 m 당김
|
||||
old_xy = [[50.0, 0.0], [350.0, 0.0]]
|
||||
pairs, _ = asyncio.run(route_save._place_pipes("p", "s", old, old_xy, points))
|
||||
assert [pipe.chainage_m for _, pipe in pairs] == [110.0, 210.0]
|
||||
assert [pipe.chainage_m for pipe in saved[0]] == [110.0, 210.0]
|
||||
Reference in New Issue
Block a user