Merge remote-tracking branch 'origin/sub_laptop_8' into main_laptop_1

This commit is contained in:
2026-10-16 11:32:44 +09:00
4 changed files with 557 additions and 20 deletions
+50 -4
View File
@@ -4,6 +4,7 @@
*
* GET /projects/{id}/route/terrain-sources → 설계 지형 · 격자 받을 자리(`grid`)
* GET /projects/{id}/surface/models/{mid}/grid → DTM 격자 원시 이진(ETag · IndexedDB)
* POST /projects/{id}/route/elevations → 격자를 못 쓸 때 계산 묶음 ground 단계의 서버 길
*
* 주소에 manifest 서명을 붙임 — 보관함은 옛 저장본을 먼저 내므로(`fetchCachedBytes`) 지형이
* 바뀐 뒤 옛 격자로 한 번 그리는 일을 막음. DTM 아닌 표현 · 못 받음 = null → 서버 표본 길.
@@ -12,6 +13,7 @@
import { API_BASE_URL } from "@config/config_frontend";
import { fetchCachedBytes } from "../A00_Common/b_asset_cache";
import { type DtmGrid, parseDtmGrid } from "../common_util/common_util_surface_sampler";
import type { GroundStageDeps } from "./logic/B05_Profile_Pipeline_RouteGround";
export interface DesignTerrainGrid {
/** 이 격자의 지형 — 편집 창 분석 지형이 이것과 같을 때만 씀. */
@@ -25,6 +27,8 @@ interface TerrainSourcesPayload {
}
const loaded = new Map<string, Promise<DesignTerrainGrid | null>>();
/** 받기가 끝난 격자 — 끝나기 전엔 칸이 없음(그동안 서버 길). */
const ready = new Map<string, DesignTerrainGrid | null>();
async function download(projectId: string): Promise<DesignTerrainGrid | null> {
const response = await fetch(`${API_BASE_URL}/projects/${projectId}/route/terrain-sources`, {
@@ -45,11 +49,53 @@ async function download(projectId: string): Promise<DesignTerrainGrid | null> {
export function loadDesignTerrainGrid(projectId: string): Promise<DesignTerrainGrid | null> {
let pending = loaded.get(projectId);
if (!pending) {
pending = download(projectId).catch(() => {
loaded.delete(projectId);
return null;
});
pending = download(projectId).then(
(value) => {
ready.set(projectId, value);
return value;
},
() => {
loaded.delete(projectId);
return null;
},
);
loaded.set(projectId, pending);
}
return pending;
}
/** 지금 쓸 수 있는 격자 — 받는 중이면 null 을 돌려주고 받기를 띄움(기다리지 않음). */
export function designTerrainGridNow(projectId: string): DesignTerrainGrid | null {
if (!ready.has(projectId)) void loadDesignTerrainGrid(projectId);
return ready.get(projectId) ?? null;
}
/** 한 번에 물을 수 있는 점 수 — 서버 `MAX_POINTS` 와 같은 값. */
const ELEVATION_CHUNK = 4000;
/** 계산 묶음 ground 단계에 넣을 격자 · 서버 길(PLAN 346-1). 서버 길 값은 mm 자리(응답 그대로). */
export function groundStageDeps(projectId: string): Pick<GroundStageDeps, "grid" | "remote"> {
return {
grid: () => designTerrainGridNow(projectId),
async remote(points, terrain, signal) {
const chunks: Array<Array<[number, number]>> = [];
for (let i = 0; i < points.length; i += ELEVATION_CHUNK) {
chunks.push(points.slice(i, i + ELEVATION_CHUNK));
}
const answers = await Promise.all(
chunks.map(async (chunk) => {
const response = await fetch(`${API_BASE_URL}/projects/${projectId}/route/elevations`, {
method: "POST",
credentials: "include",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ points: chunk, terrain }),
signal,
});
if (!response.ok) throw new Error(`지반고 요청 실패: HTTP ${response.status}`);
return ((await response.json()) as { z: Array<number | null> }).z;
}),
);
return answers.flat();
},
};
}
@@ -0,0 +1,179 @@
/* =============================================================================
* B05_Profile_Pipeline_RouteGround.ts
* 실시간 계산 묶음(PLAN 346-1)의 **route · ground 단계** — 계약 = `design_pipeline_types.ts`.
*
* route — 노선 초안(RouteDraft) → 그린 노선(원호 포함) · 누가거리. 선은 이미 있는 짝
* `buildEditedPolyline`(↔ `common_util_route_polyline.py`) 그대로.
* ground — 측점(지반 null) → 지반 채운 측점 · 종단 지반선(1 m). 표본 = 지형 격자 짝
* (`common_util_surface_sampler.ts` · `B05_Profile_Sections_Ground.ts`) · 격자가 없거나
* (아직 못 받음 · DTM 아님) 분석 지형 ≠ 설계 지형이면 주입받은 서버 표본(`remote`).
* 바뀐 범위 밖 측점 · 종단은 앞 출력 재사용(국부 연산) — 자리 · 축 · 지형이 같을 때만.
*
* 화면 · fetch · 세션 안 씀 — 격자 · 서버 길은 부르는 쪽이 넣음(`B05_Profile_Api_TerrainGrid.ts`).
* ========================================================================== */
import type {
GroundOutput,
GroundSample,
PipelineStation,
RouteDraft,
RouteLine,
StageFn,
StationGround,
Vertex,
} from "../../A00_Common/design/design_pipeline_types";
import { type DtmGrid, heightAt } from "../../common_util/common_util_surface_sampler";
import { buildEditedPolyline } from "../B05_Profile_UI_RouteEdit_Curve";
import { applyArcLocks } from "../B05_Profile_UI_RouteEdit_Edits";
import {
crossOffsets,
longitudinalPoints,
round6,
SECTION_CROSS_HALF_WIDTH_M,
SECTION_CROSS_SAMPLE_INTERVAL_M,
} from "./B05_Profile_Sections_Ground";
/* ── route ─────────────────────────────────────────────────────────────── */
export interface RouteStageDeps {
/** 곡선 기본 반지름(m) — 편집기 `minRadiusM`(계획 기준 · 0 = 없음). */
minRadiusM(): number;
/** 초안이 없을 때(= 저장분 그대로) 그릴 노선 — 없으면 null. */
savedLine(): Vertex[] | null;
}
/** 정점마다 누가거리 — 첫 점 0. */
export function routeLineOf(line: Vertex[]): RouteLine {
const chainage = [0];
for (let i = 1; i < line.length; i += 1) {
chainage.push(
chainage[i - 1] + Math.hypot(line[i][0] - line[i - 1][0], line[i][1] - line[i - 1][1]),
);
}
return { line, chainage, lengthM: chainage[chainage.length - 1] };
}
/** 초안 → 그린 노선. 길이를 붙든 곡선은 교각에 맞춰 R 을 다시 잡음(편집기 `markEdited` 와 같은 차례). */
export function draftLine(draft: RouteDraft, minRadiusM: number): Vertex[] {
const nodes = draft.nodes.map((node): Vertex => [node[0], node[1]]);
const radius = [...draft.curveRadius];
applyArcLocks(nodes, [...draft.curveLock], [...draft.curveArc], radius);
return buildEditedPolyline(nodes, draft.curveOn, radius, minRadiusM).vertices;
}
export function createRouteStage(deps: RouteStageDeps): StageFn<"route"> {
return (ctx) => {
const draft = ctx.inputs.route;
const line = draft ? draftLine(draft, deps.minRadiusM()) : deps.savedLine();
if (!line || line.length < 2) throw new Error("그릴 노선이 없습니다.");
return routeLineOf(line);
};
}
/* ── ground ────────────────────────────────────────────────────────────── */
export interface GroundStageDeps {
/** 지금 쓸 수 있는 설계 지형 격자 — 아직 못 받았거나 없으면 null(그동안은 서버 길). */
grid(): { grid: DtmGrid; design: "las" | "sheet" } | null;
/** 서버 표본(`/route/elevations`) — 점 차례 그대로 · 무효 null · `signal` 로 끊김. */
remote(
points: Vertex[],
terrain: string | null,
signal: AbortSignal,
): Promise<Array<number | null>>;
/** 횡단 반폭(m) — 비면 서버 기본(20 m). */
crossHalfWidthM?(): number | null;
}
const samePlace = (a: PipelineStation, b: PipelineStation): boolean =>
a.x === b.x && a.y === b.y && a.left[0] === b.left[0] && a.left[1] === b.left[1];
const outside = (chainageM: number, range: { fromM: number; toM: number } | null): boolean =>
range !== null && (chainageM < range.fromM || chainageM > range.toM);
export function createGroundStage(deps: GroundStageDeps): StageFn<"ground"> {
/** 앞 출력이 쓴 지형 · 노선 · 반폭 — 다르면 재사용 안 함. */
let last: { terrain: string; line: Vertex[] | null; halfWidth: number } | null = null;
return async (ctx) => {
const stations = ctx.out.stations ?? [];
const line = ctx.out.route?.line ?? null;
const terrain = ctx.inputs.route?.terrain ?? null;
const halfWidth = deps.crossHalfWidthM?.() ?? SECTION_CROSS_HALF_WIDTH_M;
const offsets = crossOffsets(halfWidth, SECTION_CROSS_SAMPLE_INTERVAL_M);
const local = deps.grid();
const useGrid = local !== null && (terrain === null || terrain === local.design);
const tag = terrain ?? "design";
const reusable = ctx.prev !== null && last?.terrain === tag && last.halfWidth === halfWidth;
const range = ctx.range;
const prevByKey = new Map((reusable ? ctx.prev!.stations : []).map((s) => [s.key, s]));
// 다시 셀 측점 — 범위 안 · 앞 출력에 없음 · 자리나 축이 바뀜.
const kept = stations.map((station) => {
const before = prevByKey.get(station.key);
return before?.ground && outside(station.chainageM, range) && samePlace(before, station)
? before.ground
: null;
});
const long = line ? longitudinalPoints(line) : { chainage: [], xy: [] as Vertex[], lengthM: 0 };
// 종단 — 노선이 그대로면 통째 · 범위 앞쪽은 같은 노선 위라 그대로.
const prevLong = reusable ? ctx.prev!.longitudinal : null;
const longKept: Array<number | null | undefined> = long.chainage.map((c, i) => {
if (!prevLong || prevLong.chainageM[i] !== round6(c)) return undefined;
if (last?.line === line || (range !== null && c < range.fromM)) return prevLong.z[i];
return undefined;
});
const points: Vertex[] = [];
stations.forEach((station, i) => {
if (kept[i]) return;
for (const offset of offsets) {
points.push([station.x + station.left[0] * offset, station.y + station.left[1] * offset]);
}
});
long.xy.forEach((xy, i) => {
if (longKept[i] === undefined) points.push(xy);
});
let heights: Array<number | null>;
if (useGrid) {
heights = points.map(([x, y]) => {
const z = heightAt(local!.grid, x, y);
return Number.isNaN(z) ? null : round6(z);
});
} else {
heights = points.length ? await deps.remote(points, terrain, ctx.signal) : [];
}
let cursor = 0;
const filled: PipelineStation[] = stations.map((station, i) => {
if (kept[i]) return { ...station, ground: kept[i] };
const zs = offsets.map(() => heights[cursor++] ?? null);
return { ...station, ground: stationGround(offsets, zs) };
});
const z = long.chainage.map((_, i) =>
longKept[i] === undefined ? (heights[cursor++] ?? null) : (longKept[i] as number | null),
);
last = { terrain: tag, line, halfWidth };
const output: GroundOutput = {
stations: filled,
longitudinal: { chainageM: long.chainage.map(round6), z },
};
return output;
};
}
/** offset 열 표고 → 계약 꼴(중심 · 좌 · 우 · 중심에서 바깥 차례 · offsetM 양수). */
function stationGround(offsets: number[], zs: Array<number | null>): StationGround {
const left: GroundSample[] = [];
const right: GroundSample[] = [];
let centerZ: number | null = null;
offsets.forEach((offset, i) => {
if (Math.abs(offset) <= 1e-9) centerZ = zs[i];
else if (offset > 0) left.push({ offsetM: round6(offset), z: zs[i] });
else right.unshift({ offsetM: round6(-offset), z: zs[i] });
});
return { centerZ, left, right };
}
@@ -70,7 +70,7 @@ export interface GroundOptions {
}
/** 파이썬 `round(x, 6)` 자리. */
const round6 = (value: number): number => Math.round(value * 1e6) / 1e6;
export const round6 = (value: number): number => Math.round(value * 1e6) / 1e6;
const round9 = (value: number): number => Math.round(value * 1e9) / 1e9;
function sample(grid: DtmGrid, x: number, y: number): GroundSample {
@@ -100,7 +100,7 @@ function cleanLine(line: ReadonlyArray<Vertex>): {
}
/** 횡단 offset 열 — `np.arange(-hw, hw + si/2, si)` · hw 넘는 것 버림 · 0 없으면 끼움. */
function crossOffsets(halfWidth: number, interval: number): number[] {
export function crossOffsets(halfWidth: number, interval: number): number[] {
const count = Math.ceil((halfWidth + interval * 0.5 + halfWidth) / interval);
const offsets: number[] = [];
for (let i = 0; i < count; i += 1) {
@@ -114,6 +114,33 @@ function crossOffsets(halfWidth: number, interval: number): number[] {
return offsets;
}
/** 종단 표본 자리 — 누가거리(간격 · 끝점 포함) · 자리 = `_interpolate_xy`(축마다 `np.interp`). */
export function longitudinalPoints(
line: ReadonlyArray<Vertex>,
intervalM: number = SECTION_LONG_SAMPLE_INTERVAL_M,
): { chainage: number[]; xy: Vertex[]; lengthM: number } {
const { xs, ys, chainage: along } = cleanLine(line);
const chainage = stationsAlong(line, intervalM, true);
const xy = chainage.map((c): Vertex => [npInterp(c, along, xs), npInterp(c, along, ys)]);
return { chainage, xy, lengthM: along[along.length - 1] ?? 0 };
}
/** 측점 자리 · 좌측 축 — 자리 = `_interpolate_xy` · 축 = 측점 틀 짝(`stationFrameAt`). 못 재면 뺌. */
export function placeStations(
line: ReadonlyArray<Vertex>,
stationsM: ReadonlyArray<number>,
longIntervalM: number = SECTION_LONG_SAMPLE_INTERVAL_M,
): Array<{ chainage: number; origin: Vertex; left: Vertex }> {
const { xs, ys, chainage: along } = cleanLine(line);
const probe = Math.max(longIntervalM, 0.5);
return stationsM.flatMap((chainage) => {
const frame = stationFrameAt(line, chainage, probe);
if (!frame) return [];
const origin: Vertex = [npInterp(chainage, along, xs), npInterp(chainage, along, ys)];
return [{ chainage, origin, left: frame.left }];
});
}
/** 노선 지반선 — 종단 표본 · 측점마다 중심 · 좌측 축 · 횡단 표본. `stationsM` = 측점 누가거리. */
export function sectionsGround(
line: ReadonlyArray<Vertex>,
@@ -124,15 +151,12 @@ export function sectionsGround(
const halfWidth = options.crossHalfWidthM ?? SECTION_CROSS_HALF_WIDTH_M;
const interval = options.crossSampleIntervalM ?? SECTION_CROSS_SAMPLE_INTERVAL_M;
const longInterval = options.longSampleIntervalM ?? SECTION_LONG_SAMPLE_INTERVAL_M;
const probe = Math.max(longInterval, 0.5);
// 자리 = `_interpolate_xy`(축마다 `np.interp`) · 좌측 축만 측점 틀 짝(`stationFrameAt`).
const { xs, ys, chainage: along } = cleanLine(line);
const at = (c: number): Vertex => [npInterp(c, along, xs), npInterp(c, along, ys)];
const samples: LongGroundSample[] = stationsAlong(line, longInterval, true).map((chainage) => {
const [x, y] = at(chainage);
return { chainage_m: round6(chainage), ...sample(grid, x, y) };
});
const long = longitudinalPoints(line, longInterval);
const samples: LongGroundSample[] = long.chainage.map((chainage, i) => ({
chainage_m: round6(chainage),
...sample(grid, long.xy[i][0], long.xy[i][1]),
}));
const offsets = crossOffsets(halfWidth, interval);
let center = 0;
@@ -141,11 +165,9 @@ export function sectionsGround(
});
const stations: StationGround[] = [];
for (const chainage of stationsM) {
const frame = stationFrameAt(line, chainage, probe);
if (!frame) continue;
const [ox, oy] = at(chainage);
const [lx, ly] = frame.left;
for (const { chainage, origin, left } of placeStations(line, stationsM, longInterval)) {
const [ox, oy] = origin;
const [lx, ly] = left;
const cross: CrossGroundPoint[] = [];
const raw: number[] = [];
for (const offset of offsets) {
@@ -176,7 +198,7 @@ export function sectionsGround(
samples: cross,
});
}
return { length_m: round6(along[along.length - 1] ?? 0), samples, stations };
return { length_m: round6(long.lengthM), samples, stations };
}
/** `np.interp(x, xp, fp)` — xp 오름차순 · 범위 밖은 끝값 · 식 차례도 numpy 와 같음. */
@@ -0,0 +1,290 @@
"""PLAN 346-1 — 계산 묶음 route · ground 단계(`logic/B05_Profile_Pipeline_RouteGround.ts`).
route = 노선 초안 → 그린 노선(서버 `build_planned_polyline` 편집 갈래와 같은 선).
ground = 측점 → 지반 채움 · 종단 지반선(서버 `generate_sections` 와 같은 표본).
격자 없음 · 분석 지형 다름 = 서버 길 · 바뀐 범위 밖 재사용 · 노선 1,078 m 한 번 시간.
"""
from __future__ import annotations
import csv
import json
import re
import subprocess
import sys
from pathlib import Path
import numpy as np
import pytest
ROOT = Path(__file__).resolve().parents[2]
if str(ROOT) not in sys.path:
sys.path.insert(0, str(ROOT))
from B05_Profile import B05_Profile_Engine_Sections_Core as core # noqa: E402
from common_util.common_util_route_polyline import build_planned_polyline # noqa: E402
from common_util.common_util_surface_sampler import DtmGridSampler, dtm_grid_bytes # noqa: E402
TS_FILE = ROOT / "B05_Profile" / "logic" / "B05_Profile_Pipeline_RouteGround.ts"
TSC = ROOT / "config" / "node_modules" / "typescript" / "bin" / "tsc"
PROJECT = ROOT / "storage/1/3/1bb0f876-983f-4ca5-9cc7-f59ee1f77e0a"
REAL_GRID = PROJECT / "B04_PreProcess/models/dtm_classification_smooth.npz"
REAL_ROUTE = PROJECT / "B05_Profile/route/planned_route_initial.csv"
REAL_NODES = PROJECT / "B05_Profile/route/planned_route_initial_nodes.csv"
_TOL = 1.5e-6
MIN_RADIUS_M = 12.0
_RUNNER = """
import { readFileSync, writeFileSync } from "node:fs";
import { parseDtmGrid, heightAt } from "./common_util/common_util_surface_sampler.js";
import { placeStations } from "./B05_Profile/logic/B05_Profile_Sections_Ground.js";
import {
createRouteStage, createGroundStage,
} from "./B05_Profile/logic/B05_Profile_Pipeline_RouteGround.js";
const bytes = readFileSync(process.argv[2]);
const grid = parseDtmGrid(bytes.buffer.slice(bytes.byteOffset, bytes.byteOffset + bytes.length));
const job = JSON.parse(readFileSync(process.argv[3], "utf8"));
const n = job.nodes.length;
const draft = {
rev: 1, nodes: job.nodes, curveOn: Array(n).fill(true), curveRadius: Array(n).fill(null),
curveLock: Array(n).fill(null), curveArc: Array(n).fill(null), stationSnap: false, terrain: null,
};
const routeStage = createRouteStage({ minRadiusM: () => job.minRadius, savedLine: () => job.line });
const ctxOf = (extra) => ({
generation: 1, range: null, signal: new AbortController().signal,
inputs: { route: null, alignment: null, cross: null, pipes: null, structures: null },
out: {}, before: {}, prev: null, ...extra,
});
const route = routeStage(ctxOf({ inputs: { route: draft } }));
const saved = routeStage(ctxOf({}));
let noRoute = null;
try { createRouteStage({ minRadiusM: () => 12, savedLine: () => null })(ctxOf({})); }
catch (error) { noRoute = String(error.message); }
const line = job.line;
const stations = placeStations(line, job.stations).map((s, i) => ({
key: `k${i}`, chainageM: s.chainage, x: s.origin[0], y: s.origin[1], azimuthDeg: 0,
left: s.left, kind: "grid", id: null, ground: null,
planRadiusM: null, curveOuterSide: null, curveWideningM: null,
}));
const lineOut = { line, chainage: [], lengthM: 0 };
let remoteCalls = [];
const remote = async (points, terrain) => {
remoteCalls.push({ count: points.length, terrain });
return points.map(([x, y]) => {
const z = heightAt(grid, x, y);
return Number.isNaN(z) ? null : Math.round(z * 1000) / 1000;
});
};
let ready = null;
const ground = createGroundStage({ grid: () => ready, remote });
const run = (extra) => ground(ctxOf({ out: { route: lineOut, stations }, ...extra }));
const out = { route, saved, noRoute };
out.serverPath = await run({}); // 격자 받기 전 = 서버 길
out.serverCalls = remoteCalls; remoteCalls = [];
ready = { grid, design: "las" };
out.gridPath = await run({}); // 받은 뒤 = 브라우저 길
out.gridCalls = remoteCalls.length;
out.sheetPath = await run({ inputs: { route: { ...draft, terrain: "sheet" } } });
out.sheetCalls = remoteCalls; remoteCalls = [];
// 범위 밖 재사용 — 서버 길로 앞 출력을 받고 범위 [fromM, ∞) 만 다시
ready = null;
const reuseStage = createGroundStage({ grid: () => ready, remote });
const first = await reuseStage(ctxOf({ out: { route: lineOut, stations } }));
remoteCalls = [];
const moved = stations.map((s, i) => (i === 1 ? { ...s, x: s.x + 0.5 } : s));
const second = await reuseStage(ctxOf({
out: { route: lineOut, stations: moved }, prev: first,
range: { sources: ["grade"], fromM: job.fromM, toM: Infinity, keys: [] },
}));
out.reuseCalls = remoteCalls;
out.reuse = second;
ready = { grid, design: "las" };
out.movedFull = await createGroundStage({ grid: () => ready, remote })(
ctxOf({ out: { route: lineOut, stations: moved } }));
const timing = createGroundStage({ grid: () => ready, remote });
for (let i = 0; i < 5; i += 1) await timing(ctxOf({ out: { route: lineOut, stations } }));
const t0 = performance.now();
for (let i = 0; i < 20; i += 1) await timing(ctxOf({ out: { route: lineOut, stations } }));
out.ms = (performance.now() - t0) / 20;
writeFileSync(process.argv[4], JSON.stringify(out));
"""
def _run_ts(tmp_path: Path, npz: Path, job: dict) -> dict:
out = tmp_path / "js"
subprocess.run( # noqa: S603 — 고정 실행 파일
[
"node",
str(TSC),
str(TS_FILE),
"--outDir",
str(out),
"--rootDir",
str(ROOT),
"--module",
"esnext",
"--target",
"es2022",
"--moduleResolution",
"bundler",
"--ignoreConfig",
],
cwd=str(ROOT),
check=True,
capture_output=True,
)
for emitted in out.rglob("*.js"):
emitted.write_text(
re.sub(r'(from\s+"\.\.?/[^"]+)"', r'\1.js"', emitted.read_text(encoding="utf-8")),
encoding="utf-8",
)
(out / "runner.mjs").write_text(_RUNNER, encoding="utf-8")
grid = tmp_path / "grid.bin"
grid.write_bytes(dtm_grid_bytes(npz))
payload = tmp_path / "job.json"
result = tmp_path / "out.json"
payload.write_text(json.dumps(job), encoding="utf-8")
subprocess.run( # noqa: S603
["node", str(out / "runner.mjs"), str(grid), str(payload), str(result)],
cwd=str(ROOT),
check=True,
capture_output=True,
)
return json.loads(result.read_text(encoding="utf-8"))
def _read_xy(path: Path) -> list[list[float]]:
with path.open(encoding="utf-8") as handle:
return [[float(row["x"]), float(row["y"])] for row in csv.DictReader(handle)]
def _contract_rows(station: dict) -> list[tuple[float, float | None]]:
"""계약 꼴(중심 · 좌 · 우) → 서버 꼴 (offset, z) 오름차순."""
ground = station["ground"]
rows = [(-s["offsetM"], s["z"]) for s in ground["right"]]
rows += [(0.0, ground["centerZ"])] + [(s["offsetM"], s["z"]) for s in ground["left"]]
return sorted(rows)
def _close(a, b, tol: float) -> bool:
return (a is None and b is None) or (
a is not None and b is not None and abs(float(a) - float(b)) <= tol
)
def _fake_case(tmp_path: Path) -> tuple[Path, list, list, float]:
"""가짜 격자 — 칸 폭 고르지 않음 · 빈칸 띠 · 노선이 빈칸 · 격자 끝을 지나감."""
rng = np.random.default_rng(3461)
x = (np.cumsum(rng.uniform(0.95, 1.1, 260)) + 1000.0).astype(np.float32)
y = (np.cumsum(rng.uniform(0.95, 1.1, 140)) + 2000.0).astype(np.float32)
xx, yy = np.meshgrid(x.astype(np.float64), y.astype(np.float64))
z = 500 + 0.08 * (xx - 1000) + 0.3 * (yy - 2000) + rng.normal(0, 0.2, xx.shape)
mask = np.ones(z.shape, dtype=bool)
mask[60:70, 100:115] = False
npz = tmp_path / "dtm_fake.npz"
np.savez(npz, x=x, y=y, z=z.astype(np.float32), valid_mask=mask)
nodes = [[1010.0, 2020.0], [1090.0, 2040.0], [1150.0, 2110.0], [1250.0, 2135.0]]
built = build_planned_polyline(
[tuple(n) for n in nodes],
min_radius_m=MIN_RADIUS_M,
simplify=False,
curve_flags=[True] * 4,
radii=[None] * 4,
)
return npz, [list(v) for v in built.vertices], nodes, 120.0
def _real_case(_tmp_path: Path) -> tuple[Path, list, list, float]:
if not (REAL_GRID.is_file() and REAL_ROUTE.is_file() and REAL_NODES.is_file()):
pytest.skip("검증_sub8 없음")
return REAL_GRID, _read_xy(REAL_ROUTE), _read_xy(REAL_NODES), 600.0
@pytest.mark.skipif(not TSC.is_file(), reason="프론트엔드 의존성 미설치")
@pytest.mark.parametrize("case", [_fake_case, _real_case], ids=["가짜", "검증_sub8"])
def test_route_ground_단계가_서버와_같고_범위_밖을_재사용한다(tmp_path: Path, case) -> None:
npz, line, nodes, from_m = case(tmp_path)
expected = core.generate_sections(
np.asarray(line, dtype=np.float64), DtmGridSampler.from_npz(npz)
)
stations = [s["chainage_m"] for s in expected["longitudinal"]["stations"]]
job = {
"line": line,
"nodes": nodes,
"minRadius": MIN_RADIUS_M,
"stations": stations,
"fromM": from_m,
}
out = _run_ts(tmp_path, npz, job)
# route — 서버 편집 갈래와 같은 선 · 누가거리 · 저장분 대체 · 노선 없음
built = build_planned_polyline(
[tuple(node) for node in nodes],
min_radius_m=MIN_RADIUS_M,
simplify=False,
curve_flags=[True] * len(nodes),
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)
assert abs(route["lengthM"] - float(steps.sum())) < 1e-6
assert route["chainage"][0] == 0 and len(route["chainage"]) == len(route["line"])
assert out["saved"]["line"] == line
assert out["noRoute"]
# ground 브라우저 길 = 서버 generate_sections(측점 · 횡단 · 종단)
grid_path = out["gridPath"]
assert out["gridCalls"] == 0
assert len(grid_path["stations"]) == len(expected["cross_sections"])
problems: list[str] = []
for ts, py in zip(grid_path["stations"], expected["cross_sections"], strict=True):
rows = _contract_rows(ts)
assert len(rows) == len(py["samples"])
for (offset, z), sample in zip(rows, py["samples"], strict=True):
if not (
_close(offset, sample["offset_m"], _TOL)
and _close(z, sample["z"] if sample["valid"] else None, _TOL)
):
problems.append(f"측점 {py['chainage_m']} offset {sample['offset_m']}: {z}")
if not _close(ts["ground"]["centerZ"], py["center_z"], _TOL):
problems.append(f"측점 {py['chainage_m']} 중심 {ts['ground']['centerZ']}")
long_py = expected["longitudinal"]["samples"]
long_ts = grid_path["longitudinal"]
assert len(long_ts["z"]) == len(long_py)
for c, z, sample in zip(long_ts["chainageM"], long_ts["z"], long_py, strict=True):
if not (_close(c, sample["chainage_m"], _TOL) and _close(z, sample["z"], _TOL)):
problems.append(f"종단 {sample['chainage_m']}: {z}")
assert not problems, "\n".join(problems[:30])
# 서버 길 — 격자 받기 전 · 분석 지형 다름 · 값은 mm 자리로 같음
count = len(stations) * 81 + len(long_py)
assert out["serverCalls"] == [{"count": count, "terrain": None}]
assert out["sheetCalls"] == [{"count": count, "terrain": "sheet"}]
for ts, gs in zip(out["serverPath"]["stations"], grid_path["stations"], strict=True):
for (_o1, a), (_o2, b) in zip(_contract_rows(ts), _contract_rows(gs), strict=True):
assert _close(a, b, 5e-4)
# 범위 밖 재사용 — 다시 묻는 것 = 범위 안 측점 + 범위 밖이어도 자리가 바뀐 측점 하나
again = sum(1 for c in stations if c >= from_m) + 1
assert out["reuseCalls"] == [{"count": again * 81, "terrain": None}]
for ts, full in zip(out["reuse"]["stations"], out["movedFull"]["stations"], strict=True):
for (_o1, a), (_o2, b) in zip(_contract_rows(ts), _contract_rows(full), strict=True):
assert _close(a, b, 5e-4), f"재사용 값 다름 {ts['chainageM']}"
assert out["reuse"]["longitudinal"]["z"] == out["serverPath"]["longitudinal"]["z"]
# 한 번 전체 ground(검증_sub8 = 노선 1,078 m · 측점 55) — 0.3 s 예산 안 넉넉히
assert out["ms"] < 50, f"ground 단계 {out['ms']:.1f} ms"
print(f"ground 단계 {out['ms']:.2f} ms · 측점 {len(stations)} · 종단 {len(long_py)}")