Files
Aislo/B05_Profile/logic/B05_Profile_Pipeline_RouteGround.ts
T
eomsangdonandClaude Opus 5.5 7aca51596a fix(475-1 노선 자리): 노선 선 소수 자리 한 벌 — 서버 노선 geojson x · y 4자리(= 계획노선 csv) · 브라우저 초안 그린 선 · 곡선표도 같은 자리
- B05_Profile_Engine._route_geojson x · y 3 → 4자리(z 3자리 그대로) · 짝 머리 표시
- draftLine 그린 선 x · y 4자리 · 곡선 start · apex · end 4자리 · R 3자리(= RouteCurve.as_dict) · 파이썬 round 짝(toFixed)
- 교점 끌기 재현 브라우저 ↔ 서버 차 0.81 → 0.00 ㎥ · 옹벽 + 추가 측점 0.88 → 0.00 ㎥(test_475_1_route_digits)
- 472-1 헬퍼 — 옮김 인자 · 페이지 배선(stationsAt · storedPvi) · 단면 모양 = 따라간 구조물(헛 차 덤 고침) · golden 값 그대로
- 옛 시험 두 곳(346_1) — 안 깎은 선 단언 → 서버 저장 자리 단언
- 관 지문 허용오차 주석 · calculations R1 줄

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-19 14:23:23 +09:00

201 lines
9.0 KiB
TypeScript

