Files
Aislo/B05_Profile/B05_Profile_UI_Page_Helpers.ts
T
eomsangdonandClaude Opus 5 22d4df5aff feat(B05): 3D 예상형상에 배수관 세트 구조물 반영
B06 횡단(computeCulvertLayout)을 그대로 재사용해 3D 코리도에 기슭막이·집수정·
배관을 세우고, 그 구간 성토/절토 단면을 횡단도와 같은 형상으로 다시 그린다.

구조물 솔리드(_Corridor_Structures.ts 신규)
- 기슭막이: 단면 폴리곤을 노선 따라 1m 간격 로프트 스윕(직선 압출 아님)
- 집수정: 부재 외곽 직육면체, 연장 basin_length_m(기본 2m) 전후 균등
- 배관: 관 하단선 축 원통(관경 지름)
- 스윕 프레임에 계획고 차(dz)를 실어 종단 경사 반영
- flat 법선 + EdgesGeometry 모서리 검은선(성토부·노폭 리본과 같은 규칙)

단면 재구성(_Corridor_Station.ts)
- 구조물 측 비탈 = 노견 → (노폭 연장) → 구조물 상단 → 전면 → 벽 하단 성토부선
  → 다단 기슭막이 → 지반/절토선 끝 한 줄(PIECE_COLS cut·fill 9 -> 20)
- 성토로 늘어난 노폭은 비탈이 아니라 노견 리본을 넓힌다
- 집수정 계류측 cutLine까지 이어 원지반을 그만큼 걷어낸다

구간 처리(_Corridor_Build.ts)
- 구조물 점유 구간(기준측점 전/후)이 다음 측점을 넘어가도 같은 단면 유지
- 그 구간은 지반 트림 생략, 비탈 앵커를 노견 조각 바깥 끝으로 통일

길이·전후 입력
- 레지스트리 pipe 옵션 추가: inlet/outlet_revet_before_m·after_m(5/5),
  inlet_basin_length_m(2)
- B05 배수관 서브폼에 기준측점 전/후 칸(전+후=길이 연동)·집수정 길이 칸
- _side_spec이 전/후·집수정 길이를 CulvertSideSpec으로 전달

비정규 측점 프레임 정정(_Page_Helpers.ts)
- left_xy 선형보간(단위길이 깨짐·방향이 현 쪽으로 누움) -> 노선 폴리라인 접선에서
  직접 산출(left = (-ty, tx)), 폴백은 각도 보간

BUILD_VERSION 10 — 저장본 자동 만료

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-23 21:46:48 +09:00

244 lines
10 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: "세월교",
};