Files
Aislo/B05_Profile/logic/B05_Profile_Pipeline_Detail.ts
T
eomsangdonandClaude Opus 5.5 78c6080853 fix(계산묶음): 349-0p 초안 새 측점 암 경계 = 서버 기본값 — cross 단계가 입력 없는 측점에 기본 오프셋을 받음 · 소수 자리 관 측점 시험
- 검증_브레인232 를 검증_sub2b(abdc72d0 · 노선 330)로 복제해 같은 끌기(25+0 부근 꺾임점 북동 10 m)로 서버 [저장] 체인 실측: 미리보기 1,169.6 대 정본 1,184.0 · 갈린 곳은 960 측점 하나(초안에서 격자 열쇠라 저장 입력 없음 → 암 경계 비어 흙 · 암 나눔이 다름)
- CrossStageInput.rockBoundaryDefaultM — 입력 없는 측점의 암 경계(서버 config 기본값) · 넘기면 미리보기 1,184.7 대 정본 1,184.0
- 넘기는 줄(design_pipeline_cross.ts `readRockBoundaryDefault(projectId)`)은 남의 도움 파일 가짜 모듈을 깨서 브레인 몫으로 남김
- 시험: test_349_0p_fractional_station 2(소수 자리 관 · 물넘이 · 세월교 측점 = 서버 보정 뒤 · 입력 없는 측점 암 경계) · 관련 97 통과 · typecheck 0

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QfHKQs7J3hQELKMWQMUJEy
2026-10-16 17:04:04 +09:00

174 lines
7.0 KiB
TypeScript

