Files
Aislo/B05_Profile/logic/B05_Profile_Pipeline_RouteGround.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

189 lines
8.3 KiB
TypeScript

/* =============================================================================
* B05_Profile_Pipeline_RouteGround.ts
* 실시간 계산 묶음(PLAN 346-1)의 **route · ground 단계** — 계약 = `design_pipeline_types.ts`.
*
* route — 노선 초안(RouteDraft) → 그린 노선(원호 포함) · 누가거리. 선은 이미 있는 짝
* `buildEditedPolyline`(↔ `common_util_route_polyline.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] };
}
/** 초안 → 그린 노선 · 곡선표(계약 꼴). 길이를 붙든 곡선은 교각에 맞춰 R 을 다시 잡음(편집기
* `markEdited` 와 같은 차례). */
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,
curves: built.curves.map(({ start, apex, end, radius_m }) => ({ start, apex, end, radius_m })),
};
}
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 };
}