/* ============================================================================= * 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>; 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, ): 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 = { ...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, 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, }; }