/* =============================================================================
* B05_Profile_Pipeline_Detail.ts
* 계산 묶음(PLAN 346-1 · 346-2) 출력 → 종단도 · 유토곡선이 읽는 상세 꼴(`SectionDetailResponse`).
* 노선 초안 동안 종단도가 저장 노선 상세 대신 이 상세를 그림(지반 · 측점 · 횡단 면적이 새 노선 것).
*
* uphillSide 측점 상단측(좌 · 우 지반 평균이 높은 쪽) — 서버 단면 유형 기본값(`{쪽}_cut`) 재료.
* ⚠ ① 짝 — `B05_Profile_Engine_Sections_Core.py` `uphill_side` · 거울 시험
* `resources/tester/test_346_2_pipeline_cross_page.py`.
* crossWithUphill cross 단계 입력에 단면 유형 기본값(상단측)을 얹음 — 서버 노선 저장이 단면 유형 ·
* 측구 방향을 새 지형으로 정하는 것과 같은 규칙(넘기는 칸 = 부르는 쪽 입력).
* draftSectionDetail ground · profile · cross → 상세(측점 · 지반 · 횡단 설계 · 얹힌 시설 제원).
*
* 순수 — 화면 · fetch · 세션 없음.
* ========================================================================== */
import type {
CrossSection,
SectionDetailResponse,
SectionStation,
} from "../../B06_Section/B06_Section_Api_Types";
import type {
CrossOutput,
GroundOutput,
PipelineStation,
} from "../../A00_Common/design/logic/design_pipeline_types";
import type { ProfileAlignment } from "./B05_Profile_UI_Profile_Alignment";
import { stationLabel } from "../../common_util/common_util_station";
import { stationIdOf } from "./B05_Profile_Pipeline_Grade";
/** 측점에 얹힌 시설 제원(저장 상세에서 측점 열쇠로 옮김) — 낡음 판정 · 그림이 읽음. */
export type SectionSpecs = Partial<
Pick<
CrossSection,
| "structure"
| "culvert"
| "ford"
| "box"
| "ford_pavement"
| "revetment"
| "pavement"
| "underdrain"
>
>;
/** cross 단계 입력 꼴(`B06_Section_Cross_Stage` `CrossStageInput`) — 칸은 그 단계 몫. */
export interface CrossInput {
standard: unknown;
byKey: Record<string, Record<string, unknown>>;
rockBoundaryDefaultM?: number | null;
}
const mean = (values: number[]): number => values.reduce((sum, v) => sum + v, 0) / values.length;
/** 상단측 — 좌 · 우 유효 지반 평균이 높은 쪽 · 한쪽이 비거나 차이가 1e-6 안이면 null(서버와 같음). */
export function uphillSide(station: PipelineStation): "left" | "right" | null {
const valid = (side: "left" | "right"): number[] =>
(station.ground?.[side] ?? [])
.filter((sample) => sample.offsetM > 1e-9 && sample.z !== null && Number.isFinite(sample.z))
.map((sample) => sample.z as number);
const left = valid("left");
const right = valid("right");
if (!left.length || !right.length) return null;
const gap = mean(left) - mean(right);
if (Math.abs(gap) <= 1e-6) return null;
return gap > 0 ? "left" : "right";
}
/** 측점마다 단면 유형 기본값(상단측 깎기 · 모르면 좌) — 입력이 정한 단면 유형은 그대로. */
export function crossWithUphill(
input: CrossInput | null,
stations: ReadonlyArray<PipelineStation>,
): CrossInput | null {
if (!input) return null;
const byKey: CrossInput["byKey"] = {};
for (const station of stations)
byKey[station.key] = {
sectionMode: `${uphillSide(station) ?? "left"}_cut`,
...input.byKey[station.key],
};
return { ...input, byKey };
}
const kindOf = (station: PipelineStation): SectionStation["kind"] =>
station.key === "end:BP"
? "bp"
: station.key === "end:EP"
? "ep"
: station.kind === "grid"
? "regular"
: "irregular";
/** 제원의 누가거리 = 새 측점(서버 부착은 따라간 관 · 구조물 자리 = 그 측점) — 옛 값이면 구조물 면적이 관 자리를 잘못 잡음(349-0n). */
function atChainage(own: SectionSpecs, chainageM: number): SectionSpecs {
const out: Record<string, unknown> = { ...own };
for (const [name, spec] of Object.entries(own))
if (
spec &&
typeof spec === "object" &&
typeof (spec as { chainage_m?: unknown }).chainage_m === "number"
)
out[name] = { ...spec, chainage_m: chainageM };
return out as SectionSpecs;
}
/** 묶음 출력 → 상세. 저장 상세의 종단 머리(이름 · 계획선 묶음)는 그대로 · 측점 · 지반 · 횡단은 새 노선. */
export function draftSectionDetail(
saved: SectionDetailResponse,
ground: GroundOutput,
profile: ProfileAlignment,
cross: CrossOutput | null,
specs: ReadonlyMap<string, SectionSpecs>,
intervalM: number,
): SectionDetailResponse {
const sections: CrossSection[] = ground.stations.map((station) => {
const own = atChainage(specs.get(station.key) ?? {}, station.chainageM);
const center = station.ground?.centerZ ?? null;
const head: SectionStation = {
station_id: stationIdOf(station.chainageM),
chainage_m: station.chainageM,
label: stationLabel(station.chainageM, intervalM),
kind: kindOf(station),
...(own.structure ? { structure: own.structure } : {}),
center_z: center,
azimuth_deg: station.azimuthDeg,
plan_radius_m: station.planRadiusM,
curve_outer_side: station.curveOuterSide,
curve_widening_m: station.curveWideningM,
center_x: station.x,
center_y: station.y,
uphill_side: uphillSide(station),
frame: { left_xy: station.left },
};
const sample = (offset: number, z: number | null) => ({
offset_m: offset,
elevation_m: z,
valid: z !== null && Number.isFinite(z),
});
const samples = [
...[...(station.ground?.right ?? [])].reverse().map((s) => sample(-s.offsetM, s.z)),
sample(0, center),
...(station.ground?.left ?? []).map((s) => sample(s.offsetM, s.z)),
];
const entry = cross?.byKey[station.key] as
{ detail?: unknown; samples?: unknown[] } | undefined;
const design = entry?.detail as CrossSection["design"] | undefined;
// 설계는 사본 — 구조물 면적 얹기(`applyStructureAreaRows`)가 cross 단계 기억을 안 고침(349-0j)
return {
...head,
...own,
// 넓힌 표본(서버 [저장]도 넓힌 표본을 상세에 남김 — 구조물 면적이 그 지반으로 닫힘 · 349-0n)
samples: (entry?.samples as CrossSection["samples"] | undefined) ?? samples,
...(design ? { design: { ...design } } : {}),
preview: true,
};
});
const { chainageM, z } = ground.longitudinal;
return {
longitudinal: {
...saved.longitudinal,
length_m: chainageM[chainageM.length - 1] ?? 0,
samples: chainageM.map((chainage, index) => ({
chainage_m: chainage,
elevation_m: z[index],
z: z[index],
valid: z[index] !== null,
})),
stations: sections.map(({ samples: _s, design: _d, preview: _p, ...head }) => head),
profile_alignment: profile,
},
cross_sections: sections,
balloon_offsets: saved.balloon_offsets,
};
}