From 7aca51596ab78a40d2056f289ac7d03d5d215814 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Mon, 19 Oct 2026 14:23:23 +0900 Subject: [PATCH] =?UTF-8?q?fix(475-1=20=EB=85=B8=EC=84=A0=20=EC=9E=90?= =?UTF-8?q?=EB=A6=AC):=20=EB=85=B8=EC=84=A0=20=EC=84=A0=20=EC=86=8C?= =?UTF-8?q?=EC=88=98=20=EC=9E=90=EB=A6=AC=20=ED=95=9C=20=EB=B2=8C=20?= =?UTF-8?q?=E2=80=94=20=EC=84=9C=EB=B2=84=20=EB=85=B8=EC=84=A0=20geojson?= =?UTF-8?q?=20x=20=C2=B7=20y=204=EC=9E=90=EB=A6=AC(=3D=20=EA=B3=84?= =?UTF-8?q?=ED=9A=8D=EB=85=B8=EC=84=A0=20csv)=20=C2=B7=20=EB=B8=8C?= =?UTF-8?q?=EB=9D=BC=EC=9A=B0=EC=A0=80=20=EC=B4=88=EC=95=88=20=EA=B7=B8?= =?UTF-8?q?=EB=A6=B0=20=EC=84=A0=20=C2=B7=20=EA=B3=A1=EC=84=A0=ED=91=9C?= =?UTF-8?q?=EB=8F=84=20=EA=B0=99=EC=9D=80=20=EC=9E=90=EB=A6=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - B05_Profile_Engine._route_geojson x · y 3 → 4자리(z 3자리 그대로) · 짝 머리 표시 - draftLine 그린 선 x · y 4자리 · 곡선 start · apex · end 4자리 · R 3자리(= RouteCurve.as_dict) · 파이썬 round 짝(toFixed) - 교점 끌기 재현 브라우저 ↔ 서버 차 0.81 → 0.00 ㎥ · 옹벽 + 추가 측점 0.88 → 0.00 ㎥(test_475_1_route_digits) - 472-1 헬퍼 — 옮김 인자 · 페이지 배선(stationsAt · storedPvi) · 단면 모양 = 따라간 구조물(헛 차 덤 고침) · golden 값 그대로 - 옛 시험 두 곳(346_1) — 안 깎은 선 단언 → 서버 저장 자리 단언 - 관 지문 허용오차 주석 · calculations R1 줄 Co-Authored-By: Claude Opus 5.5 --- B05_Profile/B05_Profile_Engine.py | 8 +- .../logic/B05_Profile_Pipeline_RouteGround.ts | 20 +- common_util/common_util_drainage_pipes.py | 1 + .../wiki/concepts/design_page/calculations.md | 2 +- resources/tester/helper_472_1_browser.mjs | 47 ++++- .../tester/helper_472_1_browser_entry.ts | 3 + resources/tester/helper_472_1_paths.py | 18 +- resources/tester/helper_475_1_moved.py | 178 ++++++++++++++++++ resources/tester/test_346_1_pipeline_page.py | 3 +- .../tester/test_346_1_route_ground_stage.py | 18 +- resources/tester/test_475_1_route_digits.py | 92 +++++++++ 11 files changed, 360 insertions(+), 30 deletions(-) create mode 100644 resources/tester/helper_475_1_moved.py create mode 100644 resources/tester/test_475_1_route_digits.py diff --git a/B05_Profile/B05_Profile_Engine.py b/B05_Profile/B05_Profile_Engine.py index 875f38043..85d558daa 100644 --- a/B05_Profile/B05_Profile_Engine.py +++ b/B05_Profile/B05_Profile_Engine.py @@ -22,12 +22,16 @@ logger = logging.getLogger(__name__) def _route_geojson(polyline: list[list[float]]) -> dict[str, Any]: - """폴리라인을 3D LineString GeoJSON Feature로 변환한다.""" + """폴리라인을 3D LineString GeoJSON Feature로 변환한다. + + x · y 4자리 = 계획노선 csv(`write_route_csv`) · 브라우저 route 단계(`draftLine` · 짝 — + `test_475_1_route_digits.py`) — 같은 선이라야 저장 체인 ↔ 화면 누가토량이 같음(475-1). + """ return { "type": "Feature", "geometry": { "type": "LineString", - "coordinates": [[round(x, 3), round(y, 3), round(z, 3)] for x, y, z in polyline], + "coordinates": [[round(x, 4), round(y, 4), round(z, 3)] for x, y, z in polyline], }, "properties": {}, } diff --git a/B05_Profile/logic/B05_Profile_Pipeline_RouteGround.ts b/B05_Profile/logic/B05_Profile_Pipeline_RouteGround.ts index de95e6c63..3d19b2e53 100644 --- a/B05_Profile/logic/B05_Profile_Pipeline_RouteGround.ts +++ b/B05_Profile/logic/B05_Profile_Pipeline_RouteGround.ts @@ -3,7 +3,8 @@ * 실시간 계산 묶음(PLAN 346-1)의 **route · ground 단계** — 계약 = `design_pipeline_types.ts`. * * route — 노선 초안(RouteDraft) → 그린 노선(원호 포함) · 누가거리. 선은 이미 있는 짝 - * `buildEditedPolyline`(↔ `common_util_route_polyline.py`) 그대로. + * `buildEditedPolyline`(↔ `common_util_route_polyline.py`) 그대로 · 자릿수 = 서버 저장 선 + * (x · y 4자리 ↔ `_route_geojson` · `write_route_csv` · 475-1 · `test_475_1_route_digits.py`). * ground — 측점(지반 null) → 지반 채운 측점 · 종단 지반선(1 m). 표본 = 지형 격자 짝 * (`common_util_surface_sampler.ts` · `B05_Profile_Sections_Ground.ts`) · 격자가 없거나 * (아직 못 받음 · DTM 아님) 분석 지형 ≠ 설계 지형이면 주입받은 서버 표본(`remote`). @@ -54,8 +55,14 @@ export function routeLineOf(line: Vertex[], curves: DesignCurve[] = []): RouteLi return { line, curves, chainage, lengthM: chainage[chainage.length - 1] }; } +/** 파이썬 `round(v, n)` 짝 — 둘 다 double 의 정확한 값으로 반올림(4 · 3자리 동점 없음). */ +const roundTo = (value: number, digits: number): number => Number(value.toFixed(digits)); +const xy4 = ([x, y]: Vertex): Vertex => [roundTo(x, 4), roundTo(y, 4)]; + /** 초안 → 그린 노선 · 곡선표(계약 꼴). 길이를 붙든 곡선은 교각에 맞춰 R 을 다시 잡음(편집기 - * `markEdited` 와 같은 차례). */ + * `markEdited` 와 같은 차례). 자릿수 = 서버가 저장하는 선(475-1 · 짝 = 계획노선 csv + * `write_route_csv` · 곡선표 `RouteCurve.as_dict` · 노선 geojson `_route_geojson` x · y 4자리 · R 3자리 · + * `test_475_1_route_digits.py`). */ export function draftLine( draft: RouteDraft, minRadiusM: number, @@ -65,8 +72,13 @@ export function draftLine( applyArcLocks(nodes, [...draft.curveLock], [...draft.curveArc], radius); const built = buildEditedPolyline(nodes, draft.curveOn, radius, minRadiusM); return { - line: built.vertices, - curves: built.curves.map(({ start, apex, end, radius_m }) => ({ start, apex, end, radius_m })), + line: built.vertices.map(xy4), + curves: built.curves.map(({ start, apex, end, radius_m }) => ({ + start: xy4(start), + apex: xy4(apex), + end: xy4(end), + radius_m: roundTo(radius_m, 3), + })), }; } diff --git a/common_util/common_util_drainage_pipes.py b/common_util/common_util_drainage_pipes.py index 89f65283a..5fe991313 100644 --- a/common_util/common_util_drainage_pipes.py +++ b/common_util/common_util_drainage_pipes.py @@ -242,6 +242,7 @@ def fill_pipe_coordinates(points: list[PipePoint], vertices: list[RouteVertex]) # `planned_route.csv`(소수 4자리)와 `route_main.geojson`(소수 3자리, csv 를 mm 로 반올림한 # 사본)로 **0.5mm 다르게** 저장돼 있어, 그 0.5mm 가 `.xx5` 경계를 넘는 정점마다 글자가 # 바뀐다(2026-09-07 실측: 169개 중 **16개**). 노선을 손댄 적이 없는데도 지문이 늘 달랐다. +# 475-1 부터 geojson 도 4자리(새로 쓴 노선) — 옛 3자리 저장분이 남으므로 허용오차는 그대로. # # 경계에서 자르는 방식은 저장 자릿수가 또 바뀌면 다시 흔들리므로 **글자 일치 대신 # 허용오차**로 가른다. 값은 0.05m — 위 어긋남이 관 누가거리에 미치는 양이 실측 diff --git a/docs/wiki/concepts/design_page/calculations.md b/docs/wiki/concepts/design_page/calculations.md index 56bbef6b8..f69e093a8 100644 --- a/docs/wiki/concepts/design_page/calculations.md +++ b/docs/wiki/concepts/design_page/calculations.md @@ -14,7 +14,7 @@ source: ["docs/raw/조사/2026-10-19_472_구조다시세움_목록.md", "PLAN.md | # | 계산 | TS | 파이썬 짝 | Node | 실행 자리 · 거울 시험 | |---|---|---|---|---|---| -| R1 | 평면 폴리라인 · 곡선(BC · EC · MC) | `logic/B05_Profile_UI_RouteEdit_Curve.ts:243` · `logic/B05_Profile_Pipeline_RouteGround.ts:73` | `common_util_route_polyline.py:579` | — | 짝 · `test_route_polyline_browser_mirror` | +| R1 | 평면 폴리라인 · 곡선(BC · EC · MC) · 선 자리 x · y 4자리 한 벌(초안 그린 선 = 계획노선 csv = 노선 geojson) | `logic/B05_Profile_UI_RouteEdit_Curve.ts:243` · `logic/B05_Profile_Pipeline_RouteGround.ts:66` draftLine | `common_util_route_polyline.py:579` · `common_util_route_geometry.py` write_route_csv · `B05_Profile_Engine.py:24` _route_geojson | — | 짝 · `test_route_polyline_browser_mirror` · `test_475_1_route_digits` | | R2 | 손잡이 → IP · R | `logic/B05_Profile_UI_RouteEdit_Curve.ts` dragHandleTo | — | — | 브라우저 · `test_route_polyline_handle_drag` | | R3 | 노드 편집 규칙(곡선 한도 · 고정점) | `logic/B05_Profile_UI_RouteEdit_Edits.ts` | `B05_Profile_Route_NodeEdits.py:21` | — | 짝 · `test_430_route_edit_rules` | | R4 | 스냅 맞춤(MC → 일반측점) | `logic/B05_Profile_RouteEdit_StationFit.ts` | — | — | 브라우저 · `test_366_3_station_fit` | diff --git a/resources/tester/helper_472_1_browser.mjs b/resources/tester/helper_472_1_browser.mjs index b96e4a34e..3518dfeec 100644 --- a/resources/tester/helper_472_1_browser.mjs +++ b/resources/tester/helper_472_1_browser.mjs @@ -1,5 +1,7 @@ /* 472-1 헬퍼 — 브라우저 길 기준값(진짜 단계 함수 · vite ssr 묶음 · helper_472_1_browser_entry.ts). - * node helper_472_1_browser.mjs <묶음.mjs> <고정 자료 폴더> <격자 .bin> + * node helper_472_1_browser.mjs <묶음.mjs> <고정 자료 폴더> <격자 .bin> [옮김 .json] + * 옮김(475-1) = { node, dx, dy, structures?, plus? } — 열기 뒤 `pipe.update("route", 초안)`(노드 하나 옮김 · 페이지 + * `flattenServerPlan` 초안 꼴) · 구조물 · 추가 측점 바꿔 넣기. * 페이지 묶음(`design_pipeline_page`)과 같은 단계 붙임 — 저장 노선 · 초안 없음 · 세션 = 메모리 가짜 · fetch 없음 · * 격자 = 서버 `dtm_grid_bytes` 가 고정 npz 로 지은 이진. 묶음 출력 → 새 노선 상세(`draftSectionDetail`) → 구조물 * 면적 얹기 → 누가토량. 표준출력 JSON 한 벌(값 꼴 = test_472_1_golden.py `values_of`). */ @@ -32,16 +34,17 @@ globalThis.document = any; globalThis.requestAnimationFrame = (run) => setTimeout(run, 0); globalThis.fetch = () => Promise.reject(new Error("서버 없음")); -const [bundle, dir, gridPath] = process.argv.slice(2); +const [bundle, dir, gridPath, movePath] = process.argv.slice(2); +const move = movePath ? JSON.parse(readFileSync(movePath, "utf8")) : {}; const m = await import(pathToFileURL(bundle).href); const read = (name) => JSON.parse(readFileSync(join(dir, name), "utf8")); const detail = read("detail.json"); const plan = read("plan.json"); const context = read("context.json"); const pipes = read("pipes.json").pipe_points.map(({ lonlat: _l, ...pipe }) => pipe); -const structures = read("structures.json").structures; +const structures = move.structures ?? read("structures.json").structures; const types = read("structure_types.json").types; -const plus = read("plus_stations.json").chainages; +const plus = move.plus ?? read("plus_stations.json").chainages; const P = context.project_id; const R = context.route_id; const bin = readFileSync(gridPath); @@ -66,7 +69,6 @@ const savedStations = m.buildStations(savedLine(), savedCurves(), pipes, structu }); const crossSource = m.pipelineCrossSource(P, detail, savedStations, R); const edits = m.readAlignment(detail.longitudinal).edits; -const status = m.shapeStatus(pipes, structures, types); const bermAt = (list) => { const spans = m.bermSpansFromStructures(Array.isArray(list) ? list : []); return (chainageM) => m.bermSpecAt(spans, chainageM); @@ -81,6 +83,17 @@ const pipe = m.createDesignPipeline({ follow: m.createFollowStage({ savedLine, saved: () => ({ pipes, structures, alignment: edits }), + // 페이지 배선(`design_pipeline_page` stationsAt) + stationsAt: (route, patch, inputs) => + m + .buildStations( + route.line, + route.curves, + patch.pipes ?? inputs.pipes ?? [], + patch.structures ?? inputs.structures ?? [], + { intervalM: interval, types: enabled, ...m.snapOptions(inputs.route, plan) }, + ) + .map((station) => station.chainageM), }), stations: m.createStationsStage({ intervalM: interval, @@ -94,6 +107,7 @@ const pipe = m.createDesignPipeline({ planline: m.createPlanlineStage({ savedBase: (at) => m.plantedBase({ base: null, detail }, at, types), savedLine, + storedPvi: () => m.toAlignmentBase(m.readAlignment(detail.longitudinal)).basePvi, }), profile: m.profileStage, cross: (ctx) => @@ -122,6 +136,27 @@ const record = await pipe.start({ }); await pipe.settled(); if (record.outcome !== "done") throw new Error(`묶음 ${record.outcome}`); +if (move.node != null) { + const flat = m.flattenServerPlan(plan.nodes, plan.curves ?? [], plan.node_edits); + const nodes = flat.planned.map(([x, y]) => [x, y]); + nodes[move.node] = [nodes[move.node][0] + move.dx, nodes[move.node][1] + move.dy]; + const moved = await pipe.update("route", { + rev: 1, + nodes, + curveOn: flat.curveOn.map((on) => on !== false), + curveRadius: flat.curveRadius.map((r) => r ?? null), + curveLock: flat.curveLock ?? nodes.map(() => null), + curveArc: flat.curveArc ?? nodes.map(() => null), + stationSnap: plan.station_snap ?? false, + stationSnapStep: plan.station_snap_step ?? null, + terrain: null, + }); + await pipe.settled(); + if (moved.outcome !== "done") throw new Error(`묶음(옮김) ${moved.outcome}`); +} +// 단면 모양 = 따라간 관 · 구조물(페이지 = follow 뒤 가게 값 · 457) +const follow = pipe.output("follow"); +const status = m.shapeStatus(follow?.pipes ?? pipes, follow?.structures ?? structures, types); const ground = pipe.output("ground"); const profile = pipe.output("profile"); @@ -149,6 +184,8 @@ process.stdout.write( violations: profile.violations ?? [], footprint_records: pipe.output("footprint")?.records?.length ?? 0, skipped: record.skipped, + route_line: pipe.output("route").line, + draft: pipe.input("route"), }), ); process.exit(0); diff --git a/resources/tester/helper_472_1_browser_entry.ts b/resources/tester/helper_472_1_browser_entry.ts index c0e9bbf54..c367bec69 100644 --- a/resources/tester/helper_472_1_browser_entry.ts +++ b/resources/tester/helper_472_1_browser_entry.ts @@ -34,3 +34,6 @@ export { bermSpansFromStructures, bermSpecAt } from "../../B06_Section/B06_Secti export { parseDtmGrid } from "../../common_util/common_util_surface_sampler"; export { computeMassHaul } from "../../common_util/common_util_mass_haul"; export { writeState, writeStateRaw } from "../../A00_Common/b_page_state"; +export { flattenServerPlan } from "../../B05_Profile/logic/B05_Profile_UI_RouteEdit_Edits"; +export { snapOptions } from "../../A00_Common/design/logic/design_pipeline_stations"; +export { toAlignmentBase } from "../../B05_Profile/logic/B05_Profile_UI_Profile_Alignment"; diff --git a/resources/tester/helper_472_1_paths.py b/resources/tester/helper_472_1_paths.py index f49187936..9540479a3 100644 --- a/resources/tester/helper_472_1_paths.py +++ b/resources/tester/helper_472_1_paths.py @@ -273,8 +273,10 @@ def stored(root: Path) -> dict[str, Any]: # ── 브라우저 길 ────────────────────────────────────────────────────────────── -def browser(work: Path) -> dict[str, Any]: - """vite ssr 로 진짜 모듈을 묶어 헬퍼를 돌림(`helper_272_detail_memory` 꼴) · 격자 = 고정 npz.""" +def browser(work: Path, move: dict[str, Any] | None = None) -> dict[str, Any]: + """vite ssr 로 진짜 모듈을 묶어 헬퍼를 돌림(`helper_272_detail_memory` 꼴) · 격자 = 고정 npz. + + `move`(475-1) = 노드 하나 옮김 · 구조물 · 추가 측점 — 값에 그린 선 · 초안을 덧붙임.""" from common_util.common_util_surface_sampler import dtm_grid_bytes grid = work / "grid.bin" @@ -303,8 +305,12 @@ def browser(work: Path) -> dict[str, Any]: raise RuntimeError(build.stderr) bundle = work / "b" / "bundle.mjs" # 조각(assets/)과 같은 폴더 shutil.copy(work / "b" / "helper_472_1_browser_entry.js", bundle) + args = [str(HERE / "helper_472_1_browser.mjs"), str(bundle), str(FIXTURE), str(grid)] + if move is not None: + (work / "move.json").write_text(json.dumps(move), encoding="utf-8") + args.append(str(work / "move.json")) proc = subprocess.run( # noqa: S603 — 고정 실행 파일 - ["node", str(HERE / "helper_472_1_browser.mjs"), str(bundle), str(FIXTURE), str(grid)], + ["node", *args], capture_output=True, text=True, encoding="utf-8", @@ -313,9 +319,13 @@ def browser(work: Path) -> dict[str, Any]: if proc.returncode: raise RuntimeError(proc.stderr) out = json.loads(proc.stdout) - return values_of( + values = values_of( out["sections"], out["keys"], out["mass_haul"], out["warnings"], out["violations"] ) + if move is not None: + values["route_line"] = out["route_line"] + values["draft"] = out["draft"] + return values PATHS = ("stored", "server_save", "server_route", "browser") diff --git a/resources/tester/helper_475_1_moved.py b/resources/tester/helper_475_1_moved.py new file mode 100644 index 000000000..c48b63d44 --- /dev/null +++ b/resources/tester/helper_475_1_moved.py @@ -0,0 +1,178 @@ +"""475-1 헬퍼 — 노선 저장 체인(DB 없이) · 노드 하나 옮긴 초안 → 서버 정본 값. + +`helper_472_1_paths.server_route` 꼴에 노선 바뀜 갈래를 더함 — +`_write_planned_polyline`(csv 4자리 · 곡선표) → 노선 geojson(`_route_geojson`) → 관 따라감 · +구조물 따라감 · 추가 측점 → `run_section_generation` → `_carry_profile`(옛 · 새 계획 폴리라인 · +`carry_base` · `seat_route_moved`) → `_carry_cross` → +상세 읽기 → `_save_tail`. 짝 = `helper_472_1_browser.mjs` 옮김 인자 · `test_475_1_route_digits.py`. +""" + +from __future__ import annotations + +import json +from pathlib import Path +from typing import Any + +import helper_472_1_paths as P + + +def server_moved(root: Path, move: dict[str, Any]) -> dict[str, Any]: + from B05_Profile.B05_Profile_Engine import _route_geojson + from B05_Profile.B05_Profile_Engine_AsPlanned import _vertex_z + from B05_Profile.B05_Profile_Engine_Grade import GradeDesignOptions + from B05_Profile.B05_Profile_Engine_Grade_Carry import ( + carry_base, + carry_edits, + carry_policy, + changed_span, + ) + from B05_Profile.B05_Profile_Engine_Sections import run_section_generation + from B05_Profile.B05_Profile_Engine_Sections_Core import SectionGenerationOptions + from B05_Profile.B05_Profile_Plus_Stations import save_plus_stations + from B05_Profile.B05_Profile_Route_Save_Parts import ( + carry_cross_inputs, + follow_pipes, + route_moved_stations, + seat_route_moved, + station_keys, + vertices_of, + ) + from B05_Profile.B05_Profile_Router import _apply_alignment_edits + from B05_Profile.B05_Profile_Router_Replan import _vertices_of, _write_planned_polyline + from B05_Profile.B05_Profile_Structures_Repository import load_structures + from B05_Profile.B05_Profile_Structures_Router import follow_structures_to_route + from B06_Section.B06_Section_Engine_Design import ROUTE_CARRY_KEYS + from B06_Section.B06_Section_Router import _read_section_detail + from B06_Section.B06_Section_Router_Confirm import _rowless_station_chainages + from B06_Section.B06_Section_Router_Design import ( + CARRY_INPUT_KEYS, + STRUCTURE_ADJUST_KEYS, + attach_default_designs, + compute_default_designs, + ) + from common_util.common_util_drainage_pipes import ( + pipe_points_path_in, + read_pipe_points_file, + route_signature, + ) + from common_util.common_util_json import atomic_write_json + from common_util.common_util_route_geometry import ( + planned_route_initial_path, + planned_route_working_path, + ) + + saved = P.read("detail.json") + summary = P.read("summary.json")["longitudinal"]["data"] + options = {k: v for k, v in summary["options"].items() if k != "extra_stations"} + old_line = _vertices_of(planned_route_working_path(root)) or _vertices_of( + planned_route_initial_path(root) + ) + old_geo = [ + [c[0], c[1]] + for c in json.loads((root / P.ROUTE).read_text(encoding="utf-8"))["geometry"]["coordinates"] + ] + old_pipes = read_pipe_points_file(pipe_points_path_in(root)) + if move.get("structures"): + atomic_write_json( + root / "B05_Profile" / "route" / "structures.json", + {"revision": 1, "structures": move["structures"], "definition_version": None}, + ) + old_structures = load_structures(str(root))[1] + # 노선 저장 = 초안 노드 그대로(브라우저 `flattenServerPlan` 초안 · 헬퍼 출력 draft_nodes) + draft = move["draft"] + working = planned_route_working_path(root) + _write_planned_polyline( + working, + [tuple(n) for n in draft["nodes"]], + 12.0, + simplify=False, + curve_flags=list(draft["curveOn"]), + radii=list(draft["curveRadius"]), + station_snap=draft["stationSnap"], + station_snap_step=draft["stationSnapStep"], + locks=list(draft["curveLock"]), + arcs=list(draft["curveArc"]), + ) + points = _vertices_of(working) + if move.get("structures"): + follow_structures_to_route(root, P.ROUTE, [{"x": x, "y": y} for x, y in points]) + z = _vertex_z(root, "classification", "dtm", True, [tuple(p) for p in points]) + atomic_write_json(root / P.ROUTE, _route_geojson([[x, y, h] for (x, y), h in zip(points, z)])) + verts = vertices_of(points) + pairs = follow_pipes(old_pipes, verts) + doc = json.loads(pipe_points_path_in(root).read_text(encoding="utf-8")) + doc["route_signature"] = route_signature(verts) + doc["points"] = [p.as_dict() for _, p in pairs] + atomic_write_json(pipe_points_path_in(root), doc) + if move.get("plus"): + save_plus_stations(root, move["plus"], points) + run_section_generation( + root, + P.ROUTE, + *P.SURFACE, + options=SectionGenerationOptions(**options), + grade_options=GradeDesignOptions(**summary["grade_options"]), + crs=f"EPSG:{P.read('context.json')['crs_epsg']}", + ) + # _carry_profile — 옛 · 새 계획 폴리라인 + old_alignment = saved["longitudinal"]["profile_alignment"] + longitudinal = json.loads((root / P.LONG).read_text(encoding="utf-8")) + alignment = longitudinal.get("profile_alignment") or {} + span = changed_span([tuple(p) for p in old_line], [tuple(p) for p in points]) + if span is None: + base, edits = old_alignment["base_pvi"], old_alignment.get("edits") or {} + else: + stations = [float(r["chainage_m"]) for r in longitudinal.get("stations") or []] + half = float((alignment.get("policy") or {}).get("station_interval_m") or 20.0) / 2 + base = carry_base(old_line, points, old_alignment["base_pvi"], alignment["base_pvi"]) + edits = carry_edits( + old_line, points, old_alignment["base_pvi"], old_alignment.get("edits"), stations, half + ) + alignment["policy"] = carry_policy(old_line, points, old_alignment, alignment) + alignment["base_pvi"] = base + if span is not None: + born = route_moved_stations(longitudinal.get("stations") or [], old_line, points) + held = [float(k) for k in (edits.get("station_offsets") or {})] + alignment["base_pvi"] = seat_route_moved(alignment, born, held) + longitudinal["profile_alignment"] = alignment + atomic_write_json(root / P.LONG, longitudinal) + alignment, _s, _d = _apply_alignment_edits(root, P.LONG, edits) + # _carry_cross + new_long = json.loads((root / P.LONG).read_text(encoding="utf-8")) + _, structures = load_structures(str(root)) + interval = float(options["station_interval_m"]) + old_keys = station_keys( + saved["longitudinal"]["stations"], + [p for _, p in follow_pipes(old_pipes, vertices_of(old_geo))], + old_structures, + interval, + ) + new_keys = station_keys( + new_long.get("stations") or [], [p for _, p in pairs], structures, interval + ) + designs = { + round(float(s["chainage_m"]), 3): s["design"] + for s in saved["cross_sections"] + if isinstance(s.get("design"), dict) + } + inputs, adjusts, _dropped = carry_cross_inputs( + old_keys, new_keys, designs, (*CARRY_INPUT_KEYS, *ROUTE_CARRY_KEYS), STRUCTURE_ADJUST_KEYS + ) + chainages = _rowless_station_chainages(root, P.LONG, []) + entries = compute_default_designs(root, P.LONG, chainages, None, inputs) + by_m = {round(at, 3): {**d, **adjusts.get(round(at, 3), {})} for at, d in entries} + detail = _read_section_detail(root, P.LONG, P.ROUTE) + for section in detail["cross_sections"]: + hit = next( + (d for at, d in by_m.items() if abs(at - float(section["chainage_m"])) < 0.01), None + ) + if hit is not None: + section["design"] = hit + attach_default_designs(detail["longitudinal"], detail["cross_sections"], root, None) + keys = P._keys(detail["longitudinal"], detail["cross_sections"], root) + sections, haul = P._save_tail(detail, root) + out = P.values_of( + sections, keys, haul, alignment.get("warnings") or [], alignment.get("violations") or [] + ) + out["route_line"] = [list(p) for p in points] + return out diff --git a/resources/tester/test_346_1_pipeline_page.py b/resources/tester/test_346_1_pipeline_page.py index a10480a78..1c0bca26c 100644 --- a/resources/tester/test_346_1_pipeline_page.py +++ b/resources/tester/test_346_1_pipeline_page.py @@ -66,7 +66,8 @@ def _line(nodes: list[list[float]]) -> list[list[float]]: curve_flags=[True] * len(nodes), radii=[None] * len(nodes), ) - return [list(v) for v in built.vertices] + # 저장 노선 = 계획노선 csv 4자리(`write_route_csv` · 475-1 — 초안 그린 선도 같은 자리) + return [[round(x, 4), round(y, 4)] for x, y in built.vertices] def _fake(folder: Path) -> tuple[Path, list[list[float]]]: diff --git a/resources/tester/test_346_1_route_ground_stage.py b/resources/tester/test_346_1_route_ground_stage.py index 0c2c3f8e2..470cc0ec3 100644 --- a/resources/tester/test_346_1_route_ground_stage.py +++ b/resources/tester/test_346_1_route_ground_stage.py @@ -232,24 +232,16 @@ def test_route_ground_단계가_서버와_같고_범위_밖을_재사용한다(t radii=[None] * len(nodes), ) route = out["route"] - assert len(route["line"]) == len(built.vertices) - assert ( - max( - abs(a - b) - for p, q in zip(route["line"], built.vertices, strict=True) - for a, b in zip(p, q) - ) - < 1e-9 - ) - steps = np.hypot(*np.diff(np.asarray(built.vertices), axis=0).T) + # 그린 선 = 서버 저장 선 자리(x · y 4자리 · `write_route_csv` 짝 · 475-1) + assert route["line"] == [[round(x, 4), round(y, 4)] for x, y in built.vertices] + steps = np.hypot(*np.diff(np.asarray(route["line"]), axis=0).T) assert abs(route["lengthM"] - float(steps.sum())) < 1e-6 assert route["chainage"][0] == 0 and len(route["chainage"]) == len(route["line"]) assert len(route["curves"]) == len(built.curves) > 0 for ts, py in zip(route["curves"], built.curves, strict=True): assert set(ts) == {"start", "apex", "end", "radius_m"} - assert abs(ts["radius_m"] - py.radius_m) < 1e-9 - for key in ("start", "apex", "end"): - assert max(abs(a - b) for a, b in zip(ts[key], getattr(py, key))) < 1e-9 + # 곡선표 = 서버 저장 곡선표 자리(`RouteCurve.as_dict` · 475-1) + assert ts == {k: py.as_dict()[k] for k in ts} assert out["saved"]["line"] == line assert out["saved"]["curves"] == [] assert out["noRoute"] diff --git a/resources/tester/test_475_1_route_digits.py b/resources/tester/test_475_1_route_digits.py new file mode 100644 index 000000000..172276a1c --- /dev/null +++ b/resources/tester/test_475_1_route_digits.py @@ -0,0 +1,92 @@ +"""475-1 — 노선 선 소수 자리 한 벌(요구 275 ① · 브라우저 ↔ 서버 저장 차). + +짝 = 서버 노선 geojson `_route_geojson` x · y 4자리 = 계획노선 csv `write_route_csv` = +브라우저 route 단계 `draftLine`(`B05_Profile_Pipeline_RouteGround.ts`). 같은 선이면 측점 · 지반 · +계획선 · 면적 짝이 모두 같아 누가토량 차가 사라짐(옛 3자리 = 교점 끌기 0.8 ㎥). + +고정 자료 = `fixtures/golden_472` · 브라우저 길 = `helper_472_1_browser.mjs` 옮김 인자 · 서버 길 = +`helper_475_1_moved.server_moved`(노선 저장 체인 DB 없이). + + ./venv/Scripts/python.exe -m pytest resources/tester/test_475_1_route_digits.py -q +""" + +from __future__ import annotations + +import copy + +import helper_472_1_paths as paths +import helper_475_1_moved as moved +import pytest + +from B05_Profile.B05_Profile_Router_Replan import _vertices_of +from common_util.common_util_route_geometry import write_route_csv + +WALL = { + "structure_id": "wall600", + "type_id": "retaining_wall", + "placement": "interval", + "chainage_m": 600.0, + "start_m": 595.0, + "end_m": 605.0, + "options": { + "wall_form": "콘크리트", + "length_m": 10, + "height_m": 2, + "before_m": 5, + "after_m": 5, + "side": "자동(성토 쪽)", + }, + "memo": "", + "placement_source": "manual", + "status": "draft", + "revision": 0, + "geometry": None, + "summary_item": None, + "group_items": None, + "x": 208785.614, + "y": 367845.1028, +} +# 교점 9(360 m 언저리) 동 +5 m · 같은 끌기 + 옹벽 595–605 + 추가 측점 510 +CASES = { + "drag": {"node": 9, "dx": 5, "dy": 0}, + "drag_wall_plus": {"node": 9, "dx": 5, "dy": 0, "plus": [510], "structures": [WALL]}, +} + + +def test_geojson_자리_csv_와_같음(tmp_path) -> None: + from B05_Profile.B05_Profile_Engine import _route_geojson + + line = [[208785.61234567, 367845.10285, 512.34567], [208790.00005001, 367850.99994999, 1.0]] + write_route_csv(tmp_path / "r.csv", [{"x": x, "y": y} for x, y, _ in line]) + csv_xy = [list(p) for p in _vertices_of(tmp_path / "r.csv")] + geo = _route_geojson(line)["geometry"]["coordinates"] + assert [[x, y] for x, y, _ in geo] == csv_xy + assert geo[0][0] == 208785.6123 and geo[0][2] == 512.346 + + +@pytest.fixture(scope="module", params=list(CASES)) +def pair(request, tmp_path_factory) -> tuple[dict, dict]: + if not (paths.ROOT / "config" / "node_modules" / "vite").is_dir(): + pytest.skip("프론트엔드 의존성 미설치") + move = copy.deepcopy(CASES[request.param]) + work = tmp_path_factory.mktemp(request.param) + browser = paths.browser(work, move) + # 추가 측점 = 브라우저가 새 노선으로 따라간 자리(페이지 [저장]이 보내는 값) + if move.get("plus"): + move["plus"] = [s["chainage_m"] for s in browser["stations"] if "plus-" in (s["key"] or "")] + server = moved.server_moved( + paths.stage_project(work / "project"), {**move, "draft": browser["draft"]} + ) + return browser, server + + +def test_그린_선_서버_선_같음(pair) -> None: + browser, server = pair + assert browser["route_line"] == server["route_line"] + + +def test_누가토량_차_002_안(pair) -> None: + browser, server = pair + b, s = browser["final_cumulative_m3"], server["final_cumulative_m3"] + assert browser["station_count"] == server["station_count"] + assert abs(b - s) <= 0.02, f"브라우저 {b} · 서버 {s}"