/* =============================================================================
* B05_Profile_Pipeline_RouteGround.ts
* 실시간 계산 묶음(PLAN 346-1)의 **route · ground 단계** — 계약 = `design_pipeline_types.ts`.
*
* route — 노선 초안(RouteDraft) → 그린 노선(원호 포함) · 누가거리. 선은 이미 있는 짝
* `buildEditedPolyline`(↔ `common_util_route_polyline.py`) 그대로 · 자릿수 = 서버 저장 선
* (x · y 4자리 ↔ `_route_geojson` · `write_route_csv` · 475-1 · `test_475_1_route_digits.py`).
* ground — 측점(지반 null) → 지반 채운 측점 · 종단 지반선(1 m). 표본 = 지형 격자 짝
* (`common_util_surface_sampler.ts` · `B05_Profile_Sections_Ground.ts`) · 격자가 없거나
* (아직 못 받음 · DTM 아님) 분석 지형 ≠ 설계 지형이면 주입받은 서버 표본(`remote`).
* 바뀐 범위 밖 측점 · 종단은 앞 출력 재사용(국부 연산) — 자리 · 축 · 지형이 같을 때만.
*
* 화면 · fetch · 세션 안 씀 — 격자 · 서버 길은 부르는 쪽이 넣음(`B05_Profile_Api_TerrainGrid.ts`).
* ========================================================================== */
import type {
DesignCurve,
GroundOutput,
GroundSample,
PipelineStation,
RouteDraft,
RouteLine,
StageFn,
StationGround,
Vertex,
} from "../../A00_Common/design/logic/design_pipeline_types";
import { type DtmGrid, heightAt } from "../../common_util/common_util_surface_sampler";
import { buildEditedPolyline } from "./B05_Profile_UI_RouteEdit_Curve";
import { applyArcLocks } from "./B05_Profile_UI_RouteEdit_Edits";
import {
crossOffsets,
longitudinalPoints,
round6,
SECTION_CROSS_HALF_WIDTH_M,
SECTION_CROSS_SAMPLE_INTERVAL_M,
} from "./B05_Profile_Sections_Ground";
/* ── route ─────────────────────────────────────────────────────────────── */
export interface RouteStageDeps {
/** 곡선 기본 반지름(m) — 편집기 `minRadiusM`(계획 기준 · 0 = 없음). */
minRadiusM(): number;
/** 초안이 없을 때(= 저장분 그대로) 그릴 노선 · 곡선표 — 없으면 null. */
savedLine(): { line: Vertex[]; curves: DesignCurve[] } | null;
}
/** 정점마다 누가거리 — 첫 점 0. */
export function routeLineOf(line: Vertex[], curves: DesignCurve[] = []): RouteLine {
const chainage = [0];
for (let i = 1; i < line.length; i += 1) {
chainage.push(
chainage[i - 1] + Math.hypot(line[i][0] - line[i - 1][0], line[i][1] - line[i - 1][1]),
);
}
return { line, curves, chainage, lengthM: chainage[chainage.length - 1] };
}
/** 파이썬 `round(v, n)` 짝 — 둘 다 double 의 정확한 값으로 반올림(4 · 3자리 동점 없음). */
const roundTo = (value: number, digits: number): number => Number(value.toFixed(digits));
const xy4 = ([x, y]: Vertex): Vertex => [roundTo(x, 4), roundTo(y, 4)];
/** 초안 → 그린 노선 · 곡선표(계약 꼴). 길이를 붙든 곡선은 교각에 맞춰 R 을 다시 잡음(편집기
* `markEdited` 와 같은 차례). 자릿수 = 서버가 저장하는 선(475-1 · 짝 = 계획노선 csv
* `write_route_csv` · 곡선표 `RouteCurve.as_dict` · 노선 geojson `_route_geojson` x · y 4자리 · R 3자리 ·
* `test_475_1_route_digits.py`). */
export function draftLine(
draft: RouteDraft,
minRadiusM: number,
): { line: Vertex[]; curves: DesignCurve[] } {
const nodes = draft.nodes.map((node): Vertex => [node[0], node[1]]);
const radius = [...draft.curveRadius];
applyArcLocks(nodes, [...draft.curveLock], [...draft.curveArc], radius);
const built = buildEditedPolyline(nodes, draft.curveOn, radius, minRadiusM);
return {
line: built.vertices.map(xy4),
curves: built.curves.map(({ start, apex, end, radius_m }) => ({
start: xy4(start),
apex: xy4(apex),
end: xy4(end),
radius_m: roundTo(radius_m, 3),
})),
};
}
export function createRouteStage(deps: RouteStageDeps): StageFn<"route"> {
return (ctx) => {
const draft = ctx.inputs.route;
const drawn = draft ? draftLine(draft, deps.minRadiusM()) : deps.savedLine();
if (!drawn || drawn.line.length < 2) throw new Error("그릴 노선이 없습니다.");
return routeLineOf(drawn.line, drawn.curves);
};
}
/* ── ground ────────────────────────────────────────────────────────────── */
export interface GroundStageDeps {
/** 지금 쓸 수 있는 설계 지형 격자 — 아직 못 받았거나 없으면 null(그동안은 서버 길). */
grid(): { grid: DtmGrid; design: "las" | "sheet" } | null;
/** 서버 표본(`/route/elevations`) — 점 차례 그대로 · 무효 null · `signal` 로 끊김. */
remote(
points: Vertex[],
terrain: string | null,
signal: AbortSignal,
): Promise<Array<number | null>>;
/** 횡단 반폭(m) — 비면 서버 기본(20 m). */
crossHalfWidthM?(): number | null;
}
const samePlace = (a: PipelineStation, b: PipelineStation): boolean =>
a.x === b.x && a.y === b.y && a.left[0] === b.left[0] && a.left[1] === b.left[1];
const outside = (chainageM: number, range: { fromM: number; toM: number } | null): boolean =>
range !== null && (chainageM < range.fromM || chainageM > range.toM);
export function createGroundStage(deps: GroundStageDeps): StageFn<"ground"> {
/** 앞 출력이 쓴 지형 · 노선 · 반폭 — 다르면 재사용 안 함. */
let last: { terrain: string; line: Vertex[] | null; halfWidth: number } | null = null;
return async (ctx) => {
const stations = ctx.out.stations ?? [];
const line = ctx.out.route?.line ?? null;
const terrain = ctx.inputs.route?.terrain ?? null;
const halfWidth = deps.crossHalfWidthM?.() ?? SECTION_CROSS_HALF_WIDTH_M;
const offsets = crossOffsets(halfWidth, SECTION_CROSS_SAMPLE_INTERVAL_M);
const local = deps.grid();
const useGrid = local !== null && (terrain === null || terrain === local.design);
const tag = terrain ?? "design";
const reusable = ctx.prev !== null && last?.terrain === tag && last.halfWidth === halfWidth;
const range = ctx.range;
const prevByKey = new Map((reusable ? ctx.prev!.stations : []).map((s) => [s.key, s]));
// 다시 셀 측점 — 범위 안 · 앞 출력에 없음 · 자리나 축이 바뀜.
const kept = stations.map((station) => {
const before = prevByKey.get(station.key);
return before?.ground && outside(station.chainageM, range) && samePlace(before, station)
? before.ground
: null;
});
const long = line ? longitudinalPoints(line) : { chainage: [], xy: [] as Vertex[], lengthM: 0 };
// 종단 — 노선이 그대로면 통째 · 범위 앞쪽은 같은 노선 위라 그대로.
const prevLong = reusable ? ctx.prev!.longitudinal : null;
const longKept: Array<number | null | undefined> = long.chainage.map((c, i) => {
if (!prevLong || prevLong.chainageM[i] !== round6(c)) return undefined;
if (last?.line === line || (range !== null && c < range.fromM)) return prevLong.z[i];
return undefined;
});
const points: Vertex[] = [];
stations.forEach((station, i) => {
if (kept[i]) return;
for (const offset of offsets) {
points.push([station.x + station.left[0] * offset, station.y + station.left[1] * offset]);
}
});
long.xy.forEach((xy, i) => {
if (longKept[i] === undefined) points.push(xy);
});
let heights: Array<number | null>;
if (useGrid) {
heights = points.map(([x, y]) => {
const z = heightAt(local!.grid, x, y);
return Number.isNaN(z) ? null : round6(z);
});
} else {
heights = points.length ? await deps.remote(points, terrain, ctx.signal) : [];
}
let cursor = 0;
const filled: PipelineStation[] = stations.map((station, i) => {
if (kept[i]) return { ...station, ground: kept[i] };
const zs = offsets.map(() => heights[cursor++] ?? null);
return { ...station, ground: stationGround(offsets, zs) };
});
const z = long.chainage.map((_, i) =>
longKept[i] === undefined ? (heights[cursor++] ?? null) : (longKept[i] as number | null),
);
last = { terrain: tag, line, halfWidth };
const output: GroundOutput = {
stations: filled,
longitudinal: { chainageM: long.chainage.map(round6), z },
};
return output;
};
}
/** offset 열 표고 → 계약 꼴(중심 · 좌 · 우 · 중심에서 바깥 차례 · offsetM 양수). */
function stationGround(offsets: number[], zs: Array<number | null>): StationGround {
const left: GroundSample[] = [];
const right: GroundSample[] = [];
let centerZ: number | null = null;
offsets.forEach((offset, i) => {
if (Math.abs(offset) <= 1e-9) centerZ = zs[i];
else if (offset > 0) left.push({ offsetM: round6(offset), z: zs[i] });
else right.unshift({ offsetM: round6(-offset), z: zs[i] });
});
return { centerZ, left, right };
}