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:
@@ -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());
|
||||
|
||||
@@ -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[]>>();
|
||||
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
Reference in New Issue
Block a user