/* ============================================================================= * B06_Section_UI_Cross_Fit.ts * 횡단 카드 「교차점 맞춤」 — 절토·성토 사면이 원지반과 만나는 지점(교차점)이 모두 * 보이는 표시 반폭(m)을 구한다. * * 판정은 **설계선(design_line)과 지반선의 실제 교차점**으로 한다. 배수관 세트가 쓰는 * `slopeToeOffset`은 「성토사면 5m」 규정에서 잘라 돌려주므로(기슭막이 자리 계산용) * 사면이 5m를 넘는 측점에서 필요 폭을 과소 산정했다 — 그 값으로 자동 줌아웃을 걸면 * 교차점이 여전히 화면 밖에 남는다(2026-08-23 사용자 보고). * * 설계선이 지반과 만나기 전에 끝나면(계산 반폭 밖) **추세로 외삽**한다 — 끝단의 사면 * 기울기와 지반 기울기 차이로 만나는 자리를 추정하고 1m 올림한다. 그 값이 계산 반폭을 * 넘으면 페이지가 백엔드 재생성으로 샘플을 넓힌다. * ========================================================================== */ import type { CrossDesign, CrossSection } from "./B06_Section_Api_Fetch"; import { designInterpolator, groundInterpolator } from "./B06_Section_UI_Cross_Culvert_Solve"; /** 설계선과 지반선이 만났다고 보는 표고 차(m). */ const MEET_TOLERANCE_M = 0.02; /** 교차점 탐색 간격(m) — 횡단 샘플 간격보다 촘촘하게 훑는다. */ const SCAN_STEP_M = 0.1; /** 추세 기울기를 재는 구간 길이(m). */ const TREND_SPAN_M = 2; /** 외삽 결과의 상한(m) — 발산하는 지형에서 폭이 무한정 커지는 것을 막는다. */ const MAX_FIT_HALF_WIDTH_M = 60; /** * 절·성토 교차점이 모두 들어오는 표시 반폭(m). 설계선·지반선이 없으면 null. * * 반환값은 1m 단위 올림 — 교차점이 축선에 딱 붙지 않게 한다. */ export function toeFitHalfWidth(section: CrossSection): number | null { const design = section.design; if (!design) return null; const groundAt = groundInterpolator(section.samples); const designAt = designInterpolator(design.design_line); const edges = design.road_edges; if (!groundAt || !designAt || !edges) return null; // 설계선이 실제로 정의된 범위 — 그 밖은 외삽 영역이다. const lineOffsets = design.design_line.map((point) => point.offset_m); if (lineOffsets.length < 2) return null; const lineMax = Math.max(...lineOffsets); const lineMin = Math.min(...lineOffsets); // 노면·측구 구간은 건너뛴다 — 계획고가 지반과 우연히 겹쳐도 사면 끝이 아니다. const { protectMax, protectMin } = protectedSpan(design, edges); let extent = 0; for (const side of ["left", "right"] as const) { const outward = side === "left" ? 1 : -1; const start = side === "left" ? protectMax : protectMin; const limit = side === "left" ? lineMax : lineMin; const meet = meetOffset(designAt, groundAt, start, limit, outward); extent = Math.max(extent, Math.abs(meet)); } if (!(extent > 0)) return null; return Math.min(Math.ceil(extent + 1), MAX_FIT_HALF_WIDTH_M); } /** 노면·노견(+측구) 구간의 바깥 경계 — 교차점 탐색은 여기서부터 바깥으로 간다. */ function protectedSpan( design: CrossDesign, edges: { left: { offset_m: number }; right: { offset_m: number } }, ): { protectMax: number; protectMin: number } { const leftEdge = Math.max(edges.left.offset_m, edges.right.offset_m); const rightEdge = Math.min(edges.left.offset_m, edges.right.offset_m); const spec = design.ditch; const ditchWidth = design.ditch_enabled === false || !spec || spec.type === "none" ? 0 : spec.type === "standard" ? spec.top_width_m : spec.width_m; // 좌표 규약: +offset = 좌. ditch_side "left" = 큰 offset 쪽. return { protectMax: leftEdge + (design.ditch_side === "left" ? ditchWidth : 0), protectMin: rightEdge - (design.ditch_side === "right" ? ditchWidth : 0), }; } /** * 노견 바깥으로 훑어 설계선이 지반과 처음 만나는 offset. 설계선 끝까지 안 만나면 * 끝단 추세로 외삽한다(그래도 못 만나면 설계선 끝). */ function meetOffset( designAt: (offset: number) => number, groundAt: (offset: number) => number, startOffset: number, limitOffset: number, outward: 1 | -1, ): number { const span = Math.abs(limitOffset - startOffset); if (!(span > 0)) return startOffset; const steps = Math.ceil(span / SCAN_STEP_M); let previousDiff = designAt(startOffset) - groundAt(startOffset); for (let index = 1; index <= steps; index += 1) { const offset = startOffset + outward * Math.min(SCAN_STEP_M * index, span); const diff = designAt(offset) - groundAt(offset); if (Math.abs(diff) <= MEET_TOLERANCE_M) return offset; if (previousDiff !== 0 && Math.sign(diff) !== Math.sign(previousDiff)) return offset; previousDiff = diff; } return extrapolateMeet(designAt, groundAt, limitOffset, outward); } /** * 설계선 끝(limit)에서 사면과 지반의 기울기 차로 만나는 자리를 외삽한다. * 벌어지기만 하면(차이가 줄지 않으면) 끝 자리를 그대로 돌려준다 — 무한정 넓히지 않는다. */ function extrapolateMeet( designAt: (offset: number) => number, groundAt: (offset: number) => number, limit: number, outward: 1 | -1, ): number { const back = limit - outward * TREND_SPAN_M; const diffAtLimit = designAt(limit) - groundAt(limit); const diffAtBack = designAt(back) - groundAt(back); // 바깥으로 갈수록 차이가 줄어드는 속도(m/m). 0 이하이면 만나지 않는다. const closingRate = (Math.abs(diffAtBack) - Math.abs(diffAtLimit)) / TREND_SPAN_M; if (!(closingRate > 1e-6)) return limit; const extra = Math.abs(diffAtLimit) / closingRate; if (!Number.isFinite(extra)) return limit; return limit + outward * Math.min(extra, MAX_FIT_HALF_WIDTH_M); } /** 전 측점 중 교차점을 보려면 필요한 최대 반폭(m). 없으면 0. */ export function maxToeFitHalfWidth(sections: CrossSection[]): number { return sections.reduce((max, section) => Math.max(max, toeFitHalfWidth(section) ?? 0), 0); }