feat(설계묶음): 344-3 · 346-1 stations 단계 — 그린 노선 + 관 · 구조물 → 측점 한 벌(열쇠 · 종류 · 틀 · 방위각 · 반경 · 바깥쪽 · 확폭) · 서버 generate_sections 짝
- B05_Profile/logic/B05_Profile_Sections_Stations.ts 새 — buildStations(격자 · 스냅 · 심는 측점 병합 = 이미 있는 짝 그대로) · 평면 반경 · 확폭 짝(_plan_radii · _curve_widenings · _design_curve_widenings) · stationKey · stationText(0.01 m) - A00_Common/design/design_pipeline_stations.ts 새 — createStationsStage · 범위 밖(± 20 m) 측점은 앞 객체 그대로 - Engine_Sections_Core.py 머리에 짝 표시 - 거울 시험 test_344_3_pipeline_stations_mirror 7 건(곡선표 · 스냅 · 곡선표 없음 서버와 같음 · 열쇠 · 한 점 옮김 앞 구간 열쇠 그대로) Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TteK8mfYZetk5P3bEZkyVZ
This commit is contained in:
@@ -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<PipelineStation> | 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<StationsOptions, "stationSnap" | "types"> & {
|
||||
types: () => ReadonlyArray<PlanType>;
|
||||
},
|
||||
): 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);
|
||||
};
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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<readonly [number, number, number]> = [
|
||||
[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:<id>` ·
|
||||
* 구조물 = `structure:<id>`(구간형 시작 · 끝은 `: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<Vertex>, chainageM: number): Vertex =>
|
||||
stationFrameAt(line, chainageM)?.origin ?? line[0];
|
||||
|
||||
/** 짝: `_design_curve_spans` — [BC, EC, 반경, 바깥쪽]. */
|
||||
export function designSpans(
|
||||
line: ReadonlyArray<Vertex>,
|
||||
curves: ReadonlyArray<DesignCurve>,
|
||||
): 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<number>,
|
||||
spans: ReadonlyArray<Span>,
|
||||
): { radii: Array<number | null>; sides: Array<Side | null>; widenings: number[] } {
|
||||
const radii: Array<number | null> = stations.map(() => null);
|
||||
const sides: Array<Side | null> = 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<Vertex>,
|
||||
stations: ReadonlyArray<number>,
|
||||
total: number,
|
||||
): { radii: Array<number | null>; sides: Array<Side | null> } {
|
||||
const arm = Math.min(PLAN_RADIUS_ARM_M, Math.max(total / 2, 0));
|
||||
const radii: Array<number | null> = [];
|
||||
const sides: Array<Side | null> = [];
|
||||
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<number>,
|
||||
radii: ReadonlyArray<number | null>,
|
||||
outerSides: ReadonlyArray<Side | null>,
|
||||
): { widenings: number[]; sides: Array<Side | null> } {
|
||||
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<PlanType>;
|
||||
/** 종류 → 옵션 기본값(카드 이름) — `planStations` 그대로. */
|
||||
registry?: Readonly<Record<string, Record<string, unknown>>>;
|
||||
}
|
||||
|
||||
/** 원래 격자 번호 — 스냅으로 옮긴 측점은 사라진 원래 격자 자리 중 가장 가까운 번호를 이어받음. */
|
||||
function gridIndices(
|
||||
original: number[],
|
||||
snapped: number[],
|
||||
intervalM: number,
|
||||
): Map<number, number> {
|
||||
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<number, number>();
|
||||
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<Vertex>): 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<Vertex>,
|
||||
curves: ReadonlyArray<DesignCurve>,
|
||||
pipes: ReadonlyArray<PlanPipe>,
|
||||
structures: ReadonlyArray<PlanStructure>,
|
||||
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<number, { kind: "pipe" | "structure"; id: string; role: string }>();
|
||||
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],
|
||||
};
|
||||
});
|
||||
}
|
||||
@@ -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));
|
||||
@@ -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"] == []
|
||||
Reference in New Issue
Block a user