fix(493-3 화면 ↔ 저장 자리): 추가 측점 = 끌기 동안 땅 위 좌표를 들고 따라감 — 걸음마다 정수 m 에서 다시 놓아 [저장] 자리와 2~4 m 어긋나던 것

- 원인 = 노선 초안 follow 가 추가 측점을 누가거리만 들고 걸음마다 앞 선 위 정수 m 자리에서 다시 투영(반올림 누적) · 서버 [저장]은 정본 좌표에서 한 번 · 관 · 구조물은 좌표를 들고 가 차 0
- followPlusGround(common_util_route_follow) — 같은 누가거리 저장분 · 앞 걸음 좌표에서 따라감 · follow 단계가 걸음 출력마다 좌표 줄을 기억(B05_Profile_Pipeline_Grade)
- 첫 걸음 = 서버 정본 좌표 — GET plus-stations 에 stations(누가거리 · x · y) · 화면 savedPlusRows · 초안 둔 채 열기 = 초안 노선 자리
- 거울 시험 test_493_3_follow_screen_save(직선 · 곡선 교점 · R · 교점 더함 · 지움 · 앞 교점 여러 걸음 오감 = 관 · 구조물 · 추가 측점 화면 자리 = 서버 자리 · 고치기 전 셈 대조 = 2 m 넘게 어긋남)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-20 09:45:41 +09:00
co-authored by Claude Opus 5.5
parent c96783e512
commit 2b7b129566
9 changed files with 384 additions and 19 deletions
@@ -58,6 +58,7 @@ import {
followPlusDraft,
plusStationsOf,
readPlusDraft,
savedPlusRows,
} from "../../B05_Profile/B05_Profile_Api_PlusStations";
import { pageReplaying } from "../../B05_Profile/B05_Profile_UI_Profile_History";
import {
@@ -258,6 +259,7 @@ function createPagePipeline(
const crossStage = createCrossStage({ grid: groundDeps.grid }); // 미교차 넓힘 = 같은 지형 격자(349-0j)
const followStage = createFollowStage({
savedLine,
savedPlus: () => savedPlusRows(projectId), // 추가 측점 정본 좌표에서 따라감(493-3)
saved: () => {
const before = beforeDraft ?? facilitiesNow();
return {
@@ -11,8 +11,11 @@ import { API_BASE_URL, API_TIMEOUT_MS } from "../config/config_frontend";
import { draftProjectId, useDesignStore } from "../A00_Common/design/design_store";
import { onPageHistory } from "./B05_Profile_UI_Profile_History";
import type { SavePart } from "../A00_Common/design/design_save_api";
import type { FollowingStructure } from "../common_util/common_util_route_follow";
const saved = new Map<string, number[]>();
/** 저장분 땅 위 자리(누가거리 · x · y) — 노선 초안 동안 이 좌표에서 따라감(493-3). */
const savedRows = new Map<string, FollowingStructure[]>();
const loaded = new Map<string, Promise<void>>();
async function request(projectId: string, init: RequestInit = {}): Promise<number[]> {
@@ -28,12 +31,17 @@ async function request(projectId: string, init: RequestInit = {}): Promise<numbe
const payload = await response.json().catch(() => null);
if (!response.ok)
throw new Error(payload?.message ?? `요청이 실패했습니다 (${response.status}).`);
if (Array.isArray(payload?.stations)) savedRows.set(projectId, payload.stations);
return Array.isArray(payload?.chainages) ? payload.chainages.map(Number) : [];
} finally {
window.clearTimeout(timer);
}
}
/** 저장분 추가 측점 땅 위 자리 — 좌표를 못 받았으면 누가거리만(노선 위 자리로 놓음). */
export const savedPlusRows = (projectId: string): FollowingStructure[] =>
savedRows.get(projectId) ?? (saved.get(projectId) ?? []).map((chainage_m) => ({ chainage_m }));
export const readPlusDraft = (projectId: string): number[] | null =>
useDesignStore(projectId, null).get("plusStations");
@@ -138,6 +146,7 @@ export function plusStationsPart(projectId: string): SavePart {
body: draft ? { chainages: draft } : null,
after: async (response) => {
const given = response?.chainages;
if (Array.isArray(given)) savedRows.delete(projectId); // 고쳐 쓴 저장분 = 노선 위 자리(누가거리로 놓음)
const list = Array.isArray(given) ? given.map(Number) : await request(projectId);
saved.set(projectId, list);
loaded.set(projectId, Promise.resolve());
+2
View File
@@ -3,6 +3,8 @@
사용자가 종단 원지반선에서 넣은 일반 측점(정수 m) · 이름 없음. 자리 정본 = 좌표(x · y) — 노선이
바뀌면 관 · 구조물처럼 새 노선에 투영해 누가거리를 다시 매김(TS `followPlusRows` · Node 입구).
측점 계획에는 `resolve_extra_stations` → `plan_stations(plus=)` 로 들어감(TS `planStations` 짝).
화면은 노선 초안 동안 이 좌표(GET `stations`)를 들고 따라감(TS `followPlusGround` · 493-3) — 거울 시험 =
`resources/tester/test_493_3_follow_screen_save.py`.
"""
from __future__ import annotations
@@ -5,7 +5,7 @@
*
* follow — 관 · 구조물 좌표 따라감(`common_util_route_follow`) · 계획고 편집 옮김(`Grade_Carry`
* `carryEdits` · 옛 노선 `before.route` 가 있을 때만 · 기준 = 마지막 종단 편집 때 한 벌 · 400) ·
* 추가 측점 좌표 따라감(459 · `followPlusStations`).
* 추가 측점 좌표 따라감(459 · `followPlusGround` · 좌표 들고 감 = 서버 [저장] 자리 · 493-3).
* planline — 노선 초안이 없으면 저장 종단 기준 그대로 · 있으면 새 측점마다 같은 자리는 저장 꼭짓점
* 높이 · 바뀐 자리만 새 지반 첫 계획선(`Grade_Ground` · `carryBase`) · 옮긴 시설 측점(바뀐 자리 ·
* 사용자가 옮김)에 427 꼭짓점 다시 세움(459 · 짝 = 파이썬 `B05_Profile_Route_Save_Parts`
@@ -25,7 +25,7 @@ import type {
Vertex,
} from "../../A00_Common/design/logic/design_pipeline_types";
import {
followPlusStations,
followPlusGround,
followStructures,
placeOnLine,
projectPipePoints,
@@ -112,6 +112,8 @@ interface GradeRef {
export function createFollowStage(deps: {
savedLine(): Vertex[] | null;
saved?(): FollowPatch;
/** 저장분 추가 측점 땅 위 자리(누가거리 · x · y) — 첫 걸음이 서버 정본 좌표에서 따라감(493-3). */
savedPlus?(): ReadonlyArray<FollowingStructure>;
/** 새 노선 측점 누가거리(stations 단계와 같은 셈) — 없으면 일반측점 격자. */
stationsAt?(route: RouteLine, patch: FollowPatch, inputs: PipelineInputs): number[];
}): StageFn<"follow"> {
@@ -134,9 +136,19 @@ 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);
// 추가 측점도 땅 위 자리 따라감(459 · 서버 `follow_plus_stations` 짝) — 가게는 부르는 쪽이 씀 · 좌표 =
// 앞 걸음 따라감 · 없으면 저장분(493-3 · 걸음마다 정수 m 에서 다시 놓으면 밀림)
if (plusStations?.length) {
const saved = deps.savedPlus?.() ?? [];
// 초안 둔 채 열기(앞 노선 없음)의 초안 목록 = 이미 초안 노선 자리(관 · 구조물 `frame` 과 같은 셈)
const onDraft =
!ctx.before.route &&
JSON.stringify(plusStations) !== JSON.stringify(saved.map((row) => row.chainage_m));
const ground = onDraft ? [] : ((ctx.before.follow && plusAt.get(ctx.before.follow)) ?? saved);
const rows = followPlusGround(plusStations, onDraft ? line : xy(before), line, ground);
patch.plusStations = rows.map((row) => row.chainage_m!);
plusAt.set(patch, rows);
}
// 노선 초안 전 관 · 구조물이 따라가기만 한 자리 — planline 이 사용자가 옮긴 것을 가름(459 · 427)
const origin = deps.saved?.();
const home = deps.savedLine();
@@ -162,6 +174,9 @@ export function createFollowStage(deps: {
/** 같은 꼭짓점 자리(m) — `seatBornNodes` · 파이썬 `_SAME_STATION_M` 과 같은 0.006. */
const SEAT_SAME_M = 0.006;
/** follow 출력마다 추가 측점 땅 위 자리(누가거리 · x · y) — 다음 걸음이 이 좌표에서 따라감(493-3). */
const plusAt = new WeakMap<object, FollowingStructure[]>();
/** follow 출력마다 따라가기만 한 자리(id → 자리 누가거리들) — planline 이 427 세움에 씀(459). */
const followedAt = new WeakMap<object, ReadonlyMap<string, number[]>>();
+6 -4
View File
@@ -422,12 +422,14 @@ class PlusStationsRequest(BaseModel):
@router.get("/{project_id}/section/plus-stations")
async def get_plus_stations(project_id: UUID) -> JSONResponse:
"""추가 측점 정본(누가거리 목록) — 없으면 빈 목록."""
from B05_Profile.B05_Profile_Plus_Stations import plus_chainages
"""추가 측점 정본(누가거리 목록 · 땅 위 자리 `stations` = 누가거리 · x · y) — 없으면 빈 목록.
화면이 노선 초안 동안 이 좌표에서 따라감(493-3 · 서버 [저장] 따라감과 같은 자리)."""
from B05_Profile.B05_Profile_Plus_Stations import load_plus_stations
paths = await _project_paths(project_id)
chainages = await asyncio.to_thread(plus_chainages, paths[0]) if paths else []
return JSONResponse(content={"status": "success", "chainages": chainages})
rows = await asyncio.to_thread(load_plus_stations, paths[0]) if paths else []
chainages = sorted({float(row["chainage_m"]) for row in rows})
return JSONResponse(content={"status": "success", "chainages": chainages, "stations": rows})
@router.put("/{project_id}/section/plus-stations")
+2 -1
View File
@@ -13,7 +13,8 @@
짝 = TS `common_util/common_util_route_follow.ts` `followStructures`(구조물) · `projectPipePoints`
(관 — 파이썬 본체 = `common_util_drainage_pipes.project_pipe_points`) · `placeOnLine`(좌표 낡음
판정) · 거울 시험 = `resources/tester/test_344_2_route_pairs.py` · `test_410_follow_xy.py`.
판정) · 거울 시험 = `resources/tester/test_344_2_route_pairs.py` · `test_410_follow_xy.py` ·
끌기 여러 걸음 화면 자리 = [저장] 한 번 자리(관 · 구조물 · 추가 측점) = `test_493_3_follow_screen_save.py`.
"""
from __future__ import annotations
+29 -9
View File
@@ -15,7 +15,10 @@
* 전 자리로 · 노선을 저장한 [저장]이 서버가 따라 옮긴 관 위에 초안을 얹음(349-0u).
* - `rebaseStructureDraft` 도 TS 만 — 구조물 초안을 서버가 따라 옮긴 정본 위에(459 · 관 짝).
* - `followPlusRows` = 추가 측점 따라감 한 벌 — 서버도(Node 입구 `facility.plus_follow` · 472-4 ①) ·
* `followPlusStations` = 노선 초안 동안(옛 선 위 자리에서).
* `followPlusGround` · `followPlusStations` = 노선 초안 동안(저장분 · 앞 걸음 좌표 들고 감 · 493-3).
* - 끌기 여러 걸음 화면 자리 = 서버 [저장] 한 번 자리(관 · 구조물 · 추가 측점 · 493-3) — 거울 시험 =
* resources/tester/test_493_3_follow_screen_save.py(짝 = `B05_Profile_Route_Save.followed_pipes` ·
* `B05_Profile_Structures_Router.followed_structures` · `B05_Profile_Plus_Stations.followed_plus_rows`).
* - `routeOutside` ↔ 파이썬 `route_outside`(459 · 노선 밖 = 측점 계획 · 알약에서 뺌 · 좌표 그대로) — 따라감이
* `outside: true` 를 붙임(파생값 · 저장 때 뺌 · 서버는 측점 세울 때 다시 셈).
* 거울 시험 = resources/tester/test_344_2_route_pairs.py.
@@ -235,20 +238,37 @@ export function followPlusRows<R extends FollowingStructure>(
.filter((item) => !seen.has(item.chainage_m!) && seen.add(item.chainage_m!));
}
/** 추가 측점이 노선을 따라감(459) — 옛 선(`from`) 위 누가거리의 땅 위 자리를 새 선(`to`)에 투영한 정수 m
* (같은 m 하나 · 오름차순 · 노선 초안 동안 = 좌표가 아직 옛 선 위). */
export function followPlusStations(
/** 추가 측점이 노선을 따라감(459) — 땅 위 자리를 새 선(`to`)에 투영한 정수 m 줄(좌표 그대로 · 같은 m 하나 ·
* 오름차순). 땅 위 자리 = 같은 누가거리인 `ground`(저장분 · 앞 걸음 따라감)의 좌표 · 없으면 옛 선(`from`) 위
* 누가거리 자리. 걸음마다 정수 m 에서 다시 놓으면 끌기 걸음 수만큼 밀림(493-3 · 서버 = 정본 좌표에서 한 번). */
export function followPlusGround(
list: ReadonlyArray<number>,
from: ReadonlyArray<XY>,
to: ReadonlyArray<XY>,
): number[] {
const placed = placeOnLine(
list.map((chainage_m) => ({ chainage_m })),
from,
ground: ReadonlyArray<FollowingStructure> = [],
): FollowingStructure[] {
const held = new Map(
ground
.filter((row) => row.chainage_m != null && row.x != null && row.y != null)
.map((row) => [row.chainage_m!, row]),
);
return followPlusRows(placed, to).map((item) => item.chainage_m!);
const placed = list.map((chainage_m) => {
const was = held.get(chainage_m);
return was
? { chainage_m, x: was.x, y: was.y }
: placeOnLine([{ chainage_m } as FollowingStructure], from)[0];
});
return followPlusRows(placed, to);
}
/** `followPlusGround` 의 누가거리만. */
export const followPlusStations = (
list: ReadonlyArray<number>,
from: ReadonlyArray<XY>,
to: ReadonlyArray<XY>,
ground: ReadonlyArray<FollowingStructure> = [],
): number[] => followPlusGround(list, from, to, ground).map((item) => item.chainage_m!);
/** 따라가기만 한 관 = 정본과 같은 땅 위 자리(m) — 서버 좌표가 mm 3자리라 그 반올림 폭. */
const FOLLOW_SAME_XY_M = 0.001;
@@ -0,0 +1,58 @@
/* 493-3 화면 ↔ 저장 자리 거울 — 진짜 TS(`common_util_route_follow`)로 교점 끌기 여러 걸음을 브라우저처럼 돌림.
* 입력 argv[2] JSON = { lines: [L0(저장 노선), L1, …, Ln(마지막 끌기)], pipes, structures, plus, interval }.
* 걸음마다(브라우저 follow 단계 · design_pipeline_page.ts · B05_Profile_Pipeline_Grade.ts) 앞 선에서 자리
* 붙임(placeOnLine) → 새 선 따라감 · 추가 측점 = 누가거리 목록(세션) + 땅 위 자리(첫 걸음 = 저장분 `plus` 좌표 ·
* 다음 = 앞 걸음 줄 · `followPlusGround`) · `input.oldPlus` 면 고치기 전 셈(좌표 없이 걸음마다 다시 놓음).
* 표준출력 = 마지막 화면 값.
*/
const fs = require("fs");
const path = require("path");
const ts = require(path.join(__dirname, "..", "..", "config", "node_modules", "typescript"));
const ROOT = path.join(__dirname, "..", "..");
function loadTs(file, stubs = {}) {
const js = ts.transpileModule(fs.readFileSync(path.join(ROOT, file), "utf8"), {
compilerOptions: { module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ES2020 },
}).outputText;
const box = { exports: {} };
const localRequire = (request) => {
if (stubs[request]) return stubs[request];
throw new Error(`stub 없음: ${request} (${file})`);
};
new Function("exports", "module", "require", js)(box.exports, box, localRequire);
return box.exports;
}
const catalog = require("./helper_322_catalog_module.cjs");
const station = loadTs("common_util/common_util_structure_station.ts", {
"./common_util_structure_catalog": catalog,
});
const follow = loadTs("common_util/common_util_route_follow.ts", {
"./common_util_structure_station": station,
});
const input = JSON.parse(fs.readFileSync(process.argv[2], "utf8"));
const lines = input.lines.map((line) => line.map(([x, y]) => ({ x, y })));
const saved = input.pipes;
let pipes = input.pipes;
let structures = input.structures;
let plus = input.plus.map((row) => row.chainage_m);
let ground = input.plus;
let followed = [];
for (let i = 0; i + 1 < lines.length; i += 1) {
const placedPipes = follow.placeOnLine(pipes, lines[i], [...saved, ...followed]);
pipes = follow.projectPipePoints(placedPipes, lines[i + 1]);
followed = pipes;
const placed = follow.placeOnLine(structures, lines[i], [], input.interval);
structures = follow
.followStructures(placed, lines[i + 1], undefined, input.interval)
.map((moved) => moved.item);
if (input.oldPlus) {
plus = follow.followPlusStations(plus, lines[i], lines[i + 1]);
continue;
}
ground = follow.followPlusGround(plus, lines[i], lines[i + 1], ground);
plus = ground.map((row) => row.chainage_m);
}
process.stdout.write(JSON.stringify({ pipes, structures, plus }));
@@ -0,0 +1,256 @@
# -*- coding: utf-8 -*-
"""493-3 화면 ↔ 저장 자리 거울 — 교점 끌기 여러 걸음 뒤 화면 시설 자리 = [저장] 뒤 서버 자리.
브라우저(TS) = 걸음마다 앞 선에서 자리 붙임 → 새 선 따라감
(`helper_493_3_follow_steps.cjs`) · 서버(파이썬) = 저장 노선(L0)의 정본에서 마지막 선(Ln)으로 한 번
(`B05_Profile_Route_Save.followed_pipes` · `B05_Profile_Structures_Router.followed_structures` ·
`B05_Profile_Plus_Stations.followed_plus_rows` 와 같은 셈). 규칙(요구 290) = 관 · 구조물 · 추가
측점 = 땅 위 자리(x · y)에 가장 가까운 새 노선 자리.
경우 = 직선 · 곡선 R · 교점 더함 · 지움 · 노선 앞 교점 옮김(여러 걸음 · 오가며).
노선 = 서버 `build_planned_polyline`(브라우저 `draftLine` 짝 ·
test_route_polyline_browser_mirror.py) · 4자리(계획노선 csv · `draftLine` `xy4`).
"""
from __future__ import annotations
import copy
import json
import math
import subprocess
import sys
from pathlib import Path
from types import SimpleNamespace
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_Plus_Stations import followed_plus_rows # noqa: E402
from B05_Profile.B05_Profile_Route_Save_Parts import follow_pipes, vertices_of # noqa: E402
from common_util.common_util_drainage_pipes import ( # noqa: E402
PipePoint,
fill_pipe_coordinates,
)
from common_util.common_util_route_follow import ( # noqa: E402
fill_structure_coordinates,
follow_structures,
)
from common_util.common_util_route_polyline import build_planned_polyline # noqa: E402
from config.config_system import SECTION_STATION_INTERVAL_M # noqa: E402
pytestmark = pytest.mark.skipif(
not (ROOT / "config" / "node_modules" / "typescript").is_dir(),
reason="프론트엔드 의존성 미설치",
)
NODES = [
(0.0, 0.0),
(60.0, 8.0),
(110.0, 52.0),
(175.0, 58.0),
(215.0, 15.0),
(290.0, 22.0),
(330.0, 80.0),
]
RADII: list[float | None] = [None, 30.0, 12.0, 25.0, 12.0, 40.0, None]
def _line(nodes, radii=None) -> list[list[float]]:
radii = list(RADII if radii is None else radii)
built = build_planned_polyline(
list(nodes),
min_radius_m=12.0,
simplify=False,
curve_flags=[True] * len(nodes),
radii=radii,
)
return [[round(x, 4), round(y, 4)] for x, y in built.vertices]
def _at(line, chainage, offset=0.0) -> tuple[float, float]:
walked = 0.0
for (ax, ay), (bx, by) in zip(line, line[1:]):
span = math.hypot(bx - ax, by - ay)
if span > 0 and walked + span >= chainage:
t = (chainage - walked) / span
nx, ny = -(by - ay) / span, (bx - ax) / span
return ax + (bx - ax) * t + nx * offset, ay + (by - ay) * t + ny * offset
walked += span
return tuple(line[-1])
def _drag(seat: int, to: tuple[float, float], steps: int, radii=None, nodes=None):
"""교점 `seat` 를 `to` 만큼 여러 걸음으로 옮긴 선들(오가며 — 끝 앞에서 한 번 넘쳤다 돌아옴)."""
nodes = list(nodes or NODES)
x0, y0 = nodes[seat]
path = [k / steps for k in range(1, steps + 1)] + [1.15, 0.85, 1.0]
lines = []
for f in path:
moved = list(nodes)
moved[seat] = (x0 + to[0] * f, y0 + to[1] * f)
lines.append(_line(moved, radii))
return lines
L0 = _line(NODES)
CASES = {
# 노선 앞 교점 옮김 — 뒤 시설 누가거리가 걸음마다 바뀜(483 · 482 보고 「굽은 곳 2~7 m」)
"앞_교점_옮김": [L0, *_drag(1, (-9.0, 13.0), 12)],
"곡선_교점_옮김": [L0, *_drag(2, (7.0, -11.0), 9)],
"R_바꿈": [
L0,
_line(NODES, [None, 30.0, 35.0, 25.0, 12.0, 40.0, None]),
_line(NODES, [None, 30.0, 60.0, 25.0, 12.0, 40.0, None]),
],
"교점_더함": [
L0,
_line([*NODES[:2], (85.0, 20.0), *NODES[2:]], [*RADII[:2], 15.0, *RADII[2:]]),
],
"교점_지움": [L0, _line([*NODES[:2], *NODES[3:]], [*RADII[:2], *RADII[3:]])],
"직선": [L0, _line([NODES[0], (70.0, -4.0), NODES[-1]], [None, 30.0, None])],
}
# 저장 정본(L0 위) — 관 = 세류선 교차(노선 옆 0.2 ~ 0.4 m) · 구간 관 ·
# 구조물 = 점 · 구간 · 사토장 · 추가 측점 = 정수 m
PIPES = [
{"id": "p1", "chainage_m": 75.0, "x": _at(L0, 75.0, 0.3)[0], "y": _at(L0, 75.0, 0.3)[1]},
{"id": "p2", "chainage_m": 141.0, "x": _at(L0, 141.0, -0.25)[0], "y": _at(L0, 141.0, -0.25)[1]},
{
"id": "p3",
"chainage_m": 236.0,
"x": _at(L0, 236.0, 0.4)[0],
"y": _at(L0, 236.0, 0.4)[1],
"start_m": 233.0,
"end_m": 240.0,
},
{"id": "p4", "chainage_m": 330.0, "x": _at(L0, 330.0)[0], "y": _at(L0, 330.0)[1]},
]
STRUCTURES = [
{"structure_id": "s1", "placement": "point", "chainage_m": 128.0},
{
"structure_id": "s2",
"placement": "interval",
"chainage_m": 205.0,
"start_m": 198.0,
"end_m": 214.0,
},
{"structure_id": "s3", "placement": "site", "chainage_m": 280.0},
]
PLUS = [{"chainage_m": c} for c in (93.0, 157.0, 263.0, 301.0)]
# 앞 노선 [저장]이 따라 옮긴 추가 측점 — 좌표 = 옛 땅 자리(노선 옆 · 누가거리 소수 자리) ·
# 누가거리 = 정수 m
PLUS_FOLLOWED = {"chainage_m": 187.0, "at": (187.4, 0.8)}
def _saved_structures() -> list[dict]:
"""서버 정본 꼴 — 저장 때 좌표 채움(`fill_structure_coordinates` · L0)."""
items = [
SimpleNamespace(**{"start_m": None, "end_m": None, "x": None, "y": None, **s})
for s in STRUCTURES
]
fill_structure_coordinates(items, L0)
return [dict(vars(item)) for item in items]
def _saved_plus() -> list[dict]:
"""서버 정본 꼴(`save_plus_stations`) — 누가거리의 L0 위 좌표 4자리 · 따라 옮긴 줄은 옛 땅
자리."""
rows = []
for row in PLUS:
x, y = _at(L0, row["chainage_m"])
rows.append({"chainage_m": row["chainage_m"], "x": round(x, 4), "y": round(y, 4)})
x, y = _at(L0, *PLUS_FOLLOWED["at"])
rows.append({"chainage_m": PLUS_FOLLOWED["chainage_m"], "x": round(x, 4), "y": round(y, 4)})
return sorted(rows, key=lambda row: row["chainage_m"])
def _browser(lines, folder: Path, old_plus: bool = False) -> dict:
source = folder / "in.json"
source.write_text(
json.dumps(
{
"lines": lines,
"pipes": PIPES,
"structures": _saved_structures(),
"plus": _saved_plus(),
"interval": SECTION_STATION_INTERVAL_M,
"oldPlus": old_plus,
}
),
encoding="utf-8",
)
proc = subprocess.run( # noqa: S603 — 고정 실행 파일
["node", str(HERE / "helper_493_3_follow_steps.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 _server(lines) -> dict:
old, new = lines[0], lines[-1]
pipes = [PipePoint(**{k: v for k, v in p.items()}) for p in copy.deepcopy(PIPES)]
fill_pipe_coordinates(pipes, vertices_of(old))
moved = [pipe for _, pipe in follow_pipes(pipes, vertices_of(new))]
items = [SimpleNamespace(**s) for s in _saved_structures()]
fill_structure_coordinates(items, old, only_missing=True)
follow_structures(items, new)
plus = followed_plus_rows(_saved_plus(), new)["rows"]
return {
"pipes": [
{"id": p.id, "chainage_m": p.chainage_m, "start_m": p.start_m, "end_m": p.end_m}
for p in moved
],
"structures": [dict(vars(item)) for item in items],
"plus": [row["chainage_m"] for row in plus],
}
def _xy_gap(line, a, b) -> float:
"""두 누가거리의 새 선 위 자리 차(m) — 판정 = 0.01 m 안."""
(ax, ay), (bx, by) = _at(line, float(a)), _at(line, float(b))
return math.hypot(ax - bx, ay - by)
def _gaps(screen, server, new) -> list:
gaps = []
by_id = {p["id"]: p for p in screen["pipes"]}
for want in server["pipes"]:
got = by_id[want["id"]]
for key in ("chainage_m", "start_m", "end_m"):
if want[key] is not None:
gaps.append((f"관 {want['id']} {key}", got[key], want[key]))
by_sid = {s["structure_id"]: s for s in screen["structures"]}
for want in server["structures"]:
got = by_sid[want["structure_id"]]
for key in ("chainage_m", "start_m", "end_m"):
if want[key] is not None:
gaps.append((f"구조물 {want['structure_id']} {key}", got[key], want[key]))
assert len(screen["plus"]) == len(server["plus"]), (screen["plus"], server["plus"])
for got, want in zip(screen["plus"], server["plus"]):
gaps.append(("추가 측점", got, want))
return [(name, got, want) for name, got, want in gaps if _xy_gap(new, got, want) > 0.01]
@pytest.mark.parametrize("case", list(CASES))
def test_화면_자리_저장_자리_같음(case, tmp_path):
lines = CASES[case]
bad = _gaps(_browser(lines, tmp_path), _server(lines), lines[-1])
assert not bad, bad
def test_고치기_전_추가_측점은_걸음마다_밀림(tmp_path):
"""대조 — 좌표 없이 걸음마다 정수 m 에서 다시 놓던 셈(493-3 전)은 끌기 뒤 서버와 m 단위로
어긋남."""
lines = CASES["앞_교점_옮김"]
bad = _gaps(_browser(lines, tmp_path, old_plus=True), _server(lines), lines[-1])
assert bad and all(name == "추가 측점" for name, _, _ in bad)
assert max(abs(got - want) for _, got, want in bad) >= 2