fix(475-1 노선 자리): 노선 선 소수 자리 한 벌 — 서버 노선 geojson x · y 4자리(= 계획노선 csv) · 브라우저 초안 그린 선 · 곡선표도 같은 자리

- 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 <noreply@anthropic.com>
This commit is contained in:
2026-10-19 14:23:23 +09:00
co-authored by Claude Opus 5.5
parent 67eaca6226
commit 7aca51596a
11 changed files with 360 additions and 30 deletions
+6 -2
View File
@@ -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": {},
}
@@ -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),
})),
};
}
@@ -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 — 위 어긋남이 관 누가거리에 미치는 양이 실측
@@ -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` |
+42 -5
View File
@@ -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);
@@ -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";
+14 -4
View File
@@ -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")
+178
View File
@@ -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
+2 -1
View File
@@ -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]]]:
@@ -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"]
@@ -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}"