Files
Aislo/B05_Profile/B05_Profile_UI_Page_Helpers.ts
T
eomsangdonandClaude Opus 5 f916aa52a1 feat(B05,B06): 독립 기슭막이를 배수관 시설 종류로 이관
독립 기슭막이(구조물 정본 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>
2026-08-28 19:09:28 +09:00

245 lines
11 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/* =============================================================================
* 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: "기슭막이",
};