Files
Aislo/B05_Profile/logic/B05_Profile_Sections_Stations.ts
T
eomsangdonandClaude Opus 5.5 281c71b55b refactor(logic): 348-1 순수 계산 TS 49 파일 <페이지>/logic/ 로 옮김 — 이름 앞머리 그대로 · 동작 고침 없음
- 옮김: B04 2 · B05 23 · B06 11 · A00_Common/design 13 (9,299 줄) · 이미 logic/ 에 있던 16 파일 그대로
- 안 옮김: 묶음 입구(package.json build:*) · 파이썬 · 순수지만 계산 아닌 8 (보기 결선 · 콜백 · API 타입 · 갈아끼움 알림판)
- import 266 줄(142 파일) · 시험 · 주석 · 짝 표시 문자열 경로 고침 · B05 · B06 CLAUDE.md 짝 줄 logic/
- 시험 도우미 새 경로: 가짜 require 열쇠 · B05()/B06() 부름 · 형제 import 자리 · tsc 묶어 돌리는 시험 · 정규식 · rglob
- design_view_cross 줄 한도 시험 685 → 688 (긴 import 경로 줄바꿈 3줄)
- 확인: typecheck 0 · 서버 묶음 넷(corridor · server-calc · b07-extent · b07-note) 다시 지어 코드 같음(//#region 경로 주석만 다름) · 관련 시험 270 파일 — 남은 실패 19 는 옮기기 전과 같음

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014utrjfoW831Vc5wJUd13kL
2026-10-16 13:44:50 +09:00

349 lines
14 KiB
TypeScript

/* =============================================================================
* B05_Profile/logic/B05_Profile_Sections_Stations.ts
* 측점 한 벌(PLAN 344-3 뼈대 · 346-1) — 그린 노선 + 관 · 구조물 → 측점(지반 null). 계산 묶음 stations
* 단계(`A00_Common/design/logic/design_pipeline_stations.ts`)가 부름. 측점 열쇠 규칙 한 곳(`stationKey` ·
* 계약 = `A00_Common/design/logic/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 { stationKey as commonStationKey } from "../../common_util/common_util_station_key";
import type {
DesignCurve,
PipelineStation,
StationKind,
Vertex,
} from "../../A00_Common/design/logic/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`.
* 규칙 한 벌 = `common_util_station_key`(파이썬 짝) — 여기는 꼴만 옮김. */
export function stationKey(
kind: StationKind,
ref: number | string,
role: "start" | "anchor" | "end" = "anchor",
): string {
if (kind === "end") return commonStationKey(ref === "BP" ? "bp" : "ep", 0, null);
if (kind === "grid") return commonStationKey("regular", Number(ref), 1); // ref = 격자 번호(간격 1)
const base = commonStationKey(kind, 0, null, String(ref));
// ponytail: 구간형 꼬리 — sub2 가 공용 함수에 넣으면 그 인자로 옮김
return role === "anchor" ? base : `${base}:${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],
};
});
}