From f8d44cd8e9144d6599e6f98e1c9d7e5d0d70fab1 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Fri, 16 Oct 2026 11:28:18 +0900 Subject: [PATCH 1/2] =?UTF-8?q?feat(=EA=B3=84=EC=82=B0=EB=AC=B6=EC=9D=8C):?= =?UTF-8?q?=20346-1=20route=20=C2=B7=20ground=20=EB=8B=A8=EA=B3=84=20?= =?UTF-8?q?=E2=80=94=20=EB=85=B8=EC=84=A0=20=EC=B4=88=EC=95=88=20=E2=86=92?= =?UTF-8?q?=20=EA=B7=B8=EB=A6=B0=20=EB=85=B8=EC=84=A0(buildEditedPolyline?= =?UTF-8?q?=20=EC=A7=9D=20=EA=B7=B8=EB=8C=80=EB=A1=9C=20=C2=B7=20=EA=B8=B8?= =?UTF-8?q?=EC=9D=B4=20=EB=B6=99=EB=93=A0=20=EA=B3=A1=EC=84=A0=20R=20?= =?UTF-8?q?=EB=8B=A4=EC=8B=9C=20=EC=9E=A1=EC=9D=8C)=20=C2=B7=20=EC=B8=A1?= =?UTF-8?q?=EC=A0=90=20=E2=86=92=20=EC=A7=80=EB=B0=98=20=EC=B1=84=EC=9B=80?= =?UTF-8?q?=20=C2=B7=20=EC=A2=85=EB=8B=A8=20=EC=A7=80=EB=B0=98=EC=84=A0(?= =?UTF-8?q?=EA=B3=84=EC=95=BD=20=EA=BC=B4)=20=C2=B7=20=EB=B2=94=EC=9C=84?= =?UTF-8?q?=20=EB=B0=96=20=EC=B8=A1=EC=A0=90=20=C2=B7=20=EC=A2=85=EB=8B=A8?= =?UTF-8?q?=20=EC=9E=AC=EC=82=AC=EC=9A=A9(=EC=9E=90=EB=A6=AC=20=C2=B7=20?= =?UTF-8?q?=EC=B6=95=20=C2=B7=20=EC=A7=80=ED=98=95=20=EA=B0=99=EC=9D=84=20?= =?UTF-8?q?=EB=95=8C=EB=A7=8C)=20=C2=B7=20=EA=B2=A9=EC=9E=90=20=EC=97=86?= =?UTF-8?q?=EC=9D=8C=20=C2=B7=20=EB=B6=84=EC=84=9D=20=EC=A7=80=ED=98=95=20?= =?UTF-8?q?=EB=8B=A4=EB=A6=84=20=3D=20=EC=A3=BC=EC=9E=85=20=EC=84=9C?= =?UTF-8?q?=EB=B2=84=20=EA=B8=B8=20=C2=B7=20=EC=8B=9C=ED=97=98(=EC=84=9C?= =?UTF-8?q?=EB=B2=84=20generate=5Fsections=20=EC=99=80=20=EA=B0=99?= =?UTF-8?q?=EC=9D=8C=20=C2=B7=20=EB=85=B8=EC=84=A0=201,078=20m=201.4~6.9?= =?UTF-8?q?=20ms)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01Se7ytqbohmBQnfYpwYFnYf --- .../logic/B05_Profile_Pipeline_RouteGround.ts | 179 +++++++++++ .../logic/B05_Profile_Sections_Ground.ts | 54 +++- .../tester/test_346_1_route_ground_stage.py | 290 ++++++++++++++++++ 3 files changed, 507 insertions(+), 16 deletions(-) create mode 100644 B05_Profile/logic/B05_Profile_Pipeline_RouteGround.ts create mode 100644 resources/tester/test_346_1_route_ground_stage.py diff --git a/B05_Profile/logic/B05_Profile_Pipeline_RouteGround.ts b/B05_Profile/logic/B05_Profile_Pipeline_RouteGround.ts new file mode 100644 index 000000000..47ec5b627 --- /dev/null +++ b/B05_Profile/logic/B05_Profile_Pipeline_RouteGround.ts @@ -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>; + /** 횡단 반폭(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 = 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; + 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): 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 }; +} diff --git a/B05_Profile/logic/B05_Profile_Sections_Ground.ts b/B05_Profile/logic/B05_Profile_Sections_Ground.ts index 299f2bc57..64c6f3dc7 100644 --- a/B05_Profile/logic/B05_Profile_Sections_Ground.ts +++ b/B05_Profile/logic/B05_Profile_Sections_Ground.ts @@ -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): { } /** 횡단 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, + 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, + stationsM: ReadonlyArray, + 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, @@ -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 와 같음. */ diff --git a/resources/tester/test_346_1_route_ground_stage.py b/resources/tester/test_346_1_route_ground_stage.py new file mode 100644 index 000000000..9f25a8298 --- /dev/null +++ b/resources/tester/test_346_1_route_ground_stage.py @@ -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)}") From 7a26d218815c56f610ddf4d0838cf4eb27afc766 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Fri, 16 Oct 2026 11:28:19 +0900 Subject: [PATCH 2/2] =?UTF-8?q?feat(=EA=B3=84=EC=82=B0=EB=AC=B6=EC=9D=8C):?= =?UTF-8?q?=20346-1=20=EA=B2=A9=EC=9E=90=20=EC=A4=80=EB=B9=84=20=E2=80=94?= =?UTF-8?q?=20designTerrainGridNow(=EB=B0=9B=EB=8A=94=20=EC=A4=91=EC=97=94?= =?UTF-8?q?=20null=20=C2=B7=20=EC=84=9C=EB=B2=84=20=EA=B8=B8=20=C2=B7=20?= =?UTF-8?q?=EB=B0=9B=EC=9D=80=20=EB=92=A4=20=EB=B8=8C=EB=9D=BC=EC=9A=B0?= =?UTF-8?q?=EC=A0=80=20=EA=B8=B8)=20=C2=B7=20groundStageDeps(=EA=B2=A9?= =?UTF-8?q?=EC=9E=90=20=C2=B7=20elevations=20=EC=84=9C=EB=B2=84=20?= =?UTF-8?q?=EA=B8=B8=204,000=EC=A0=90=EC=94=A9=20=C2=B7=20AbortSignal)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01Se7ytqbohmBQnfYpwYFnYf --- B05_Profile/B05_Profile_Api_TerrainGrid.ts | 54 ++++++++++++++++++++-- 1 file changed, 50 insertions(+), 4 deletions(-) diff --git a/B05_Profile/B05_Profile_Api_TerrainGrid.ts b/B05_Profile/B05_Profile_Api_TerrainGrid.ts index ce49bbc13..647f5dcad 100644 --- a/B05_Profile/B05_Profile_Api_TerrainGrid.ts +++ b/B05_Profile/B05_Profile_Api_TerrainGrid.ts @@ -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>(); +/** 받기가 끝난 격자 — 끝나기 전엔 칸이 없음(그동안 서버 길). */ +const ready = new Map(); async function download(projectId: string): Promise { const response = await fetch(`${API_BASE_URL}/projects/${projectId}/route/terrain-sources`, { @@ -45,11 +49,53 @@ async function download(projectId: string): Promise { export function loadDesignTerrainGrid(projectId: string): Promise { 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 { + return { + grid: () => designTerrainGridNow(projectId), + async remote(points, terrain, signal) { + const chunks: Array> = []; + 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 }).z; + }), + ); + return answers.flat(); + }, + }; +}