From 51cebefd4034ad40731e27f5186307128dc6076e Mon Sep 17 00:00:00 2001 From: umsangdon Date: Fri, 16 Oct 2026 11:14:46 +0900 Subject: [PATCH] =?UTF-8?q?feat(=EC=A7=80=ED=98=95=ED=91=9C=EB=B3=B8):=203?= =?UTF-8?q?44-1=20=ED=9A=A1=EB=8B=A8=20=EC=A7=80=EB=B0=98=EC=84=A0=20?= =?UTF-8?q?=EC=83=9D=EC=84=B1=20=EC=A7=9D=20=E2=80=94=20B05=20logic=20sect?= =?UTF-8?q?ionsGround(=EC=A2=85=EB=8B=A8=201=20m=20=C2=B7=20=EC=B8=A1?= =?UTF-8?q?=EC=A0=90=20=EC=A4=91=EC=8B=AC=20=C2=B7=20=ED=9A=A1=EB=8B=A8=20?= =?UTF-8?q?=C2=B120=20m=200.5=20m=20=C2=B7=20=EC=83=81=EB=8B=A8=EC=B8=A1)?= =?UTF-8?q?=20=C2=B7=20groundProfile=20=C2=B7=20groundAt(=EC=B8=A1?= =?UTF-8?q?=EC=A0=90=20=EC=A7=80=EB=B0=98=EA=B3=A0)=20=C2=B7=20extendUnclo?= =?UTF-8?q?sed(=EB=AF=B8=EA=B5=90=EC=B0=A8=20=EB=8D=A7=EB=B6=99=EC=9E=84)?= =?UTF-8?q?=20=C2=B7=20=EB=91=90=20=EC=AA=BD=20=EB=A8=B8=EB=A6=AC=20?= =?UTF-8?q?=EC=A7=9D=20=ED=91=9C=EC=8B=9C=20=C2=B7=20=EA=B1=B0=EC=9A=B8=20?= =?UTF-8?q?=EC=8B=9C=ED=97=98(=EC=84=9C=EB=B2=84=20=EC=83=9D=EC=84=B1=20?= =?UTF-8?q?=C2=B7=20=EC=A0=80=EC=9E=A5=EB=90=9C=20=EC=A2=85=20=C2=B7=20?= =?UTF-8?q?=ED=9A=A1=EB=8B=A8=EA=B3=BC=20=EA=B0=99=EC=9D=8C=20=C2=B7=20?= =?UTF-8?q?=EB=85=B8=EC=84=A0=201,078=20m=203.9=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 --- .../B05_Profile_Engine_Sections_Core.py | 1 + .../logic/B05_Profile_Sections_Ground.ts | 311 ++++++++++++++++++ .../B06_Section_Engine_SampleExtend.py | 1 + .../test_344_1_sections_ground_mirror.py | 276 ++++++++++++++++ 4 files changed, 589 insertions(+) create mode 100644 B05_Profile/logic/B05_Profile_Sections_Ground.ts create mode 100644 resources/tester/test_344_1_sections_ground_mirror.py diff --git a/B05_Profile/B05_Profile_Engine_Sections_Core.py b/B05_Profile/B05_Profile_Engine_Sections_Core.py index 22e567aaa..b9f07459b 100644 --- a/B05_Profile/B05_Profile_Engine_Sections_Core.py +++ b/B05_Profile/B05_Profile_Engine_Sections_Core.py @@ -9,6 +9,7 @@ ⚠ 파이썬·TS 짝 — 측점 스냅 `_chainages` · `_design_curve_spans` · `snap_curve_stations` ↔ `B05_Profile_UI_RouteEdit_Frame.ts` `stationsAlong` · `curveSpans` · `snapCurveStations`. 거울 시험 `resources/tester/test_224_2_station_snap.py`. +⚠ 파이썬·TS 짝 — `generate_sections` 지반 부분 ↔ `B05_Profile/logic/B05_Profile_Sections_Ground.ts`. """ import math diff --git a/B05_Profile/logic/B05_Profile_Sections_Ground.ts b/B05_Profile/logic/B05_Profile_Sections_Ground.ts new file mode 100644 index 000000000..299f2bc57 --- /dev/null +++ b/B05_Profile/logic/B05_Profile_Sections_Ground.ts @@ -0,0 +1,311 @@ +/* ============================================================================= + * B05_Profile_Sections_Ground.ts + * 노선 한 벌의 **지반선** — 종단 1 m 표본 · 측점 지반고 · 횡단 ±20 m 0.5 m · 미교차 덧붙임. + * 노선을 고치는 동안 브라우저가 지형 격자(`common_util_surface_sampler.ts`)로 바로 셈한다(PLAN 344-1). + * + * ⚠ 파이썬·TS 짝 — `B05_Profile_Engine_Sections_Core.py` `generate_sections` 의 지반 부분 + * (종단 표본 · 측점 중심 · 횡단 표본 · `uphill_side`) ↔ `sectionsGround`. + * ⚠ 파이썬·TS 짝 — `B05_Profile_Engine_Grade.py` `ground_profile` + 측점 `np.interp` ↔ + * `groundProfile` · `groundAt`. + * ⚠ 파이썬·TS 짝 — `B06_Section_Engine_SampleExtend.py` `extend_unclosed` ↔ `extendUnclosed`. + * 한쪽을 고치면 다른 쪽도 고치고 거울 시험 `resources/tester/test_344_1_sections_ground_mirror.py`. + * + * 측점 자리(규칙 · 스냅 · 구조물 측점 병합)는 부르는 쪽이 정해 넘김 — 틀은 + * `B05_Profile_UI_RouteEdit_Frame.ts`(이미 짝). 자릿수는 서버 출력과 같게 소수 6자리. + * ========================================================================== */ + +import { type DtmGrid, heightAt } from "../../common_util/common_util_surface_sampler"; +import type { CrossDesignEdge } from "../../common_util/common_util_cross_design"; +import { stationFrameAt, stationsAlong } from "../B05_Profile_UI_RouteEdit_Frame"; + +type Vertex = [number, number]; + +/** 짝: `config_system_design.py` `SECTION_CROSS_HALF_WIDTH_M` · `_SAMPLE_INTERVAL_M` · `SECTION_LONG_…`. */ +export const SECTION_CROSS_HALF_WIDTH_M = 20.0; +export const SECTION_CROSS_SAMPLE_INTERVAL_M = 0.5; +export const SECTION_LONG_SAMPLE_INTERVAL_M = 1.0; +/** 짝: `B06_Section_Engine_SampleExtend.EXTEND_STEP_M`. */ +export const EXTEND_STEP_M = 5.0; +/** 짝: `B06_Section_Engine_Design._SLOPE_CLOSE_TOLERANCE_M`. */ +const SLOPE_CLOSE_TOLERANCE_M = 0.01; + +export interface GroundSample { + x: number; + y: number; + z: number | null; + elevation_m: number | null; + valid: boolean; +} +export interface LongGroundSample extends GroundSample { + chainage_m: number; +} +export interface CrossGroundPoint extends GroundSample { + offset_m: number; +} + +export interface StationGround { + chainage_m: number; + center_x: number; + center_y: number; + center_z: number | null; + tangent_xy: Vertex; + /** 좌측(+offset) 단위 벡터. */ + left_xy: Vertex; + /** 횡단 기준 등고가 높은 쪽 — 좌우 유효 표본 평균 비교 · 모르면 null. */ + uphill_side: "left" | "right" | null; + samples: CrossGroundPoint[]; +} + +export interface SectionsGround { + length_m: number; + /** 종단 지반 표본(1 m · 끝점 포함). */ + samples: LongGroundSample[]; + stations: StationGround[]; +} + +export interface GroundOptions { + crossHalfWidthM?: number; + crossSampleIntervalM?: number; + longSampleIntervalM?: number; +} + +/** 파이썬 `round(x, 6)` 자리. */ +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 { + const z = heightAt(grid, x, y); + const valid = !Number.isNaN(z); + const value = valid ? round6(z) : null; + return { x: round6(x), y: round6(y), z: value, elevation_m: value, valid }; +} + +/** `_clean_polyline` — 앞 점과 길이 0(≤ 1e-8)인 점을 버리고 누가거리. */ +function cleanLine(line: ReadonlyArray): { + xs: number[]; + ys: number[]; + chainage: number[]; +} { + const kept = line.filter( + (vertex, i) => + i === 0 || Math.hypot(vertex[0] - line[i - 1][0], vertex[1] - line[i - 1][1]) > 1e-8, + ); + const chainage = [0]; + for (let i = 1; i < kept.length; i += 1) { + chainage.push( + chainage[i - 1] + Math.hypot(kept[i][0] - kept[i - 1][0], kept[i][1] - kept[i - 1][1]), + ); + } + return { xs: kept.map((v) => v[0]), ys: kept.map((v) => v[1]), chainage }; +} + +/** 횡단 offset 열 — `np.arange(-hw, hw + si/2, si)` · hw 넘는 것 버림 · 0 없으면 끼움. */ +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) { + const value = -halfWidth + i * interval; + if (value <= halfWidth + 1e-9) offsets.push(value); + } + if (!offsets.some((value) => Math.abs(value) <= 1e-9)) { + offsets.push(0); + offsets.sort((a, b) => a - b); + } + return offsets; +} + +/** 노선 지반선 — 종단 표본 · 측점마다 중심 · 좌측 축 · 횡단 표본. `stationsM` = 측점 누가거리. */ +export function sectionsGround( + line: ReadonlyArray, + stationsM: ReadonlyArray, + grid: DtmGrid, + options: GroundOptions = {}, +): 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 offsets = crossOffsets(halfWidth, interval); + let center = 0; + offsets.forEach((value, index) => { + if (Math.abs(value) < Math.abs(offsets[center])) center = index; + }); + + 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; + const cross: CrossGroundPoint[] = []; + const raw: number[] = []; + for (const offset of offsets) { + const x = ox + lx * offset; + const y = oy + ly * offset; + raw.push(heightAt(grid, x, y)); + cross.push({ offset_m: round6(offset), ...sample(grid, x, y) }); + } + const mean = (pick: (offset: number) => boolean): number | null => { + const values = raw.filter((z, i) => pick(offsets[i]) && !Number.isNaN(z)); + return values.length ? values.reduce((a, b) => a + b, 0) / values.length : null; + }; + const leftMean = mean((offset) => offset > 1e-9); + const rightMean = mean((offset) => offset < -1e-9); + let uphill: StationGround["uphill_side"] = null; + if (leftMean !== null && rightMean !== null && Math.abs(leftMean - rightMean) > 1e-6) { + uphill = leftMean > rightMean ? "left" : "right"; + } + const centerZ = raw[center]; + stations.push({ + chainage_m: round6(chainage), + center_x: round6(ox), + center_y: round6(oy), + center_z: Number.isNaN(centerZ) ? null : round6(centerZ), + tangent_xy: [round9(ly), round9(-lx)], + left_xy: [round9(lx), round9(ly)], + uphill_side: uphill, + samples: cross, + }); + } + return { length_m: round6(along[along.length - 1] ?? 0), samples, stations }; +} + +/** `np.interp(x, xp, fp)` — xp 오름차순 · 범위 밖은 끝값 · 식 차례도 numpy 와 같음. */ +function npInterp(x: number, xp: ReadonlyArray, fp: ReadonlyArray): number { + const last = xp.length - 1; + if (x <= xp[0]) return fp[0]; + if (x >= xp[last]) return fp[last]; + let low = 0; + let high = last; + while (high - low > 1) { + const mid = (low + high) >> 1; + if (xp[mid] <= x) low = mid; + else high = mid; + } + const slope = (fp[low + 1] - fp[low]) / (xp[low + 1] - xp[low]); + return slope * (x - xp[low]) + fp[low]; +} + +/** 종단 표본 → (누가거리, 지반고) · 결측은 이웃 유효 값으로 선형 보간. 유효 둘 미만이면 throw. */ +export function groundProfile(samples: ReadonlyArray>): { + chainage: number[]; + ground: number[]; +} { + const rows = samples + .map((s) => ({ + chainage: Number(s.chainage_m ?? 0), + z: s.valid !== false && s.elevation_m != null ? Number(s.elevation_m) : NaN, + })) + .sort((a, b) => a.chainage - b.chainage); + const known = rows.filter((row) => Number.isFinite(row.z)); + if (known.length < 2 || rows.length < 2) { + throw new Error("계획선을 만들기에 유효한 종단 표고 샘플이 부족합니다."); + } + const xp = known.map((row) => row.chainage); + const fp = known.map((row) => row.z); + return { + chainage: rows.map((row) => row.chainage), + ground: rows.map((row) => npInterp(row.chainage, xp, fp)), + }; +} + +/** 측점 지반고 — `groundProfile` 위 선형 보간(파이썬 `station_ground`). */ +export function groundAt(profile: { chainage: number[]; ground: number[] }, chainageM: number) { + return npInterp(chainageM, profile.chainage, profile.ground); +} + +/** 횡단 설계가 덧붙임에 돌려줘야 할 칸. */ +export interface UnclosedDesign { + slope_unclosed: boolean; + design_line?: ReadonlyArray | null; +} + +export interface ExtendInfo { + left_m: number; + right_m: number; + surface_end: boolean; +} + +function validSorted(samples: ReadonlyArray>): Array<[number, number]> { + return samples + .filter((s) => s.valid !== false && s.elevation_m != null) + .map((s): [number, number] => [Number(s.offset_m), Number(s.elevation_m)]) + .sort((a, b) => a[0] - b[0] || a[1] - b[1]); +} + +/** 미교차 측점 지반 표본을 **열린 쪽만** +`stepM` 씩 지표면 끝까지 넓힘 — 닫혔으면 null. + * 새 걸음에 무효 표본이 섞이면 그 쪽은 안 붙이고 `surface_end`. 상한 없음(파이썬과 같음). */ +export function extendUnclosed>( + samples: ReadonlyArray, + origin: Vertex, + leftAxis: Vertex, + compute: (samples: Array) => UnclosedDesign, + grid: DtmGrid, + stepM: number = EXTEND_STEP_M, +): { samples: Array; info: ExtendInfo } | null { + let current: Array = [...samples]; + let design = compute(current); + if (!design.slope_unclosed) return null; + let offsets = validSorted(current).map(([offset]) => offset); + let spacing = Infinity; + offsets.slice(1).forEach((value, index) => { + const gap = value - offsets[index]; + if (gap > 1e-9 && gap < spacing) spacing = gap; + }); + if (!Number.isFinite(spacing)) throw new Error("넓힐 표본 간격을 잴 수 없습니다."); + const count = Math.max(Math.round(stepM / spacing), 1); + const info: ExtendInfo = { left_m: 0, right_m: 0, surface_end: false }; + while (design.slope_unclosed) { + const ground = validSorted(current); + const lineEnds = (design.design_line ?? []) + .map((p): [number, number] => [Number(p.offset_m), Number(p.elevation_m)]) + .sort((a, b) => a[0] - b[0] || a[1] - b[1]); + const open = + ground.length && lineEnds.length + ? { + right: Math.abs(ground[0][1] - lineEnds[0][1]) > SLOPE_CLOSE_TOLERANCE_M, + left: + Math.abs(ground[ground.length - 1][1] - lineEnds[lineEnds.length - 1][1]) > + SLOPE_CLOSE_TOLERANCE_M, + } + : { right: false, left: false }; + const added: CrossGroundPoint[] = []; + for (const [side, sign] of [ + ["right", -1], + ["left", 1], + ] as const) { + if (!open[side]) continue; + const edge = sign < 0 ? Math.min(...offsets) : Math.max(...offsets); + const step: CrossGroundPoint[] = []; + for (let k = 1; k <= count; k += 1) { + const offset = edge + sign * spacing * k; + const point = sample( + grid, + origin[0] + leftAxis[0] * offset, + origin[1] + leftAxis[1] * offset, + ); + step.push({ offset_m: round6(offset), ...point }); + } + if (!step.every((point) => point.valid)) { + info.surface_end = true; + continue; + } + added.push(...step); + info[`${side}_m`] += stepM; + } + if (!added.length) break; + current = [...current, ...added].sort((a, b) => Number(a.offset_m) - Number(b.offset_m)); + offsets = validSorted(current).map(([offset]) => offset); + design = compute(current); + } + return { samples: current, info }; +} diff --git a/B06_Section/B06_Section_Engine_SampleExtend.py b/B06_Section/B06_Section_Engine_SampleExtend.py index 62ca91726..333bf609c 100644 --- a/B06_Section/B06_Section_Engine_SampleExtend.py +++ b/B06_Section/B06_Section_Engine_SampleExtend.py @@ -8,6 +8,7 @@ ⚠ 716 판정 「미교차 측점만 반폭 +5m 한 번」을 갈음(그것으론 4곳만 닫혔음). ⚠ 계산식은 안 바뀐다 — 샘플(입력)만 넓어진다. 넓힌 샘플은 횡단 파일에 남겨 화면(TS)·Node·B08 이 같은 지반을 본다(한 벌). +⚠ 파이썬·TS 짝 — `extend_unclosed` ↔ `B05_Profile_Sections_Ground.ts`(B05 logic) `extendUnclosed`. """ from __future__ import annotations diff --git a/resources/tester/test_344_1_sections_ground_mirror.py b/resources/tester/test_344_1_sections_ground_mirror.py new file mode 100644 index 000000000..52617c966 --- /dev/null +++ b/resources/tester/test_344_1_sections_ground_mirror.py @@ -0,0 +1,276 @@ +"""횡단 지반선 생성 거울 — 파이썬 ↔ TS(`B05_Profile/logic/B05_Profile_Sections_Ground.ts`). + +PLAN 344-1 — 노선을 고치는 동안 브라우저가 지은 지반선이 서버 정본과 같아야 함. +짝: `generate_sections` 지반 부분 ↔ `sectionsGround` · `ground_profile` + 측점 `np.interp` ↔ +`groundProfile` · `groundAt` · `extend_unclosed` ↔ `extendUnclosed`. 저장된 종 · 횡단 파일과도 대조. +""" + +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 B05_Profile.B05_Profile_Engine_Grade import ground_profile # noqa: E402 +from B06_Section import B06_Section_Engine_SampleExtend as extend_py # noqa: E402 +from common_util.common_util_surface_sampler import DtmGridSampler, dtm_grid_bytes # noqa: E402 +from config import config_system # noqa: E402 + +TS_FILE = ROOT / "B05_Profile" / "logic" / "B05_Profile_Sections_Ground.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" +_TOL = 1.5e-6 # 두 쪽 다 소수 6자리 반올림 +#: 저장값 대조 — 저장된 x · y 가 6자리로 잘려 그 자리 표고가 기울기만큼(수 μm) 움직임. +_STORED_TOL = 1e-5 + +#: 덧붙임 시험용 설계 — 쪽마다 닫히는 반폭(우 27 · 좌 31 m)에 닿기 전엔 끝이 1 m 떠 있음. +_COMPUTE_PY_RADII = {"right": 27.0, "left": 31.0} + +_RUNNER = """ +import { readFileSync, writeFileSync } from "node:fs"; +import { parseDtmGrid, heightAt } from "./common_util/common_util_surface_sampler.js"; +import { + sectionsGround, groundProfile, groundAt, extendUnclosed, + SECTION_CROSS_HALF_WIDTH_M, SECTION_CROSS_SAMPLE_INTERVAL_M, SECTION_LONG_SAMPLE_INTERVAL_M, + EXTEND_STEP_M, +} from "./B05_Profile/logic/B05_Profile_Sections_Ground.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 compute = (samples) => { + const ground = samples.filter((s) => s.valid !== false && s.elevation_m != null) + .sort((a, b) => a.offset_m - b.offset_m); + const right = ground[0], left = ground[ground.length - 1]; + const rightOpen = Math.abs(right.offset_m) < job.radii.right; + const leftOpen = Math.abs(left.offset_m) < job.radii.left; + return { + slope_unclosed: rightOpen || leftOpen, + design_line: [ + { offset_m: right.offset_m, elevation_m: right.elevation_m + (rightOpen ? 1 : 0) }, + { offset_m: left.offset_m, elevation_m: left.elevation_m + (leftOpen ? 1 : 0) }, + ], + }; +}; +const ground = sectionsGround(job.line, job.stations, grid); +const profile = groundProfile(ground.samples); +const out = { + constants: [SECTION_CROSS_HALF_WIDTH_M, SECTION_CROSS_SAMPLE_INTERVAL_M, + SECTION_LONG_SAMPLE_INTERVAL_M, EXTEND_STEP_M], + ground, + stationGround: job.stations.map((c) => groundAt(profile, c)), + extended: ground.stations.map((s) => + extendUnclosed(s.samples, [s.center_x, s.center_y], s.left_xy, compute, grid)), + stored: job.stored.map(([x, y]) => { + const z = heightAt(grid, x, y); + return Number.isNaN(z) ? null : z; + }), +}; +writeFileSync(process.argv[4], JSON.stringify(out)); +""" + + +def _compute_py(samples: list[dict]) -> dict: + ground = sorted( + (s for s in samples if s.get("valid") is not False and s.get("elevation_m") is not None), + key=lambda s: s["offset_m"], + ) + right, left = ground[0], ground[-1] + right_open = abs(right["offset_m"]) < _COMPUTE_PY_RADII["right"] + left_open = abs(left["offset_m"]) < _COMPUTE_PY_RADII["left"] + return { + "slope_unclosed": right_open or left_open, + "design_line": [ + {"offset_m": right["offset_m"], "elevation_m": right["elevation_m"] + right_open}, + {"offset_m": left["offset_m"], "elevation_m": left["elevation_m"] + left_open}, + ], + } + + +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 _same(a, b, where: str, problems: list[str]) -> None: + if a is None or b is None: + if a is not b: + problems.append(f"{where}: {a!r} vs {b!r}") + elif abs(float(a) - float(b)) > _TOL: + problems.append(f"{where}: {a!r} vs {b!r}") + + +def _check(npz: Path, line: list, tmp_path: Path, stored: list | None = None) -> dict: + sampler = DtmGridSampler.from_npz(npz) + expected = core.generate_sections(np.asarray(line, dtype=np.float64), sampler) + stations = [s["chainage_m"] for s in expected["longitudinal"]["stations"]] + job = {"line": line, "stations": stations, "radii": _COMPUTE_PY_RADII, "stored": stored or []} + actual = _run_ts(tmp_path, npz, job) + problems: list[str] = [] + ground = actual["ground"] + _same(ground["length_m"], expected["longitudinal"]["length_m"], "길이", problems) + long_py = expected["longitudinal"]["samples"] + assert len(ground["samples"]) == len(long_py) + for i, (ts, py) in enumerate(zip(ground["samples"], long_py, strict=True)): + assert ts["valid"] == py["valid"], f"종단 {i} 유효 어긋남" + for key in ("chainage_m", "x", "y", "z", "elevation_m"): + _same(ts[key], py[key], f"종단 {i} {key}", problems) + cross_py = expected["cross_sections"] + assert len(ground["stations"]) == len(cross_py) + for ts, py in zip(ground["stations"], cross_py, strict=True): + where = f"측점 {py['chainage_m']}" + assert ts["uphill_side"] == py["uphill_side"], f"{where} 상단측" + for key in ("chainage_m", "center_x", "center_y", "center_z"): + _same(ts[key], py[key], f"{where} {key}", problems) + for key in ("left_xy", "tangent_xy"): + for axis in (0, 1): + _same(ts[key][axis], py["frame"][key][axis], f"{where} {key}", problems) + assert len(ts["samples"]) == len(py["samples"]) + for a, b in zip(ts["samples"], py["samples"], strict=True): + assert a["valid"] == b["valid"], f"{where} offset {b['offset_m']} 유효 어긋남" + for key in ("offset_m", "x", "y", "z"): + _same(a[key], b[key], f"{where} offset {b['offset_m']} {key}", problems) + + profile_c, profile_z = ground_profile(expected["longitudinal"]) + for c, z in zip(stations, actual["stationGround"], strict=True): + _same(z, float(np.interp(c, profile_c, profile_z)), f"측점 지반고 {c}", problems) + + extended_count = 0 + for ts, py in zip(actual["extended"], cross_py, strict=True): + want = extend_py.extend_unclosed(py["samples"], py["frame"], _compute_py, sampler) + if want is None: + assert ts is None + continue + extended_count += 1 + assert ts["info"] == want[1], f"측점 {py['chainage_m']} 넓힘 {ts['info']} vs {want[1]}" + assert len(ts["samples"]) == len(want[0]) + for a, b in zip(ts["samples"], want[0], strict=True): + for key in ("offset_m", "x", "y", "z"): + _same(a[key], b[key], f"넓힘 {py['chainage_m']} {b['offset_m']} {key}", problems) + assert not problems, "짝이 갈림:\n" + "\n".join(problems[:40]) + return {"actual": actual, "extended": extended_count, "stations": len(stations)} + + +@pytest.fixture +def holed_npz(tmp_path: Path) -> Path: + """칸 폭 고르지 않음 · 빈칸 띠 · 격자 끝 — 노선이 빈칸 · 끝을 지나감.""" + rng = np.random.default_rng(3441) + x = (np.cumsum(rng.uniform(0.95, 1.1, 160)) + 1000.0).astype(np.float32) + y = (np.cumsum(rng.uniform(0.95, 1.1, 90)) + 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)).astype( + np.float32 + ) + mask = np.ones(z.shape, dtype=bool) + mask[40:46, 60:70] = False + path = tmp_path / "dtm_holed.npz" + np.savez(path, x=x, y=y, z=z, valid_mask=mask) + return path + + +@pytest.mark.skipif(not TSC.is_file(), reason="프론트엔드 의존성 미설치") +def test_가짜_지형_빈칸_끝_넓힘이_서버와_같다(tmp_path: Path, holed_npz: Path) -> None: + arc = [[1080 + 30 * np.cos(a), 2040 + 30 * np.sin(a)] for a in np.linspace(-1.2, 0.9, 25)] + line = [[1003.0, 2010.0], [1040.0, 2016.0], *arc, [1150.0, 2083.0]] + result = _check(holed_npz, [[float(a), float(b)] for a, b in line], tmp_path) + assert result["actual"]["constants"] == [ + config_system.SECTION_CROSS_HALF_WIDTH_M, + config_system.SECTION_CROSS_SAMPLE_INTERVAL_M, + config_system.SECTION_LONG_SAMPLE_INTERVAL_M, + extend_py.EXTEND_STEP_M, + ] + assert result["extended"] >= 3 + infos = [e["info"] for e in result["actual"]["extended"] if e] + assert any(info["surface_end"] for info in infos), "격자 끝 멈춤 사례 없음" + assert any(not info["surface_end"] for info in infos), "끝까지 닫힌 사례 없음" + + +def _stored_points() -> list[list[float]]: + longitudinal = json.loads( + (PROJECT / "B06_Section/longitudinal/longitudinal.json").read_text(encoding="utf-8") + ) + points = [[s["x"], s["y"], s["z"]] for s in longitudinal["samples"]] + for path in sorted((PROJECT / "B06_Section/cross_sections").glob("cross_*.json")): + section = json.loads(path.read_text(encoding="utf-8")) + points += [[s["x"], s["y"], s.get("z")] for s in section.get("samples") or []] + return points + + +@pytest.mark.skipif(not TSC.is_file(), reason="프론트엔드 의존성 미설치") +@pytest.mark.skipif(not (REAL_GRID.is_file() and REAL_ROUTE.is_file()), reason="검증_sub8 없음") +def test_검증_프로젝트_노선과_저장된_종횡단이_같다(tmp_path: Path) -> None: + with REAL_ROUTE.open(encoding="utf-8") as handle: + line = [[float(row["x"]), float(row["y"])] for row in csv.DictReader(handle)] + stored = _stored_points() + result = _check(REAL_GRID, line, tmp_path, [[x, y] for x, y, _z in stored]) + assert result["stations"] > 50 + # 저장된 종단 · 횡단(넓힌 표본 포함)의 자리에서 브라우저 표본 = 저장값 + wrong = [ + (x, y, z, got) + for (x, y, z), got in zip(stored, result["actual"]["stored"], strict=True) + if (z is None) != (got is None) or (z is not None and abs(z - got) > _STORED_TOL) + ] + assert len(stored) > 5000 + assert not wrong, f"저장값과 다름 {len(wrong)}점 — 처음 {wrong[:3]}" + + +def test_짝_머리글이_서로를_가리킨다() -> None: + text = TS_FILE.read_text(encoding="utf-8") + for name in ( + "B05_Profile_Engine_Sections_Core.py", + "B05_Profile_Engine_Grade.py", + "B06_Section_Engine_SampleExtend.py", + ): + assert name in text + assert "B05_Profile_Sections_Ground.ts" in (core.__doc__ or "") + assert "B05_Profile_Sections_Ground.ts" in (extend_py.__doc__ or "")