From 2b7b12956601566996b2728175fda18ad6e00c6d Mon Sep 17 00:00:00 2001 From: umsangdon Date: Tue, 20 Oct 2026 09:45:41 +0900 Subject: [PATCH] =?UTF-8?q?fix(493-3=20=ED=99=94=EB=A9=B4=20=E2=86=94=20?= =?UTF-8?q?=EC=A0=80=EC=9E=A5=20=EC=9E=90=EB=A6=AC):=20=EC=B6=94=EA=B0=80?= =?UTF-8?q?=20=EC=B8=A1=EC=A0=90=20=3D=20=EB=81=8C=EA=B8=B0=20=EB=8F=99?= =?UTF-8?q?=EC=95=88=20=EB=95=85=20=EC=9C=84=20=EC=A2=8C=ED=91=9C=EB=A5=BC?= =?UTF-8?q?=20=EB=93=A4=EA=B3=A0=20=EB=94=B0=EB=9D=BC=EA=B0=90=20=E2=80=94?= =?UTF-8?q?=20=EA=B1=B8=EC=9D=8C=EB=A7=88=EB=8B=A4=20=EC=A0=95=EC=88=98=20?= =?UTF-8?q?m=20=EC=97=90=EC=84=9C=20=EB=8B=A4=EC=8B=9C=20=EB=86=93?= =?UTF-8?q?=EC=95=84=20[=EC=A0=80=EC=9E=A5]=20=EC=9E=90=EB=A6=AC=EC=99=80?= =?UTF-8?q?=202~4=20m=20=EC=96=B4=EA=B8=8B=EB=82=98=EB=8D=98=20=EA=B2=83?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 원인 = 노선 초안 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 --- A00_Common/design/design_pipeline_page.ts | 2 + B05_Profile/B05_Profile_Api_PlusStations.ts | 9 + B05_Profile/B05_Profile_Plus_Stations.py | 2 + .../logic/B05_Profile_Pipeline_Grade.ts | 25 +- B06_Section/B06_Section_Router_Stations.py | 10 +- common_util/common_util_route_follow.py | 3 +- common_util/common_util_route_follow.ts | 38 ++- .../tester/helper_493_3_follow_steps.cjs | 58 ++++ .../tester/test_493_3_follow_screen_save.py | 256 ++++++++++++++++++ 9 files changed, 384 insertions(+), 19 deletions(-) create mode 100644 resources/tester/helper_493_3_follow_steps.cjs create mode 100644 resources/tester/test_493_3_follow_screen_save.py diff --git a/A00_Common/design/design_pipeline_page.ts b/A00_Common/design/design_pipeline_page.ts index d5627c973..cf56a56db 100644 --- a/A00_Common/design/design_pipeline_page.ts +++ b/A00_Common/design/design_pipeline_page.ts @@ -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 { diff --git a/B05_Profile/B05_Profile_Api_PlusStations.ts b/B05_Profile/B05_Profile_Api_PlusStations.ts index 0ba88adc1..7fd50ba7b 100644 --- a/B05_Profile/B05_Profile_Api_PlusStations.ts +++ b/B05_Profile/B05_Profile_Api_PlusStations.ts @@ -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(); +/** 저장분 땅 위 자리(누가거리 · x · y) — 노선 초안 동안 이 좌표에서 따라감(493-3). */ +const savedRows = new Map(); const loaded = new Map>(); async function request(projectId: string, init: RequestInit = {}): Promise { @@ -28,12 +31,17 @@ async function request(projectId: string, init: RequestInit = {}): Promise 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()); diff --git a/B05_Profile/B05_Profile_Plus_Stations.py b/B05_Profile/B05_Profile_Plus_Stations.py index 911c93462..12c25ae8a 100644 --- a/B05_Profile/B05_Profile_Plus_Stations.py +++ b/B05_Profile/B05_Profile_Plus_Stations.py @@ -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 diff --git a/B05_Profile/logic/B05_Profile_Pipeline_Grade.ts b/B05_Profile/logic/B05_Profile_Pipeline_Grade.ts index 3587768b1..b7084ba0d 100644 --- a/B05_Profile/logic/B05_Profile_Pipeline_Grade.ts +++ b/B05_Profile/logic/B05_Profile_Pipeline_Grade.ts @@ -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; /** 새 노선 측점 누가거리(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(); + /** follow 출력마다 따라가기만 한 자리(id → 자리 누가거리들) — planline 이 427 세움에 씀(459). */ const followedAt = new WeakMap>(); diff --git a/B06_Section/B06_Section_Router_Stations.py b/B06_Section/B06_Section_Router_Stations.py index f3da5105a..8b1e13f83 100644 --- a/B06_Section/B06_Section_Router_Stations.py +++ b/B06_Section/B06_Section_Router_Stations.py @@ -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") diff --git a/common_util/common_util_route_follow.py b/common_util/common_util_route_follow.py index cf56c50bf..019f89b44 100644 --- a/common_util/common_util_route_follow.py +++ b/common_util/common_util_route_follow.py @@ -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 diff --git a/common_util/common_util_route_follow.ts b/common_util/common_util_route_follow.ts index 069a94345..9c5a8d063 100644 --- a/common_util/common_util_route_follow.ts +++ b/common_util/common_util_route_follow.ts @@ -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( .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, from: ReadonlyArray, to: ReadonlyArray, -): number[] { - const placed = placeOnLine( - list.map((chainage_m) => ({ chainage_m })), - from, + ground: ReadonlyArray = [], +): 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, + from: ReadonlyArray, + to: ReadonlyArray, + ground: ReadonlyArray = [], +): number[] => followPlusGround(list, from, to, ground).map((item) => item.chainage_m!); + /** 따라가기만 한 관 = 정본과 같은 땅 위 자리(m) — 서버 좌표가 mm 3자리라 그 반올림 폭. */ const FOLLOW_SAME_XY_M = 0.001; diff --git a/resources/tester/helper_493_3_follow_steps.cjs b/resources/tester/helper_493_3_follow_steps.cjs new file mode 100644 index 000000000..d2be8d33f --- /dev/null +++ b/resources/tester/helper_493_3_follow_steps.cjs @@ -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 })); diff --git a/resources/tester/test_493_3_follow_screen_save.py b/resources/tester/test_493_3_follow_screen_save.py new file mode 100644 index 000000000..1d1692836 --- /dev/null +++ b/resources/tester/test_493_3_follow_screen_save.py @@ -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