Files
Aislo/B05_Profile/logic/B05_Profile_Pipeline_Grade.ts
T
eomsangdonandClaude Opus 5.5 172ff8170b refactor(511-B B-6 정리): 운영 부름 0 파이썬 짝 걷음 · 남는 짝 머리에 남는 까닭 · 등록부 until 걷음 · 알려진 차 까닭 · 322_3 flow 빨강 고침
걷은 함수(운영 부름 0 · 전수 확인 = 화면 · 서버 · 체인 · 추천 엔진 · B07 · 시험)
- common_util/common_util_excavation.py 통째(126 줄 · pipe_trench_width_m · wall_trench_area_m2 · wall_trench_width_m · 서버 = Node excavation.trench)
- Engine_Grade_Carry.carry_edits(Node carry_seat 겉) · Route_Save_Parts.seat_route_moved(시험 헬퍼 helper_475_1_moved 로 옮김)
- Engine_Tilt.attach_underdrains · Engine_Structures_Wall.attach_wall_structures · load_wall_structures
- Engine_Grade_Alignment.tangent_elevation(부름 0) · station_key.dedupe_station_keys · loose_chainage
줄 수 전 → 후: excavation.py 126 → 0 · Grade_Carry 178 → 157 · Save_Parts 289 → 269 · Wall 221 → 197 · Tilt 115 → 112 · station_key 141 → 122 · Alignment 657 → 654

남는 짝(머리 「남는 까닭」 한 줄) — Grade · Grade_Profile · Alignment · CurveLaw(2~4 순위 폴백 · 추천) · NodeEdits · SampleExtend · Router_Design · Ranges · Engine_Design · Areas · cross_berm(DTM 아닌 지표면 · Node 못 부를 때 · B07 · 추천) · Tilt · Wall · Culvert(B04 B07 B08) · route_follow · structure_station · station_key · route_polyline(745 줄 · 까닭 = 등록부 R1 줄)

등록부 — until 칸 걷음(G1 = py 표본기 남김 · 까닭 note) · C6 pair = attach_underdrain_specs · F8 pair null
알려진 차 — golden KNOWN 셋 그대로 나타남(R7 표본 출처 · W0 저장 때 경고 · X2 입력 선 자릿수 · 까닭 고침) · underdrain 알려진 차 까닭 · 511_b5 KNOWN_ARC 그대로
계산 자리 창 — 45 줄 · 서버만 아닌 41(바꿀 수 있음 40 + F9) · 서버만 넷(G3 D1 D2 N1 줄마다 까닭) · 옛 까닭 「짝 모으는 중 · B-4」 글 걷음
test_322_3_flow 빨강 — 헬퍼가 @config/config_frontend 흉내(511-A design_calc_server import)
시험 기준 — 걷은 짝 거울 = 걷기 전 파이썬 값 고정(excavation · station_key) · Node carry_seat · attach_underdrain_specs · wall_specs_from

검증: 고친 대상 시험 97 파일 562 통과 · 21 건너뜀 · typecheck 0 · 700 줄 넘는 새 파일 없음 — 요구 320-4

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-22 16:34:08 +09:00

411 lines
19 KiB
TypeScript

