독립 기슭막이(구조물 정본 D군 구간형)를 배수관 측점(pipe_points)의 시설 종류 "revetment"로 옮긴다. 관을 숨긴(hidden_pipe) 배관 세트로 얹혀 컴퓨트·렌더·패널· 연동·경사·3D·수량이 배관 경로를 그대로 탄다. - common_util_drainage_pipes: PIPE_FACILITY_REVET 추가, 여유고 0 - B06_Section_Engine_Culvert: _revet_set/_revet_side — 관 숨김 세트 생성 - B06_Section_Router: attach_revetments 제거(관 세트에 통합) - B05_Profile_Engine_Sections: 기슭막이는 시작·기준·종료 세 측점 심기 - B05_Profile_Structure_Types.json: revetment placement=point, managed_by=pipe_points, 설치 측에 "양쪽" 추가 - B05_Profile_UI_Drainage_Facility: 기슭막이 옵션 폼(설치 측·형태·높이·길이·전후·단 수) - B06_Section_UI_Cross_Culvert_Geom: 관 숨김 시 관경 하한·역경사 클램프·유출 가드 해제 - B06_Section_UI_Cross_Culvert_Wire: restrictToSide — 설치 측(좌/우)만 남기기 - B06_Section_UI_Cross_View: 옛 D군 경로 이중그리기 가드, appliedAdjust 되받기 - B06_Section_UI_Cross_Wall: 기슭막이 벽 공용 기하·그리기 층 신설 - B05_Profile_UI_Corridor_Structures: 3D도 같은 규칙(관 실린더 숨김, 관통 컷 없음) Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
245 lines
11 KiB
TypeScript
245 lines
11 KiB
TypeScript
/* =============================================================================
|
||
* B05_Profile_UI_Page_Helpers.ts
|
||
* B05 화면의 순수 도우미 — 설계폭 조회, 모델 경계 변환, 저장분 → 마커 복원,
|
||
* 비정규 측점의 3D 좌표 선형보간.
|
||
*
|
||
* 화면 본체(B05_Profile_UI_Page)가 700줄 한계에 닿아 분리했다.
|
||
* 전부 상태를 갖지 않는 변환 함수라 화면 흐름과 독립적이다.
|
||
* ========================================================================== */
|
||
|
||
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<GradeClass, number>;
|
||
|
||
/** 임도 종류별 유효너비(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<RoadWidths> {
|
||
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<RoadWidths> };
|
||
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<PipeFacility, string> = {
|
||
pipe: "배수관",
|
||
box_culvert: "BOX암거",
|
||
ford_pavement: "물넘이포장",
|
||
ford_bridge: "세월교",
|
||
revetment: "기슭막이",
|
||
};
|