/* ============================================================================= * 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 = [ [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`. * 규칙 한 벌 = `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, 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], }; }); }