/* ============================================================================= * B05_Profile_UI_Page_Helpers.ts * B05 화면의 순수 도우미 — 설계폭 조회, 모델 경계 변환, 저장분 → 마커 복원, * 비정규 측점의 3D 좌표 선형보간. * * 화면 본체(B05_Profile_UI_Page)가 700줄 한계에 닿아 분리했다. * 전부 상태를 갖지 않는 변환 함수라 화면 흐름과 독립적이다. * ========================================================================== */ import { readState, writeState } from "../A00_Common/b_page_state"; import type { RoutePanelValues } from "./B05_Profile_UI_Panel"; import type { ModelBounds, PlacedRoutePoint, RouteDesignPoints, RoutePointKind, } from "./B05_Profile_UI_Markers"; import type { CirclePoint, RouteLatestResponse, RoutePoint } from "./B05_Profile_Api_Fetch"; import type { PipeFacility } from "../B04_PreProcess/B04_PreProcess_Api_Fetch"; import type { SectionStation } from "../B06_Section/B06_Section_Api_Fetch"; import { irregularLabel, irregularStationId, type IrregularStation, } from "./B05_Profile_UI_IrregularStations"; type GradeClass = RoutePanelValues["gradeClass"]; export type RoadWidths = Record; /** 임도 종류별 유효너비(m) — 간선·산불진화 3m, 작업 2.5m(설계제원_총괄 §3). * branch(지선)는 폐지됐지만 기존 저장분이 남아 있어 값은 유지한다(2026-08-19). */ export const DEFAULT_ROAD_WIDTHS: RoadWidths = { trunk: 3, fire: 3, branch: 3, work: 2.5 }; /** 프로젝트 등록(B02)의 임도 종류 값을 계획선 기준 코드로 옮긴다(2026-08-19). * B02 값: main(간선)·fire(산불진화)·branch(구 지선)·stream(계류보전). * 현행 규칙의 3종은 간선·산불진화·작업이고 지선은 폐지됐으므로, 간선·산불진화가 * 아닌 값은 모두 작업임도로 본다(별표2 부칙 경과조치와 같은 방향). */ export function toGradeClass(roadType: string | null | undefined): GradeClass { if (roadType === "main" || roadType === "trunk") return "trunk"; if (roadType === "fire") return "fire"; return "work"; } export async function fetchRoadWidths(projectId: string): Promise { const response = await fetch(`/api/projects/${projectId}/sections/road-widths`); if (!response.ok) return DEFAULT_ROAD_WIDTHS; const payload = (await response.json()) as { forest_road_min_width_m?: Partial }; return { ...DEFAULT_ROAD_WIDTHS, ...payload.forest_road_min_width_m }; } export function toBounds(bounds: { x_min: number; x_max: number; y_min: number; y_max: number; z_min: number; z_max: number; }): ModelBounds { return { x: [bounds.x_min, bounds.x_max], y: [bounds.y_min, bounds.y_max], z: [bounds.z_min, bounds.z_max], }; } export function placed( type: RoutePointKind, point: RoutePoint | CirclePoint, index = 0, ): PlacedRoutePoint { return { id: `${type}-restored-${index}`, type, x: point.x, y: point.y, // 표고를 모르면 모르는 채로 넘긴다 — 0으로 눕히면 마커가 지형 한참 아래 평면에 깔린다. z: point.z ?? null, ...(type === "ap" || type === "fp" ? { radius_m: (point as CirclePoint).radius_m ?? 25 } : {}), }; } export function restorePoints(latest: RouteLatestResponse): RouteDesignPoints { const points = latest.route_params?.points; return { bp: points?.bp ? placed("bp", points.bp) : null, ep: points?.ep ? placed("ep", points.ep) : null, cp: (points?.cp ?? []).map((point, index) => placed("cp", point, index)), ap: (points?.ap ?? []).map((point, index) => placed("ap", point, index)), fp: (points?.fp ?? []).map((point, index) => placed("fp", point, index)), }; } export function routePoint(point: PlacedRoutePoint): RoutePoint { // 서버 스키마는 표고를 빼면 "모름"으로 받는다 — null은 undefined로 바꿔 보낸다. return { x: point.x, y: point.y, z: point.z ?? undefined }; } export function circlePoint(point: PlacedRoutePoint): CirclePoint { return { ...routePoint(point), radius_m: point.radius_m ?? 25 }; } /** 노선 폴리라인 위 chainage 지점의 (x, y) — 세그먼트 누적 길이로 정확히 되짚는다. * 규칙 측점(20m 간격) 사이 직선보간은 곡선 구간에서 모서리를 잘라 3D 마커가 노선을 * 벗어난다(2026-08-19 사용자 보고 — 도엽 등고선 분석 좌표와 라이다 노선의 미세 차). * 폴리라인은 노선 정본이라 이 좌표가 3D 노선 선 위에 정확히 얹힌다. */ export function chainageToPolylineXY( polyline: ReadonlyArray<{ x: number; y: number }>, chainage: number, ): { x: number; y: number } | null { if (polyline.length < 2) return null; let travelled = 0; for (let index = 1; index < polyline.length; index += 1) { const from = polyline[index - 1]; const to = polyline[index]; const span = Math.hypot(to.x - from.x, to.y - from.y); if (span < 1e-9) continue; if (travelled + span >= chainage - 1e-6) { const t = Math.min(Math.max((chainage - travelled) / span, 0), 1); return { x: from.x + (to.x - from.x) * t, y: from.y + (to.y - from.y) * t }; } travelled += span; } const last = polyline[polyline.length - 1]; return { x: last.x, y: last.y }; } /** 단위벡터 두 개를 각도로 보간 — 선형보간과 달리 길이가 1로 유지된다. */ function slerpUnit(a: [number, number], b: [number, number], t: number): [number, number] { const angleA = Math.atan2(a[1], a[0]); let delta = Math.atan2(b[1], b[0]) - angleA; if (delta > Math.PI) delta -= Math.PI * 2; if (delta < -Math.PI) delta += Math.PI * 2; const angle = angleA + delta * t; return [Math.cos(angle), Math.sin(angle)]; } /** * 노선 폴리라인 위 chainage 지점의 **좌향 단위벡터**(횡단 법선) — 백엔드 프레임과 * 같은 규약 `left = (−ty, tx)`(`B05_Profile_Engine_Sections_Core.py`). * * 비정규(구조물) 측점 프레임을 규칙 측점 두 개의 left_xy **선형보간**으로 만들면 * 곡선 구간에서 단위길이가 깨지고 방향도 현(chord) 쪽으로 눕는다 — 그 측점의 횡단 * 가로선·구조물이 노선 법선과 어긋나 보인다(2026-08-23 사용자 지적: 4+4.3 법선). * 폴리라인 접선에서 직접 뽑으면 그 자리의 실제 법선이 된다. */ export function chainageToPolylineLeft( polyline: ReadonlyArray<{ x: number; y: number }>, chainage: number, ): [number, number] | null { if (polyline.length < 2) return null; let travelled = 0; let fallback: [number, number] | null = null; for (let index = 1; index < polyline.length; index += 1) { const from = polyline[index - 1]; const to = polyline[index]; const span = Math.hypot(to.x - from.x, to.y - from.y); if (span < 1e-9) continue; const left: [number, number] = [-(to.y - from.y) / span, (to.x - from.x) / span]; fallback = left; if (travelled + span >= chainage - 1e-6) return left; travelled += span; } return fallback; } /** * 비정규 측점을 규칙 측점 좌표 사이 chainage로 선형보간해 `SectionStation`(월드 좌표·프레임 포함)으로 * 만든다. 백엔드가 아직 이 측점의 횡단을 생성하지 않으므로, 3D 표시에 필요한 위치만 근사한다. * 노선 범위를 벗어난 chainage는 제외한다. `routePolyline`을 주면 중심 (x, y)는 노선 * 폴리라인에서 정확히 되짚는다(곡선 모서리 잘림 방지, 2026-08-19) — 표고·프레임은 * 여전히 규칙 측점 보간값이다. */ export function interpolateIrregularStations( base: SectionStation[], list: IrregularStation[], maxChainage: number, routePolyline: ReadonlyArray<{ x: number; y: number }> = [], ): SectionStation[] { const sorted = [...base].sort((a, b) => a.chainage_m - b.chainage_m); if (!sorted.length) return []; const anchorAt = (chainage: number): SectionStation => { if (chainage <= sorted[0].chainage_m) return sorted[0]; const last = sorted[sorted.length - 1]; if (chainage >= last.chainage_m) return last; let lo = sorted[0]; let hi = last; for (let index = 1; index < sorted.length; index += 1) { if (sorted[index].chainage_m >= chainage) { lo = sorted[index - 1]; hi = sorted[index]; break; } } const span = hi.chainage_m - lo.chainage_m; const t = span > 1e-9 ? (chainage - lo.chainage_m) / span : 0; const lerp = (a: number, b: number): number => a + (b - a) * t; const centerZ = lo.center_z !== null && hi.center_z !== null ? lerp(lo.center_z, hi.center_z) : (lo.center_z ?? hi.center_z); return { ...lo, center_x: lerp(lo.center_x, hi.center_x), center_y: lerp(lo.center_y, hi.center_y), center_z: centerZ, frame: { // 단위벡터는 선형보간하면 길이가 줄고 방향이 현 쪽으로 눕는다 — 각도로 // 보간해 단위길이를 지킨다(2026-08-23). 폴리라인이 있으면 아래에서 실제 // 접선 법선으로 다시 덮는다. left_xy: slerpUnit(lo.frame.left_xy, hi.frame.left_xy, t), }, }; }; return list .filter((entry) => entry.chainage_m >= 0 && entry.chainage_m <= maxChainage + 1e-6) .map((entry) => { const anchor = anchorAt(entry.chainage_m); // 중심 좌표는 노선 폴리라인에서 정확히 — 곡선에서 규칙 측점 직선보간이 만드는 // 모서리 잘림(3D 마커가 노선 옆으로 새는 현상)을 없앤다(2026-08-19). const onRoute = chainageToPolylineXY(routePolyline, entry.chainage_m); // 법선(좌향)도 같은 폴리라인에서 뽑는다 — 중심만 노선 위에 얹고 프레임은 // 측점 보간값을 쓰면 그 측점의 횡단이 노선과 어긋난다(2026-08-23 사용자). const onRouteLeft = chainageToPolylineLeft(routePolyline, entry.chainage_m); return { ...anchor, ...(onRoute ? { center_x: onRoute.x, center_y: onRoute.y } : {}), ...(onRouteLeft ? { frame: { left_xy: onRouteLeft } } : {}), station_id: irregularStationId(entry.id), chainage_m: entry.chainage_m, label: irregularLabel(entry), kind: "irregular" as const, // 3D 측점 라벨이 `측점번호 구조물명`으로 표기할 수 있게 구조물 이름을 실어 보낸다. structure: entry.structure, }; }); } /** 시설 종류별 표시 이름 — 그래프·3D 라벨용(배관은 관종·관경까지 따로 붙인다). */ export const FACILITY_NAMES: Record = { pipe: "배수관", box_culvert: "BOX암거", ford_pavement: "물넘이포장", ford_bridge: "세월교", revetment: "기슭막이", }; /* ── 측점 상단측(=측구 방향) 사용자 변경분 세션 보관 ───────────────────── * 3D 램프 클릭으로 바꾼 값. 경로 확정 때 uphill_overrides로 백엔드에 병합한다. * 화면 본체가 700줄에 닿아 읽기·쓰기만 여기로 뺐다(2026-09-04, 동작 불변). */ /** 세션에 남은 상단측 변경분을 읽는다. 손상된 값은 무시하고 빈 것으로 시작한다. */ export function loadUphillOverrides(projectId: string): Map { const overrides = new Map(); try { const stored = readState>("uphill", projectId); if (!stored) return overrides; Object.entries(stored).forEach(([chainage, side]) => { if (side === "left" || side === "right") overrides.set(chainage, side); }); } catch { /* 손상된 세션 값은 무시 — 자동 판정값으로 재시작. */ } return overrides; } /** 상단측 변경분을 세션에 적는다. 저장 실패는 무시한다(값은 메모리에 남는다). */ export function saveUphillOverrides( projectId: string, overrides: ReadonlyMap, ): void { try { writeState("uphill", Object.fromEntries(overrides), projectId); } catch { /* 세션 저장 실패는 무시 — 값은 메모리에 유지된다. */ } }