diff --git a/A00_Common/design/design_pipeline_stations.ts b/A00_Common/design/design_pipeline_stations.ts new file mode 100644 index 000000000..851cd4d63 --- /dev/null +++ b/A00_Common/design/design_pipeline_stations.ts @@ -0,0 +1,56 @@ +/* ============================================================================= + * A00_Common/design/design_pipeline_stations.ts + * 계산 묶음 stations 단계(PLAN 346-1) — 측점 계산은 `B05_Profile/logic/B05_Profile_Sections_Stations.ts` + * (서버 `generate_sections` 짝) · 여기는 단계 꼴 · 범위 밖 앞 값 재사용만. + * ========================================================================== */ + +import { + buildStations, + REUSE_MARGIN_M, + type StationsOptions, +} from "../../B05_Profile/logic/B05_Profile_Sections_Stations"; +import type { + PlanPipe, + PlanStructure, + PlanType, +} from "../../common_util/common_util_structure_station"; +import type { PipelineStation, StageContext, StageFn } from "./design_pipeline_types"; + +/** 범위 밖 측점은 앞 값(같은 열쇠 · 같은 자리)을 그대로 — 뒤 단계가 같은 객체로 「안 바뀜」을 앎. */ +export function reuseOutside( + next: PipelineStation[], + prev: ReadonlyArray | null, + range: { fromM: number; toM: number } | null, +): PipelineStation[] { + if (!prev || !range) return next; + const old = new Map(prev.map((station) => [station.key, station])); + return next.map((station) => { + const inside = + station.chainageM >= range.fromM - REUSE_MARGIN_M && + station.chainageM <= range.toM + REUSE_MARGIN_M; + const before = old.get(station.key); + return !inside && before && Math.abs(before.chainageM - station.chainageM) < 1e-9 + ? before + : station; + }); +} + +/** stations 단계 — 입력 관 · 구조물(follow 뒤 누가거리) · 그린 노선 · 곡선표 · 노선 초안 스냅. */ +export function createStationsStage( + options: Omit & { + types: () => ReadonlyArray; + }, +): StageFn<"stations"> { + return (ctx: StageContext<"stations">) => { + const route = ctx.out.route; + if (!route) return []; + const next = buildStations( + route.line, + route.curves, + (ctx.inputs.pipes as PlanPipe[] | null) ?? [], + (ctx.inputs.structures as PlanStructure[] | null) ?? [], + { ...options, types: options.types(), stationSnap: ctx.inputs.route?.stationSnap ?? false }, + ); + return reuseOutside(next, ctx.prev, ctx.range); + }; +} diff --git a/B05_Profile/B05_Profile_Engine_Sections_Core.py b/B05_Profile/B05_Profile_Engine_Sections_Core.py index b9f07459b..fb7d9fe76 100644 --- a/B05_Profile/B05_Profile_Engine_Sections_Core.py +++ b/B05_Profile/B05_Profile_Engine_Sections_Core.py @@ -10,6 +10,9 @@ `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`. +⚠ 파이썬·TS 짝 — `generate_sections` 측점 부분(격자 · 스냅 · 구조물 측점 병합 · 종류 · 방위각) · +`_plan_radii` · `_curve_widenings` · `_design_curve_widenings` ↔ `B05_Profile/logic/B05_Profile_Sections_Stations.ts` +(`buildStations`). 거울 시험 `resources/tester/test_344_3_pipeline_stations_mirror.py`. """ import math diff --git a/B05_Profile/logic/B05_Profile_Sections_Stations.ts b/B05_Profile/logic/B05_Profile_Sections_Stations.ts new file mode 100644 index 000000000..d23f7aefb --- /dev/null +++ b/B05_Profile/logic/B05_Profile_Sections_Stations.ts @@ -0,0 +1,344 @@ +/* ============================================================================= + * B05_Profile/logic/B05_Profile_Sections_Stations.ts + * 측점 한 벌(PLAN 344-3 뼈대 · 346-1) — 그린 노선 + 관 · 구조물 → 측점(지반 null). 계산 묶음 stations + * 단계(`A00_Common/design/design_pipeline_stations.ts`)가 부름. 측점 열쇠 규칙 한 곳(`stationKey` · + * 계약 = `A00_Common/design/design_pipeline_types.ts` 머리). + * + * ⚠ 파이썬·TS 짝 — `B05_Profile_Engine_Sections_Core.py` `generate_sections` 의 측점 부분 + * (격자 · 스냅 · 구조물 측점 병합 · 종류 · 틀 · 방위각) ↔ `buildStations` · + * `_plan_radii` · `_curve_widenings` · `_design_curve_widenings` ↔ `planRadii` · `curveWidenings` · + * `designCurveWidenings` · `curve_widening_m` · `CURVE_WIDENING_TABLE_M` 등(config_system_design) ↔ + * 같은 이름 상수 · `circumradius_2d`(Engine_Geometry) ↔ `circumradius`. + * 거울 시험 `resources/tester/test_344_3_pipeline_stations_mirror.py`. + * 이미 있는 짝을 그대로 씀 — 격자 · 스냅 · 틀(`B05_Profile_UI_RouteEdit_Frame`) · 심는 측점 + * (`common_util_structure_station.planStations`). + * 화면 · fetch · 세션 없음(순수). + * ========================================================================== */ + +import { + curveSpans, + snapCurveStations, + stationFrameAt, + stationsAlong, +} from "../B05_Profile_UI_RouteEdit_Frame"; +import { + planStations, + type PlanPipe, + type PlanStructure, + type PlanType, +} from "../../common_util/common_util_structure_station"; +import type { + DesignCurve, + PipelineStation, + StationKind, + Vertex, +} from "../../A00_Common/design/design_pipeline_types"; + +/** 짝: `config_system_design.py` 같은 이름. */ +export const SECTION_STATION_INTERVAL_M = 20.0; +export const SECTION_INCLUDE_ENDPOINT = true; +export const CURVE_WIDENING_TAPER_M = 10.0; +export const CURVE_WIDENING_TABLE_M: ReadonlyArray = [ + [10.0, 13.0, 2.25], + [13.0, 14.0, 2.0], + [14.0, 15.0, 1.75], + [15.0, 18.0, 1.5], + [18.0, 20.0, 1.25], + [20.0, 25.0, 1.0], + [25.0, 30.0, 0.75], + [30.0, 40.0, 0.5], + [40.0, 45.0, 0.25], +]; +/** 짝: `Engine_Sections_Core.py` 같은 이름. */ +export const PLAN_RADIUS_ARM_M = 10.0; +export const PLAN_RADIUS_STRAIGHT_M = 10000.0; +/** 같은 측점으로 볼 거리(m) — 서버 격자 붙임 폭 · `SAME_STATION_M` 과 같은 값. */ +const SNAP_M = 0.005; +/** 범위 밖 측점을 앞 값 그대로 쓸 때 범위 앞뒤 여유(m) — 반경 팔 10 + 확폭 테이퍼 10. */ +export const REUSE_MARGIN_M = PLAN_RADIUS_ARM_M + CURVE_WIDENING_TAPER_M; + +type Side = "left" | "right"; +type Span = [number, number, number, Side]; + +const round = (value: number, digits: number): number => Number(value.toFixed(digits)); + +/** 누가거리를 글로 쓸 때(열쇠 · 초안 칸) — 자릿수 0.01 m 하나. */ +export const stationText = (chainageM: number): string => chainageM.toFixed(2); + +/** 측점 열쇠 — 격자 = `grid:k`(k = 원래 격자 번호 · 노선 모양이 바뀌어도 같은 번호) · 관 = `pipe:` · + * 구조물 = `structure:`(구간형 시작 · 끝은 `:start` · `:end`) · 시종점 = `end:BP` · `end:EP`. */ +export function stationKey( + kind: StationKind, + ref: number | string, + role: "start" | "anchor" | "end" = "anchor", +): string { + if (kind === "grid") return `grid:${ref}`; + if (kind === "end") return `end:${ref}`; + return `${kind}:${ref}${role === "anchor" ? "" : `:${role}`}`; +} + +/** 짝: `curve_widening_m` — 직선 · 45 m 이상 · 값 없음은 0. */ +export function curveWideningM(radiusM: number | null): number { + if (radiusM === null) return 0; + for (const [low, high, widening] of CURVE_WIDENING_TABLE_M) + if (low <= radiusM && radiusM < high) return widening; + return 0; +} + +/** 짝: `circumradius_2d`. */ +function circumradius(p0: Vertex, p1: Vertex, p2: Vertex): number { + const a = Math.hypot(p1[0] - p2[0], p1[1] - p2[1]); + const b = Math.hypot(p0[0] - p2[0], p0[1] - p2[1]); + const c = Math.hypot(p0[0] - p1[0], p0[1] - p1[1]); + if (a < 0.01 || b < 0.01 || c < 0.01) return Infinity; + const area2 = Math.abs((p1[0] - p0[0]) * (p2[1] - p0[1]) - (p2[0] - p0[0]) * (p1[1] - p0[1])); + if (area2 < 1e-9) return Infinity; + return (a * b * c) / (2 * area2); +} + +const pointAt = (line: ReadonlyArray, chainageM: number): Vertex => + stationFrameAt(line, chainageM)?.origin ?? line[0]; + +/** 짝: `_design_curve_spans` — [BC, EC, 반경, 바깥쪽]. */ +export function designSpans( + line: ReadonlyArray, + curves: ReadonlyArray, +): Span[] { + const spans: Span[] = []; + for (const curve of curves) { + const [span] = curveSpans(line, [curve]); + if (!span) continue; + const { start, apex, end } = curve; + const cross = + (apex[0] - start[0]) * (end[1] - apex[1]) - (apex[1] - start[1]) * (end[0] - apex[0]); + spans.push([span[0], span[1], curve.radius_m, cross > 0 ? "right" : "left"]); + } + return spans.sort((a, b) => a[0] - b[0] || a[1] - b[1] || a[2] - b[2]); +} + +/** 짝: `_design_curve_widenings` — 곡선 안 = 설계 반경 · 표값 · 앞뒤 테이퍼 · 겹치면 큰 확폭 · 작은 반경. */ +export function designCurveWidenings( + stations: ReadonlyArray, + spans: ReadonlyArray, +): { radii: Array; sides: Array; widenings: number[] } { + const radii: Array = stations.map(() => null); + const sides: Array = stations.map(() => null); + const widenings = stations.map(() => 0); + for (const [start, end, radius, side] of spans) { + const table = curveWideningM(radius); + stations.forEach((chainage, index) => { + const inside = start - 1e-9 <= chainage && chainage <= end + 1e-9; + const current = radii[index]; + if (inside && (current === null || radius < current)) radii[index] = round(radius, 3); + if (table <= 0) return; + let amount = table; + if (!inside) { + const distance = chainage < start ? start - chainage : chainage - end; + if (distance > CURVE_WIDENING_TAPER_M) return; + amount = round(table * (1 - distance / CURVE_WIDENING_TAPER_M), 4); + } + if (amount > widenings[index]) { + widenings[index] = amount; + sides[index] = side; + } + }); + } + return { radii, sides, widenings }; +} + +/** 짝: `_plan_radii` — 곡선표 없는 옛 노선 · 앞뒤 팔 세 점 외접원 · 바깥쪽. */ +export function planRadii( + line: ReadonlyArray, + stations: ReadonlyArray, + total: number, +): { radii: Array; sides: Array } { + const arm = Math.min(PLAN_RADIUS_ARM_M, Math.max(total / 2, 0)); + const radii: Array = []; + const sides: Array = []; + for (const center of stations) { + const back = Math.max(0, center - arm); + const ahead = Math.min(total, center + arm); + if (arm < 0.5 || center - back < arm * 0.5 || ahead - center < arm * 0.5) { + radii.push(null); + sides.push(null); + continue; + } + const trio = [back, center, ahead].map((value) => pointAt(line, value)); + const radius = circumradius(trio[0], trio[1], trio[2]); + if (!Number.isFinite(radius) || radius >= PLAN_RADIUS_STRAIGHT_M) { + radii.push(null); + sides.push(null); + continue; + } + radii.push(round(radius, 3)); + const cross = + (trio[1][0] - trio[0][0]) * (trio[2][1] - trio[1][1]) - + (trio[1][1] - trio[0][1]) * (trio[2][0] - trio[1][0]); + sides.push(cross > 0 ? "right" : cross < 0 ? "left" : null); + } + return { radii, sides }; +} + +/** 짝: `_curve_widenings` — 확폭 측점 덩어리 바깥으로 테이퍼(이웃 측점을 봄). */ +export function curveWidenings( + stations: ReadonlyArray, + radii: ReadonlyArray, + outerSides: ReadonlyArray, +): { widenings: number[]; sides: Array } { + const base = radii.map(curveWideningM); + const widenings = [...base]; + const sides = [...outerSides]; + const runs: Array<[number, number]> = []; + let start: number | null = null; + base.forEach((value, index) => { + if (value > 0 && outerSides[index] !== null) { + if (start === null) start = index; + } else if (start !== null) { + runs.push([start, index - 1]); + start = null; + } + }); + if (start !== null) runs.push([start, base.length - 1]); + for (const [first, last] of runs) { + for (const [edge, step] of [ + [first, -1], + [last, 1], + ]) { + for (let index = edge + step; index >= 0 && index < base.length; index += step) { + const distance = Math.abs(stations[index] - stations[edge]); + if (distance > CURVE_WIDENING_TAPER_M || base[index] > 0) break; + const tapered = round(base[edge] * Math.max(0, 1 - distance / CURVE_WIDENING_TAPER_M), 4); + if (tapered > widenings[index]) { + widenings[index] = tapered; + sides[index] = outerSides[edge]; + } + } + } + } + return { widenings, sides }; +} + +export interface StationsOptions { + intervalM?: number; + includeEndpoint?: boolean; + stationSnap?: boolean; + /** 구조물 정의(종류 · 군 · 자리꼴) — 심는 측점 판정. */ + types: ReadonlyArray; + /** 종류 → 옵션 기본값(카드 이름) — `planStations` 그대로. */ + registry?: Readonly>>; +} + +/** 원래 격자 번호 — 스냅으로 옮긴 측점은 사라진 원래 격자 자리 중 가장 가까운 번호를 이어받음. */ +function gridIndices( + original: number[], + snapped: number[], + intervalM: number, +): Map { + const has = (list: number[], value: number): boolean => + list.some((other) => Math.abs(other - value) < 1e-9); + const gone = original + .map((value, index) => ({ value, index })) + .filter((o) => !has(snapped, o.value)); + const out = new Map(); + for (const value of snapped) { + const same = original.findIndex((other) => Math.abs(other - value) < 1e-9); + if (same >= 0) { + out.set(value, same); + continue; + } + const near = gone.sort((a, b) => Math.abs(a.value - value) - Math.abs(b.value - value))[0]; + out.set(value, near ? near.index : Math.round(value / intervalM)); + if (near) gone.splice(gone.indexOf(near), 1); + } + return out; +} + +/** 노선 길이(m) — 길이 0 선분은 0(서버 `_clean_polyline`). */ +const lineLength = (line: ReadonlyArray): number => + line.reduce( + (sum, vertex, index) => + index ? sum + Math.hypot(vertex[0] - line[index - 1][0], vertex[1] - line[index - 1][1]) : 0, + 0, + ); + +/** 측점 한 벌 — 서버 `generate_sections` 측점 목록과 같은 자리 · 종류 · 틀 · 반경 · 확폭(지반 null). */ +export function buildStations( + line: ReadonlyArray, + curves: ReadonlyArray, + pipes: ReadonlyArray, + structures: ReadonlyArray, + options: StationsOptions, +): PipelineStation[] { + if (line.length < 2) return []; + const intervalM = options.intervalM ?? SECTION_STATION_INTERVAL_M; + const total = lineLength(line); + const original = stationsAlong( + line, + intervalM, + options.includeEndpoint ?? SECTION_INCLUDE_ENDPOINT, + ); + const spans = designSpans(line, curves); + const grid = options.stationSnap + ? snapCurveStations( + original, + spans.map(([bc, ec]) => [bc, ec] as const), + ) + : original; + const gridIndex = gridIndices(original, grid, intervalM); + + // 심는 측점(관 · 구조물) — 격자 0.005 m 안이면 격자 자리에 얹고 서로 겹치면 앞선 것만(서버 `extras`). + const pipeIds = new Set(pipes.map((pipe) => pipe.id ?? `pipe-${pipe.chainage_m.toFixed(2)}`)); + const planted = new Map(); + for (const station of planStations(pipes, structures, options.types, options.registry)) { + if (station.chainage_m < 0 || station.chainage_m > total) continue; + const near = grid.find((value) => Math.abs(value - station.chainage_m) < SNAP_M); + const at = near ?? station.chainage_m; + if ([...planted.keys()].some((taken) => Math.abs(taken - at) < SNAP_M)) continue; + const owner = station.owners.find((id) => pipeIds.has(id)) ?? station.owners[0]; + planted.set(at, { + kind: pipeIds.has(owner) ? "pipe" : "structure", + id: owner, + role: station.role, + }); + } + const chainages = [...new Set([...grid, ...planted.keys()])].sort((a, b) => a - b); + + const fromSpans = spans.length ? designCurveWidenings(chainages, spans) : null; + const measured = fromSpans ? null : planRadii(line, chainages, total); + const tapered = measured ? curveWidenings(chainages, measured.radii, measured.sides) : null; + const radii = fromSpans?.radii ?? measured!.radii; + const sides = fromSpans?.sides ?? tapered!.sides; + const widenings = fromSpans?.widenings ?? tapered!.widenings; + + return chainages.map((chainage, index) => { + const frame = stationFrameAt(line, chainage)!; + const tangent: Vertex = [frame.left[1], -frame.left[0]]; + const mark = planted.get(chainage); + let kind: StationKind = "grid"; + let key = stationKey("grid", gridIndex.get(chainage) ?? Math.round(chainage / intervalM)); + if (index === 0 || Math.abs(chainage - total) <= 1e-6) { + kind = "end"; + key = stationKey("end", index === 0 ? "BP" : "EP"); + } else if (mark) { + kind = mark.kind; + key = stationKey(mark.kind, mark.id, mark.role as "start" | "anchor" | "end"); + } + return { + key, + chainageM: round(chainage, 6), + x: round(frame.origin[0], 6), + y: round(frame.origin[1], 6), + azimuthDeg: round( + (((Math.atan2(tangent[0], tangent[1]) * 180) / Math.PI + 360) % 360) as number, + 6, + ), + left: [round(frame.left[0], 9), round(frame.left[1], 9)], + kind, + id: mark?.id ?? null, + ground: null, + planRadiusM: radii[index], + curveOuterSide: sides[index], + curveWideningM: widenings[index], + }; + }); +} diff --git a/resources/tester/helper_344_3_pipeline_stations.cjs b/resources/tester/helper_344_3_pipeline_stations.cjs new file mode 100644 index 000000000..7bdacd20d --- /dev/null +++ b/resources/tester/helper_344_3_pipeline_stations.cjs @@ -0,0 +1,75 @@ +/* PLAN 344-3 · 346-1 측점 한 벌 거울 헬퍼 — 진짜 `B05_Profile/logic/B05_Profile_Sections_Stations.ts` · + * `A00_Common/design/design_pipeline_stations.ts`(단계 · 범위 밖 재사용)를 그 자리에서 트랜스파일해 Node 로 돌림. + * 입력 argv[2] JSON(파이썬과 같은 노선 · 곡선표 · 관 · 구조물) · 표준출력 JSON 한 벌. + * 짝 = `test_344_3_pipeline_stations_mirror.py`. */ +const fs = require("fs"); +const path = require("path"); +const ts = require(path.join(__dirname, "..", "..", "config", "node_modules", "typescript")); +const CATALOG = require("./helper_322_catalog_module.cjs"); // 구조물 표(322-1) + +const ROOT = path.join(__dirname, "..", ".."); +const cache = new Map(); +function loadTs(full) { + if (cache.has(full)) return cache.get(full); + const js = ts.transpileModule(fs.readFileSync(full, "utf8"), { + compilerOptions: { module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ES2020 }, + }).outputText; + const box = { exports: {} }; + cache.set(full, box.exports); + const localRequire = (request) => { + if (CATALOG.isCatalogRequest(request)) return CATALOG; + if (request.startsWith(".")) return loadTs(path.join(path.dirname(full), `${request}.ts`)); + return require(request); + }; + new Function("exports", "module", "require", js)(box.exports, box, localRequire); + return box.exports; +} + +const logic = loadTs(path.join(ROOT, "B05_Profile", "logic", "B05_Profile_Sections_Stations.ts")); +const stage = loadTs(path.join(ROOT, "A00_Common", "design", "design_pipeline_stations.ts")); +const job = JSON.parse(fs.readFileSync(process.argv[2], "utf8")); + +const out = { + constants: { + interval: logic.SECTION_STATION_INTERVAL_M, + endpoint: logic.SECTION_INCLUDE_ENDPOINT, + taper: logic.CURVE_WIDENING_TAPER_M, + table: logic.CURVE_WIDENING_TABLE_M, + arm: logic.PLAN_RADIUS_ARM_M, + straight: logic.PLAN_RADIUS_STRAIGHT_M, + }, + cases: job.cases.map((c) => + logic.buildStations(c.line, c.curves, job.pipes, job.structures, { + types: job.types, + stationSnap: c.snap, + }), + ), + text: [0, 12.345, 40, 699.996].map(logic.stationText), +}; + +// 노선 한 점 옮김 — 단계가 범위 밖 측점을 앞 객체 그대로 · 열쇠 그대로 +const run = (line, curves, prev, range) => + stage.createStationsStage({ types: () => job.types })({ + generation: 1, + range, + signal: new AbortController().signal, + inputs: { route: { stationSnap: false }, pipes: job.pipes, structures: job.structures }, + out: { route: line ? { line, curves, chainage: [], lengthM: 0 } : null }, + before: {}, + prev, + }); +const before = run(job.move.line, job.move.curves, null, null); +const after = run(job.move.movedLine, job.move.movedCurves, before, { + sources: ["route"], + fromM: job.move.fromM, + toM: Infinity, + keys: [], +}); +const byKey = new Map(before.map((s) => [s.key, s])); +out.move = { + before: before.map((s) => [s.key, s.chainageM]), + after: after.map((s) => [s.key, s.chainageM]), + same: after.map((s) => byKey.get(s.key) === s), + empty: run(null, null, null, null), +}; +process.stdout.write(JSON.stringify(out)); diff --git a/resources/tester/test_344_3_pipeline_stations_mirror.py b/resources/tester/test_344_3_pipeline_stations_mirror.py new file mode 100644 index 000000000..80a6a9157 --- /dev/null +++ b/resources/tester/test_344_3_pipeline_stations_mirror.py @@ -0,0 +1,261 @@ +"""PLAN 344-3 · 346-1 — 계산 묶음 stations 단계 거울(파이썬 ↔ TS). + +· 서버 `generate_sections` 측점 목록 = TS `buildStations`(`B05_Profile/logic/B05_Profile_Sections_Stations.ts`) + — 누가거리 · 종류 · 열쇠 · 자리 · 방위각 · 평면 반경 · 바깥쪽 · 확폭(곡선표 · 스냅 · 곡선표 없음 셋) +· 상수 = config_system_design · Engine_Sections_Core 같은 값 +· 노선 한 점 옮김 뒤 앞 구간 측점 열쇠 그대로 · 범위 밖은 앞 객체 그대로(단계 `design_pipeline_stations.ts`) +짝: `helper_344_3_pipeline_stations.cjs`(실제 TS 를 Node 로 돌림). +""" + +from __future__ import annotations + +import json +import math +import subprocess +import sys +import tempfile +from types import SimpleNamespace +from pathlib import Path + +import numpy as np +import pytest + +HERE = Path(__file__).resolve().parent +ROOT = HERE.parents[1] +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_Structures_Schema import structure_type_map # noqa: E402 +from common_util.common_util_drainage_pipes import parse_pipe_points # noqa: E402 +from common_util.common_util_structure_station import plan_stations # noqa: E402 +from config import config_system_design as cfg # noqa: E402 + +pytestmark = pytest.mark.skipif( + not (ROOT / "config" / "node_modules" / "typescript").is_dir(), + reason="프론트엔드 의존성 미설치", +) + +_TOL = 1.5e-6 # 두 쪽 다 소수 6자리 반올림 + +PIPES = [ + {"id": "p57", "chainage_m": 57.0, "source": "user"}, + {"id": "p140", "chainage_m": 140.0, "source": "user"}, # 격자 위 — 관 측점이 위 + { + "id": "p-rev", + "chainage_m": 250.0, + "source": "user", + "facility": "revetment", + "start_m": 245.0, + "end_m": 260.0, + }, + {"id": "p333", "chainage_m": 333.0, "source": "user"}, +] +STRUCTURES = [ + { + "structure_id": "s-wall", + "type_id": "retaining_wall", + "placement": "interval", + "chainage_m": 410.0, + "start_m": 400.0, + "end_m": 430.0, + }, +] + + +class _Flat: + """평지 표본기 — 측점 부분만 대조(지반은 344-1 거울 몫).""" + + def sample_xy(self, xy): + xy = np.asarray(xy) + return np.zeros(len(xy)), np.ones(len(xy), dtype=bool) + + +def _route(nodes, radii): + """꺾임점마다 원호(반경) — 그린 노선 · 곡선표(시작 · 꼭짓점 · 끝 · 반경).""" + line = [list(nodes[0])] + curves = [] + for i in range(1, len(nodes) - 1): + p, b, n = (np.asarray(v, dtype=float) for v in (nodes[i - 1], nodes[i], nodes[i + 1])) + u = (b - p) / np.linalg.norm(b - p) + v = (n - b) / np.linalg.norm(n - b) + delta = math.acos(max(-1.0, min(1.0, float(u @ v)))) + r = radii[i - 1] + t = r * math.tan(delta / 2) + bc, ec = b - u * t, b + v * t + turn = 1.0 if u[0] * v[1] - u[1] * v[0] > 0 else -1.0 + center = bc + turn * r * np.array([-u[1], u[0]]) + a0 = math.atan2(*(bc - center)[::-1]) + for k in range(21): + a = a0 + turn * delta * k / 20 + line.append([float(center[0] + r * math.cos(a)), float(center[1] + r * math.sin(a))]) + curves.append({"start": bc.tolist(), "apex": b.tolist(), "end": ec.tolist(), "radius_m": r}) + line.append(list(nodes[-1])) + return line, curves + + +NODES = [(0.0, 0.0), (200.0, 0.0), (300.0, 120.0), (520.0, 130.0), (600.0, 40.0)] +RADII = [25.0, 12.0, 40.0] +LINE, CURVES = _route(NODES, RADII) +MOVED_LINE, MOVED_CURVES = _route([*NODES[:-1], (640.0, 90.0)], RADII) + + +def _types() -> list[dict]: + return [ + { + "type_id": t.type_id, + "group": t.group, + "placement": t.placement, + "managed_by": t.managed_by, + } + for t in structure_type_map().values() + ] + + +def _server(line, curves, snap): + extras = tuple( + (float(s["chainage_m"]), str(s["title"])) + for s in plan_stations( + parse_pipe_points(PIPES), [SimpleNamespace(options=None, **item) for item in STRUCTURES] + ) + ) + data = core.generate_sections( + line, + _Flat(), + core.SectionGenerationOptions(extra_stations=extras), + design_curves=curves, + station_snap=snap, + ) + return data["longitudinal"]["stations"] + + +def _first_changed_m() -> float: + """옮긴 꼭짓점의 앞 접선점(BC) 누가거리 — 노선 = 처음 바뀐 접선점부터 끝.""" + _gap, at = core._project_chainage( + *core._clean_polyline(np.asarray(LINE)), tuple(CURVES[-1]["start"]) + ) + return at + + +CASES = [ + {"name": "곡선표", "line": LINE, "curves": CURVES, "snap": False}, + {"name": "스냅", "line": LINE, "curves": CURVES, "snap": True}, + {"name": "곡선표없음", "line": LINE, "curves": [], "snap": False}, +] + + +@pytest.fixture(scope="module") +def r() -> dict: + job = { + "cases": CASES, + "pipes": PIPES, + "structures": STRUCTURES, + "types": _types(), + "move": { + "line": LINE, + "curves": CURVES, + "movedLine": MOVED_LINE, + "movedCurves": MOVED_CURVES, + "fromM": _first_changed_m(), + }, + } + with tempfile.TemporaryDirectory() as folder: + path = Path(folder) / "job.json" + path.write_text(json.dumps(job), encoding="utf-8") + proc = subprocess.run( # noqa: S603 — 고정 실행 파일 + ["node", str(HERE / "helper_344_3_pipeline_stations.cjs"), str(path)], + capture_output=True, + text=True, + encoding="utf-8", + timeout=60, + ) + assert proc.returncode == 0, proc.stderr + return json.loads(proc.stdout) + + +def test_상수_같음(r): + c = r["constants"] + assert c["interval"] == cfg.SECTION_STATION_INTERVAL_M + assert c["endpoint"] == cfg.SECTION_INCLUDE_ENDPOINT + assert c["taper"] == cfg.CURVE_WIDENING_TAPER_M + assert [tuple(row) for row in c["table"]] == list(cfg.CURVE_WIDENING_TABLE_M) + assert c["arm"] == core.PLAN_RADIUS_ARM_M and c["straight"] == core.PLAN_RADIUS_STRAIGHT_M + + +def _close(a, b) -> bool: + if a is None or b is None: + return a is b + return abs(float(a) - float(b)) <= _TOL + + +_KIND = {"bp": {"end"}, "ep": {"end"}, "regular": {"grid"}, "irregular": {"pipe", "structure"}} + + +@pytest.mark.parametrize("index", range(len(CASES)), ids=[c["name"] for c in CASES]) +def test_서버_측점_목록과_같음(r, index): + case = CASES[index] + server = _server(case["line"], case["curves"], case["snap"]) + ours = r["cases"][index] + assert [s["chainage_m"] for s in server] == pytest.approx( + [s["chainageM"] for s in ours], abs=_TOL + ) + problems = [] + for py, ts in zip(server, ours, strict=True): + where = f"{py['chainage_m']}" + if ts["kind"] not in _KIND[py["kind"]]: + problems.append(f"{where} 종류 {py['kind']} vs {ts['kind']}") + pairs = { + "x": (py["center_x"], ts["x"]), + "y": (py["center_y"], ts["y"]), + "방위각": (py["azimuth_deg"], ts["azimuthDeg"]), + "반경": (py["plan_radius_m"], ts["planRadiusM"]), + "확폭": (py["curve_widening_m"], ts["curveWideningM"]), + "좌측x": (py["frame"]["left_xy"][0], ts["left"][0]), + "좌측y": (py["frame"]["left_xy"][1], ts["left"][1]), + } + for label, (a, b) in pairs.items(): + if not _close(a, b): + problems.append(f"{where} {label} {a} vs {b}") + if py["curve_outer_side"] != ts["curveOuterSide"]: + problems.append(f"{where} 바깥쪽 {py['curve_outer_side']} vs {ts['curveOuterSide']}") + assert not problems, problems[:10] + # 확폭 · 반경이 실제로 섬(시험이 빈 값만 대조하지 않게) + assert any(s["curveWideningM"] > 0 for s in ours) and any(s["planRadiusM"] for s in ours) + + +def test_열쇠(r): + ours = r["cases"][0] + keys = {s["key"]: s for s in ours} + assert ours[0]["key"] == "end:BP" and ours[-1]["key"] == "end:EP" + assert keys["pipe:p57"]["chainageM"] == 57.0 and keys["pipe:p57"]["id"] == "p57" + assert keys["pipe:p140"]["kind"] == "pipe" and "grid:7" not in keys # 격자 위 관 = 관 열쇠 하나 + assert {"pipe:p-rev:start", "pipe:p-rev", "pipe:p-rev:end"} <= keys.keys() + assert {"structure:s-wall:start", "structure:s-wall", "structure:s-wall:end"} <= keys.keys() + for s in ours: + if s["kind"] == "grid": + assert s["key"] == f"grid:{round(s['chainageM'] / 20)}" + assert len(keys) == len(ours) # 열쇠 겹침 없음 + assert r["text"] == ["0.00", "12.35", "40.00", "700.00"] + + +def test_스냅_측점은_원래_격자_번호(r): + plain = {s["key"]: s["chainageM"] for s in r["cases"][0] if s["kind"] == "grid"} + snapped = {s["key"]: s["chainageM"] for s in r["cases"][1] if s["kind"] == "grid"} + assert plain.keys() == snapped.keys() # 옮겨도 번호 그대로 · 겹침 없음 + moved = {k: (plain[k], snapped[k]) for k in plain if plain[k] != snapped[k]} + assert moved and all(abs(a - b) <= 10 for a, b in moved.values()) + + +def test_노선_한_점_옮김_앞_구간_열쇠_그대로(r): + m = r["move"] + from_m = _first_changed_m() + before = {k: c for k, c in m["before"]} + after = dict(m["after"]) + ahead = [k for k, c in m["before"] if c < from_m] + assert ahead and all(after.get(k) == before[k] for k in ahead) + # 범위(옮긴 접선점 − 여유 20 m) 밖은 앞 객체 그대로 · 안은 새로 셈 + for (key, chainage), same in zip(m["after"], m["same"], strict=True): + if chainage < from_m - 20 and key in before: + assert same, key + assert not all(m["same"]) + assert m["empty"] == []