Files
Aislo/B05_Profile/B05_Profile_UI_Page_Helpers.ts
T
eomsangdonandClaude Opus 5.5 cfdcf0aed7 fix(472-5 남음): 암선 · 암 경사 · B05 상단측 메모리 맵 걷음 · 노선 저장 뒤 옛 노선 칸 · hold 비움
- 암 경계 · 암 절토 경사 저장소(`B06_Section_UI_Page_Persist`) = 세션 칸이 주인 — 읽을 때마다 측점 열쇠 칸(`readCells`) · 고칠 때 지금 칸을 읽어 그 측점만 바꿔 씀(`putCell`) · 메모리 맵 · load · 갈아끼움 다시 읽기(keyedStores · reloadKeyedStores) 걷음 · ↶ 뒤 다음 조작이 되돌림을 덮던 흠(N5) 고침
- 저장 재료 = `offsets()` · `ratios()`(새 Map) · ↶ ↷ 가 rockb · cutslope 칸을 바꾸면 조정창과 같은 onRestore 로 카드 · 면적 다시
- B05 상단측 메모리 맵(uphillOverrides · load/saveUphillOverrides) 걷음 — 읽는 곳 없이 저장 뒤 비우기만 하던 사본 · 값 주인 = 세션 칸 `uphill` · 상단측 차례 = 기본 조각 그대로
- 노선 바뀐 [저장] 성공 뒤(페이지 다시 세운 뒤) 옛 노선 번호의 노선 범위 초안(`clearRouteDrafts`) · `route-draft-hold` 비움 · 넘김 규칙 그대로 · 같은 번호면 노선 칸 안 지움 · 실패면 그대로
- 시험 test_472_5r_rock_owner 7(옛 코드 빨강 5) · 옛 단언 고침(176_3 · 271 · 443 상단측 조각 · 344_3 저장소 load)
- 위키 암선 · 암 경사 · ↶ ↷ 규칙 · 노선 저장 뒤 정리 줄

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-19 15:13:30 +09:00

226 lines
9.8 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 { 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)),
};
}
/** 노선 폴리라인 위 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,
};
});
}