/* =============================================================================
* B05_Profile_Pipeline_Grade.ts
* 계산 묶음(PLAN 346-1) **follow · planline · profile 단계** + 바뀐 범위 — 계약 =
* `A00_Common/design/logic/design_pipeline_types.ts`. 셈은 이미 있는 짝을 부르기만(어댑터):
*
* follow — 관 · 구조물 좌표 따라감(`common_util_route_follow`) · 계획고 편집 옮김(`Grade_Carry`
* `carryEdits` · 옛 노선 `before.route` 가 있을 때만 · 기준 = 마지막 종단 편집 때 한 벌 · 400) ·
* 추가 측점 좌표 따라감(459 · `followPlusGround` · 좌표 들고 감 = 서버 [저장] 자리 · 493-3).
* planline — 노선 초안이 없으면 저장 종단 기준 그대로 · 있으면 새 측점마다 같은 자리는 저장 꼭짓점
* 높이 · 바뀐 자리만 새 지반 첫 계획선(`Grade_Ground` · `carryBase`) · 옮긴 시설 측점(바뀐 자리 ·
* 사용자가 옮김)에 427 꼭짓점 다시 세움(459 · 짝 = 파이썬 `B05_Profile_Route_Save_Parts`
* `route_moved_stations` · 서버 세움 = Node `profile.carry` · 거울 시험 test_459_route_seat_mirror.py).
* profile — `buildAlignment`(첫 계획선 + 계획고 편집) — 종단 보기가 쓰는 같은 함수.
*
* 화면 · fetch · 세션 없음(순수) — 저장 기준 · 저장 노선은 부르는 쪽이 넣음.
* ========================================================================== */
import type {
FollowPatch,
PipelineInputs,
PipelineStation,
RangePart,
RouteLine,
StageFn,
Vertex,
} from "../../A00_Common/design/logic/design_pipeline_types";
import {
followPlusGround,
followStructures,
placeOnLine,
projectPipePoints,
type FollowingPoint,
type FollowingStructure,
} from "../../common_util/common_util_route_follow";
import {
followMovedNodes,
PLUS_OWNER_PREFIX,
SAME_STATION_M,
} from "../../common_util/common_util_structure_station";
import {
buildAlignment,
emptyEdits,
type AlignmentBase,
type AlignmentEdits,
} from "./B05_Profile_UI_Profile_Alignment";
import {
carryBase,
carryEdits,
changedSpan,
lineMatcher,
type XY,
} from "./B05_Profile_Grade_Carry";
import { seatBornNodes } from "./B05_Profile_Grade_Seat";
import { SECTION_STATION_INTERVAL_M } from "./B05_Profile_Sections_Stations";
import {
groundFollowingBasePvi,
groundProfile,
type GroundFollowingInput,
} from "./B05_Profile_Grade_Ground";
import { DEFAULT_CURVE_LENGTH_M } from "./B05_Profile_Profile_CurveLaw";
const xy = (line: ReadonlyArray<Vertex>): Array<{ x: number; y: number }> =>
line.map(([x, y]) => ({ x, y }));
/** 서버 측점 id 꼴(`generate_sections`) — 종단 표 · 고르기가 이 id 로 측점을 짚음. */
export const stationIdOf = (chainageM: number): string =>
`station_${String(Math.round(chainageM * 1000)).padStart(12, "0")}`;
/** 노선 바뀐 범위 — 처음 바뀐 접선점부터 끝(같으면 전 구간). */
export function routeRange(
before: ReadonlyArray<Vertex> | null,
after: ReadonlyArray<Vertex> | null,
): RangePart {
const span = before && after ? changedSpan(xy(before), xy(after)) : null;
return { fromM: span ? span.start_m : 0, toM: Infinity };
}
/** 계획고 바뀐 범위 — 바뀐 편집 키 앞뒤 이웃 꼭짓점 사이(`nodes` = 계획선 꼭짓점 누가거리). 같으면 null. */
export function gradeRange(
before: AlignmentEdits | null,
after: AlignmentEdits | null,
nodes: ReadonlyArray<number>,
): RangePart | null {
const changed: number[] = [];
for (const field of ["station_offsets", "curve_radii"] as const) {
const a = before?.[field] ?? {};
const b = after?.[field] ?? {};
for (const key of new Set([...Object.keys(a), ...Object.keys(b)]))
if (a[key] !== b[key]) changed.push(Number(key));
}
if (!changed.length) return null;
const low = Math.min(...changed);
const high = Math.max(...changed);
const ahead = nodes.filter((node) => node > high + 1e-6);
return {
fromM: Math.max(0, ...nodes.filter((node) => node < low - 1e-6)),
toM: ahead.length ? Math.min(...ahead) : Infinity,
};
}
/** 계획고 편집 기준 한 벌 — 그 편집이 놓인 선 · 자동 꼭짓점. */
interface GradeRef {
line: XY[];
basePvi: AlignmentBase["basePvi"];
edits: AlignmentEdits;
}
/**
* follow — 노선이 바뀐 차례에 한 번 · 관 · 구조물 좌표 따라감 · 계획고 편집 옮김. 편집 기준 = 마지막으로
* 편집이 바뀐(종단 조작 · 되돌리기 · 열 때) 뒤 첫 노선 걸음의 옛 선 · 자동 꼭짓점 · 편집 — 노선 걸음이 여럿이어도
* 그 한 벌에서 한 번에 옮김(걸음 수와 무관 · 서버 [저장] = 저장 노선에서 한 번 · 400). 첫 계산(옛 노선 없음)은
* 저장 노선(`savedLine`)을 옛 노선으로 보고 좌표만 따라감(투영은 몇 번 해도 같음 · 계획고 옮김은 안 함 —
* 열 때의 계획고 초안은 이미 그 노선 기준). 노선 초안이 없으면(열 때 · 초안 버림) `saved` 를 그대로 —
* 저장 노선의 관 · 구조물 누가거리 · 계획고는 정본이라 다시 투영하지 않음(투영 오차 = 측점 목록 어긋남).
*/
export function createFollowStage(deps: {
savedLine(): Vertex[] | null;
saved?(): FollowPatch;
/** 저장분 추가 측점 땅 위 자리(누가거리 · x · y) — 첫 걸음이 서버 정본 좌표에서 따라감(493-3). */
savedPlus?(): ReadonlyArray<FollowingStructure>;
/** 새 노선 측점 누가거리(stations 단계와 같은 셈) — 없으면 일반측점 격자. */
stationsAt?(route: RouteLine, patch: FollowPatch, inputs: PipelineInputs): number[];
}): StageFn<"follow"> {
let ref: GradeRef | null = null;
let carried: unknown = null; // 마지막으로 낸 편집 — 입력이 이것이면 기준 그대로
return (ctx) => {
if (!ctx.inputs.route && deps.saved) return deps.saved();
const before = ctx.before.route?.line ?? deps.savedLine();
const now = ctx.out.route;
if (!before || !now) return {};
const line = xy(now.line);
const patch: FollowPatch = {};
const { pipes, structures, alignment, plusStations } = ctx.inputs;
if (Array.isArray(pipes) && pipes.length)
patch.pipes = projectPipePoints(pipes as FollowingPoint[], line);
if (Array.isArray(structures) && structures.length)
patch.structures = followStructures(
structures as FollowingStructure[],
line,
undefined,
SECTION_STATION_INTERVAL_M, // site = 새 노선 일반 측점(380-3)
).map((moved) => moved.item);
// 추가 측점도 땅 위 자리 따라감(459 · 서버 `follow_plus_stations` 짝) — 가게는 부르는 쪽이 씀 · 좌표 =
// 앞 걸음 따라감 · 없으면 저장분(493-3 · 걸음마다 정수 m 에서 다시 놓으면 밀림)
if (plusStations?.length) {
const saved = deps.savedPlus?.() ?? [];
// 초안 둔 채 열기(앞 노선 없음)의 초안 목록 = 이미 초안 노선 자리(관 · 구조물 `frame` 과 같은 셈)
const onDraft =
!ctx.before.route &&
JSON.stringify(plusStations) !== JSON.stringify(saved.map((row) => row.chainage_m));
const ground = onDraft ? [] : ((ctx.before.follow && plusAt.get(ctx.before.follow)) ?? saved);
const rows = followPlusGround(plusStations, onDraft ? line : xy(before), line, ground);
patch.plusStations = rows.map((row) => row.chainage_m!);
plusAt.set(patch, rows);
}
// 노선 초안 전 관 · 구조물이 따라가기만 한 자리 — planline 이 사용자가 옮긴 것을 가름(459 · 427)
const origin = deps.saved?.();
const home = deps.savedLine();
if (origin && home) followedAt.set(patch, followedSpots(origin, xy(home), line));
const planline = ctx.before.planline;
if (alignment !== carried && JSON.stringify(alignment) !== JSON.stringify(carried))
ref =
alignment && planline
? { line: xy(before), basePvi: planline.basePvi, edits: alignment as AlignmentEdits }
: null;
if (ref && ctx.before.route) {
const span = changedSpan(ref.line, line);
const half = (planline?.policy.station_interval_m ?? SECTION_STATION_INTERVAL_M) / 2;
const stations = span ? (deps.stationsAt?.(now, patch, ctx.inputs) ?? gridOf(now)) : [];
carried = patch.alignment = span
? carryEdits(ref.line, line, ref.basePvi, ref.edits, stations, half)
: ref.edits;
}
return patch;
};
}
/** 같은 꼭짓점 자리(m) — `seatBornNodes` · 파이썬 `_SAME_STATION_M` 과 같은 0.006. */
const SEAT_SAME_M = 0.006;
/** follow 출력마다 추가 측점 땅 위 자리(누가거리 · x · y) — 다음 걸음이 이 좌표에서 따라감(493-3). */
const plusAt = new WeakMap<object, FollowingStructure[]>();
/** follow 출력마다 따라가기만 한 자리(id → 자리 누가거리들) — planline 이 427 세움에 씀(459). */
const followedAt = new WeakMap<object, ReadonlyMap<string, number[]>>();
/** follow 출력 곁 자료(추가 측점 땅 자리 · 따라가기만 한 자리) — JSON 꼴(서버 follow · 511-B B-5). */
export interface FollowSides {
plus: FollowingStructure[] | null;
followed: Array<[string, number[]]> | null;
}
export function followSides(patch: object): FollowSides {
const followed = followedAt.get(patch);
return { plus: plusAt.get(patch) ?? null, followed: followed ? [...followed] : null };
}
/** 서버가 낸 follow 출력에 곁 자료를 다시 붙임 — 다음 걸음 · planline 이 화면 셈과 같은 값을 봄. */
export function withFollowSides<T extends object>(patch: T, sides: FollowSides): T {
if (sides.plus) plusAt.set(patch, sides.plus);
if (sides.followed) followedAt.set(patch, new Map(sides.followed));
return patch;
}
type Facility = FollowingStructure & { id?: string | null; structure_id?: string | null };
/** 땅 위 자리 — 좌표가 있으면 그대로(따라가기만 한 시설 = 정본 좌표 · 초안 둔 채 열기도) · 없으면 누가거리의
* `home` 위 자리. */
const grounded = (items: Facility[], home: ReadonlyArray<XY>): Facility[] =>
items.map((item) =>
item.x != null && item.y != null
? item
: placeOnLine([item], home, [], SECTION_STATION_INTERVAL_M)[0],
);
/** 노선 초안 전 관 · 구조물(`origin` · 저장 노선 `home` 위)을 새 선에 따라 옮긴 자리 — id → 기준 · 구간 끝. */
function followedSpots(
origin: FollowPatch,
home: ReadonlyArray<XY>,
line: ReadonlyArray<XY>,
): Map<string, number[]> {
const spots = new Map<string, number[]>();
const put = (id: string | null | undefined, item: FollowingStructure): void => {
if (!id) return;
const at = [item.chainage_m, item.start_m, item.end_m].filter(
(value): value is number => value != null,
);
spots.set(id, [...new Set(at)]);
};
if (Array.isArray(origin.pipes))
projectPipePoints(grounded(origin.pipes as Facility[], home) as FollowingPoint[], line).forEach(
(pipe) => put((pipe as Facility).id, pipe),
);
if (Array.isArray(origin.structures))
followStructures(
grounded(origin.structures as Facility[], home),
line,
undefined,
SECTION_STATION_INTERVAL_M,
).forEach(({ item }) => put(item.structure_id, item));
return spots;
}
/** 사용자가 옮긴 · 새로 놓은 시설 측점 — 따라가기만 한 자리(`spots`)와 다른 것 · 옛 자리(`from` · 새로 놓음 =
* null) → 지금 자리(`to`). 한 시설의 여러 측점(구간 끝)은 차례대로 짝. 추가 측점은 뺌. */
export function userMoves(
stations: ReadonlyArray<Pick<PipelineStation, "chainageM" | "kind" | "id">>,
spots: ReadonlyMap<string, ReadonlyArray<number>>,
): Array<{ from: number | null; to: number }> {
const byId = new Map<string, number[]>();
for (const station of stations)
if (
(station.kind === "pipe" || station.kind === "structure") &&
station.id &&
!station.id.startsWith(PLUS_OWNER_PREFIX)
)
byId.set(station.id, [...(byId.get(station.id) ?? []), station.chainageM]);
const out: Array<{ from: number | null; to: number }> = [];
byId.forEach((now, id) => {
const was = [...(spots.get(id) ?? [])].sort((a, b) => a - b);
now
.sort((a, b) => a - b)
.forEach((to, index) => {
const from = was.length === now.length ? was[index] : null;
if (from === null || Math.abs(from - to) > SAME_STATION_M) out.push({ from, to });
});
});
return out;
}
/** 노선 바뀜으로 옮긴 시설 측점(459) — 관 · 구조물 측점 중 옛 선과 같은 자리가 아닌 것(`lineMatcher` 짝
* 없음) · 추가 측점(`plus-`)은 뺌. 파이썬 `route_moved_stations` 짝. */
export function routeMovedStations(
stations: ReadonlyArray<Pick<PipelineStation, "chainageM" | "kind" | "id">>,
oldXy: ReadonlyArray<XY>,
newXy: ReadonlyArray<XY>,
): number[] {
const match = lineMatcher(oldXy, newXy);
return stations
.filter(
(station) =>
(station.kind === "pipe" || station.kind === "structure") &&
!station.id?.startsWith(PLUS_OWNER_PREFIX) &&
match(station.chainageM) === null,
)
.map((station) => station.chainageM)
.sort((a, b) => a - b);
}
/** 옮긴 시설 측점(`born`)에 427 꼭짓점을 다시 세움 — 그 자리 꼭짓점을 걷고 `seatBornNodes`(원지반 지남 ·
* 이웃 1 mm 맞춤 · 균형 종단이면 옮기기 전 계획선 높이). 서버 = Node `profile.carry` 의 seat(거울 기준 = 시험 헬퍼 `seat_route_moved`). */
export function seatRouteMoved(
base: Pick<AlignmentBase, "chainage" | "ground" | "policy" | "basePvi">,
born: ReadonlyArray<number>,
held: ReadonlyArray<number> = [],
): AlignmentBase["basePvi"] {
if (!born.length) return base.basePvi;
const kept = base.basePvi.filter((node) =>
born.every((value) => Math.abs(node.chainage_m - value) >= SEAT_SAME_M),
);
return seatBornNodes(base, kept, born, held);
}
/** 일반측점 격자 + 끝 — `stationsAt` 없을 때(시험 · 단독 묶음). */
const gridOf = (route: RouteLine): number[] => {
const grid: number[] = [];
for (let s = 0; s < route.lengthM; s += SECTION_STATION_INTERVAL_M) grid.push(s);
return [...grid, route.lengthM];
};
export const followStage = createFollowStage({ savedLine: () => null });
export interface PlanlineDeps {
/** 저장 노선의 종단 기준(측점 한 벌 = 지금 입력이 심는 측점 · 386-3) — 없으면 아직 못 셈. */
savedBase(inputs: PipelineInputs): AlignmentBase | null;
/** 저장 노선(그린 선) — 바뀐 구간을 가름. */
savedLine(): Vertex[] | null;
/** 노선 초안 첫 계획선 꼭짓점 = 서버 저장 종단 — 보기가 심은 측점(초안 자리 관 등)에 안 흔들림(365-5) · 없으면 `savedBase`. */
storedPvi?(): AlignmentBase["basePvi"] | null;
/** 바뀐 자리 새 지반 첫 계획선 꼭짓점 — 계산 자리 「서버」 면 서버 Node 약속(511-B B-3) · null = 브라우저
* `groundFollowingBasePvi`(단계 동기 그대로). */
groundPvi?(
input: GroundFollowingInput,
signal: AbortSignal,
): Promise<AlignmentBase["basePvi"] | null> | null;
}
export function createPlanlineStage(deps: PlanlineDeps): StageFn<"planline"> {
return (ctx) => {
const saved = deps.savedBase(ctx.inputs);
if (!saved) throw new Error("저장 종단 기준이 아직 없습니다.");
const ground = ctx.out.ground;
const route = ctx.out.route;
if (!ctx.inputs.route || !ground || !route) return saved;
const { chainageM, z } = ground.longitudinal;
const samples = chainageM.map((chainage, index) => ({
chainage_m: chainage,
elevation_m: z[index],
}));
const profile = groundProfile(samples);
if (!profile) return saved; // 못 서는 땅 — 저장 기준 그대로(서버가 [저장] 때 다시 셈)
const savedLine = deps.savedLine();
const span = savedLine ? changedSpan(xy(savedLine), xy(route.line)) : null;
const savedPvi = deps.storedPvi?.() ?? saved.basePvi;
const baseOf = (basePvi: AlignmentBase["basePvi"]) => ({
chainage: profile.chainage,
ground: profile.ground,
policy: saved.policy,
basePvi,
});
const edited = Object.keys(ctx.inputs.alignment?.station_offsets ?? {});
// 427 세움(459) — 서버 [저장] 차례 짝: ① 노선 저장 = 관 · 구조물이 따라가기만 한 자리 측점으로 새 계획선 ·
// 바뀐 구간에 든 시설 측점 세움(`_carry_profile`) ② 관 · 구조물 저장 = 사용자가 옮긴 자리로(`_follow_moved_stations`)
const spots = ctx.out.follow ? followedAt.get(ctx.out.follow) : undefined;
const moves = spots ? userMoves(ground.stations, spots) : [];
// 일반측점 격자 자리는 시설이 떠나도 · 와도 측점이 남음(서버 `grid` 측점 · 272)
const interval = saved.policy.station_interval_m || SECTION_STATION_INTERVAL_M;
const onGrid = (value: number) =>
Math.abs(value - Math.round(value / interval) * interval) < SAME_STATION_M;
const gone = moves.flatMap((move) =>
move.from === null || onGrid(move.from) ? [] : [move.from],
);
const born = moves.flatMap((move) => (onGrid(move.to) ? [] : [move.to]));
const homeStations = [
...ground.stations.filter((station) => !born.includes(station.chainageM)),
...gone.map((chainageM) => ({ chainageM, kind: "structure" as const, id: null })),
];
const finish = (groundPvi: AlignmentBase["basePvi"]): AlignmentBase => {
const carried = span
? carryBase(xy(savedLine!), xy(route.line), savedPvi, groundPvi)
: savedPvi;
const home = span
? seatRouteMoved(
baseOf(carried),
routeMovedStations(homeStations, xy(savedLine!), xy(route.line)),
edited.map(Number),
)
: carried;
const step = gone.length || born.length ? followMovedNodes(home, gone, born, edited) : null;
return {
policy: saved.policy, // 옛 기본 L 이어받음(서버 `carry_policy` 짝)
onlyExplicitCurves: saved.onlyExplicitCurves,
basePvi: step ? seatBornNodes(baseOf(home), step.nodes, step.seat, step.held) : home,
chainage: profile.chainage,
ground: profile.ground,
stations: ground.stations.map((station) => ({
station_id: stationIdOf(station.chainageM),
chainage_m: station.chainageM,
})),
};
};
// 바뀐 자리 새 꼭짓점 = 서버 새 지반 첫 계획선(기본 L = config 짝 · 초기값 직선화 · 438) · 계산 자리
// 「서버」 면 서버 Node(`groundPvi` · 511-B B-3)
const input = {
samples,
stations: homeStations.map((station) => station.chainageM),
policy: { ...saved.policy, default_curve_length_m: DEFAULT_CURVE_LENGTH_M },
};
const remote = span ? deps.groundPvi?.(input, ctx.signal) : null;
if (remote) return remote.then((nodes) => finish(nodes ?? []));
return finish(span ? (groundFollowingBasePvi(input) ?? []) : []);
};
}
/** profile — 첫 계획선 + 계획고 편집 → 종단(종단 보기와 같은 `buildAlignment`). */
export const profileStage: StageFn<"profile"> = (ctx) => {
const base = ctx.out.planline;
if (!base) throw new Error("첫 계획선이 없습니다.");
return buildAlignment(base, ctx.inputs.alignment ?? emptyEdits());
};