사용자 조작 중 계산은 브라우저 안에서 끝나야 함(2026-09-03 사용자 확정). 계획선을 만질 때마다 전 측점 횡단 계산이 서버로 나가 조작 속도를 왕복이 지배했음. - `common_util_cross_design_areas.ts` — `B06_Section_Engine_Areas.py` 미러 (사다리꼴 적분 + 절토 토사/암반 분리). - `common_util_cross_design.ts` — `B06_Section_Engine_Design.py` 미러 (표준횡단 설계선 구성 + 단면적). config 수치는 복제하지 않고 `sections/context.standard_cross_section` 을 입력으로 받음. 두 파일 머리에 짝 파일·회귀 테스트를 가리키는 경고 블록을 둠 — 한쪽만 고치면 두 화면 값이 갈림. 아직 어느 화면도 이 모듈을 부르지 않음(다음 커밋에서 연결). Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
718 lines
29 KiB
TypeScript
718 lines
29 KiB
TypeScript
/* =============================================================================
|
|
* common_util_cross_design.ts
|
|
* B06 측점 표준횡단 설계 계산 — **브라우저 판**.
|
|
*
|
|
* ⚠⚠ 파이썬 짝 파일과 **한 벌**이다 — 한쪽만 고치면 두 화면 값이 갈린다 ⚠⚠
|
|
* 짝: `B06_Section/B06_Section_Engine_Design.py` (`compute_cross_design`)
|
|
* 면적 적분은 `common_util_cross_design_areas.ts` ↔ `B06_Section_Engine_Areas.py`.
|
|
* 회귀 테스트가 두 구현을 같은 입력으로 실제 비교한다:
|
|
* `tmp/tests/test_b06_cross_design_mirror.py` — 어느 쪽을 고치든 반드시 같이 돌릴 것.
|
|
*
|
|
* ── 왜 같은 계산이 두 벌인가 (2026-09-03 사용자 확정) ────────────────
|
|
* 사용자가 계획선을 만지는 동안의 계산은 **브라우저 안에서 끝나야 한다**. 조작은 세션
|
|
* 캐시에 쌓이고 화면은 즉시 따라오며, 서버는 [저장]·[확정]에서만 부른다. 계획고가 바뀔
|
|
* 때마다 서버에 전 측점 횡단을 물으면 왕복이 조작 속도를 지배한다(2026-09-03 실측 보고).
|
|
* 계획선 선형(`B05_Profile_Engine_Grade_Alignment.py` ↔ `B05_Profile_UI_Profile_Alignment.ts`)
|
|
* 이 이미 같은 이유로 1:1 미러다.
|
|
*
|
|
* ── 갈라지지 않게 하는 규칙 ──────────────────────────────────────────
|
|
* 1. **config 상수를 여기에 복제하지 않는다.** 표준단면 수치는 서버가 config 에서 내려
|
|
* 주는 `sections/context.standard_cross_section` 을 입력으로 받는다. 아래 상수는
|
|
* config 의 *열거값*(지반유형→프리셋 등)뿐이며 그마저 짝 파일과 나란히 둔다.
|
|
* 2. 반올림·경계 판정 상수까지 파이썬과 같은 값을 쓴다.
|
|
* 3. 새 필드를 더하면 양쪽 다 더하고 테스트 비교 목록에도 넣는다.
|
|
* ========================================================================== */
|
|
|
|
import { splitCutAreas, trapezoidAreas } from "./common_util_cross_design_areas";
|
|
|
|
/** 지반유형 → 표준단면 프리셋 키. 짝: config `SECTION_GROUND_TYPE_PRESET`. */
|
|
const GROUND_TYPE_PRESET: Record<string, string> = {
|
|
soil: "soil",
|
|
ripping_rock: "rock",
|
|
blasting_rock: "rock",
|
|
};
|
|
/** 단면유형. 짝: config `SECTION_MODES`. 좌=양(+)offset, 우=음(-)offset. */
|
|
const SECTION_MODES = ["left_cut", "right_cut", "both_cut", "both_fill"];
|
|
/** 짝: config `SECTION_DITCH_SIDES` / `SECTION_DITCH_TYPES`. */
|
|
const DITCH_SIDES = ["left", "right"];
|
|
const DITCH_TYPES = ["standard", "l_type"];
|
|
|
|
/** 사면이 원지반과 만났다고 볼 높이차(m). 짝: `_SLOPE_CLOSE_TOLERANCE_M`. */
|
|
const SLOPE_CLOSE_TOLERANCE_M = 0.01;
|
|
/** 사면·경계 교차 탐색 행진 간격(m)과 최대 거리. 짝: 파이썬 `step`/`max_dist`. */
|
|
const MARCH_STEP_M = 0.05;
|
|
const KNEE_MAX_M = 200;
|
|
const CROSS_MAX_M = 500;
|
|
|
|
export interface CrossGroundSample {
|
|
offset_m?: number | null;
|
|
elevation_m?: number | null;
|
|
valid?: boolean;
|
|
}
|
|
|
|
/** `sections/context.standard_cross_section` 한 그룹의 모양(서버가 config 에서 내려 준다). */
|
|
export interface StandardGroupSpec {
|
|
road_width_m?: number;
|
|
shoulder_left_m?: number;
|
|
shoulder_right_m?: number;
|
|
ditch?: { top_width_m?: number; bottom_width_m?: number; depth_m?: number };
|
|
ditch_l_type?: { width_m?: number; depth_m?: number };
|
|
cross_slope_pct?: { min?: number; max?: number };
|
|
fill_slope_ratio?: number;
|
|
cut_slope_ratio?: number;
|
|
pavement_thickness_m?: number;
|
|
}
|
|
|
|
/** 프리셋 키(soil/rock/paved) → 그룹 값. 세션 편집값도 같은 모양이다. */
|
|
export type StandardCrossSectionSpec = Record<string, StandardGroupSpec | undefined>;
|
|
|
|
export interface CrossDesignOptions {
|
|
groundType: string;
|
|
sectionMode: string;
|
|
ditchSide?: string | null;
|
|
ditchType?: string;
|
|
paved?: boolean;
|
|
/** 표준단면 수치(필수) — config 기본값 또는 그 위에 얹은 세션 편집값. */
|
|
standard: StandardCrossSectionSpec;
|
|
rockBoundaryOffsetM?: number | null;
|
|
twoStageSlope?: boolean;
|
|
ditchEnabled?: boolean | null;
|
|
/** 세월교 월류 높이만큼 노면을 통째로 내린다(m). */
|
|
surfaceDropM?: number;
|
|
}
|
|
|
|
export interface CrossDesignEdge {
|
|
offset_m: number;
|
|
elevation_m: number;
|
|
}
|
|
|
|
export interface CrossDesignResult {
|
|
ground_type: string;
|
|
geometry_preset: string;
|
|
section_mode: string;
|
|
ditch_side: string;
|
|
ditch_type: string | null;
|
|
cut_slope_ratio: number;
|
|
soil_cut_slope_ratio: number;
|
|
two_stage_slope: boolean;
|
|
fill_slope_ratio: number;
|
|
roadbed_width_m: number;
|
|
carriageway_width_m: number;
|
|
cross_slope_pct: number;
|
|
ditch: Record<string, unknown>;
|
|
ditch_enabled: boolean;
|
|
paved: boolean;
|
|
road_edges: { left: CrossDesignEdge; right: CrossDesignEdge };
|
|
carriageway_edges: { left: CrossDesignEdge; right: CrossDesignEdge };
|
|
design_elevation_m: number;
|
|
cut_area_m2: number;
|
|
cut_soil_area_m2: number;
|
|
cut_rock_area_m2: number;
|
|
cut_rock_kind: string | null;
|
|
fill_area_m2: number;
|
|
slope_unclosed: boolean;
|
|
fill_ground_slope: number | null;
|
|
ditch_area_m2: number;
|
|
design_line: CrossDesignEdge[];
|
|
surface_drop_m?: number;
|
|
pavement_thickness_m?: number;
|
|
rock_boundary_offset_m?: number;
|
|
}
|
|
|
|
/** 짝: 파이썬 `round(value, 4)`. */
|
|
function round4(value: number): number {
|
|
return Math.round(value * 1e4) / 1e4;
|
|
}
|
|
|
|
/** 짝: `_as_float` — 손상값·음수면 fallback. */
|
|
function asFloat(value: unknown, fallback: number): number {
|
|
const parsed = typeof value === "number" ? value : Number(value);
|
|
if (!Number.isFinite(parsed)) return fallback;
|
|
return parsed >= 0 ? parsed : fallback;
|
|
}
|
|
|
|
interface ResolvedGroup {
|
|
road_width_m: number;
|
|
shoulder_left_m: number;
|
|
shoulder_right_m: number;
|
|
ditch_top_width_m: number;
|
|
ditch_bottom_width_m: number;
|
|
ditch_depth_m: number;
|
|
l_ditch_width_m: number;
|
|
l_ditch_depth_m: number;
|
|
cross_slope_pct: number;
|
|
fill_slope_ratio: number;
|
|
cut_slope_ratio: number;
|
|
pavement_thickness_m: number;
|
|
}
|
|
|
|
/**
|
|
* 짝: `_resolve_group`. 파이썬은 config 위에 패널 편집값을 덮지만, 여기서는 이미 그렇게
|
|
* 합쳐진 한 벌(`standard`)을 받는다 — config 수치를 프론트에 복제하지 않기 위함이다.
|
|
*/
|
|
function resolveGroup(presetKey: string, standard: StandardCrossSectionSpec): ResolvedGroup {
|
|
const group = standard[presetKey];
|
|
if (!group) {
|
|
throw new Error(`표준횡단 설정에 '${presetKey}' 그룹이 없어 횡단 설계를 계산할 수 없습니다.`);
|
|
}
|
|
const ditch = group.ditch ?? {};
|
|
const lDitch = group.ditch_l_type ?? {};
|
|
const slope = group.cross_slope_pct ?? {};
|
|
return {
|
|
road_width_m: asFloat(group.road_width_m, 0),
|
|
shoulder_left_m: asFloat(group.shoulder_left_m, 0),
|
|
shoulder_right_m: asFloat(group.shoulder_right_m, 0),
|
|
ditch_top_width_m: asFloat(ditch.top_width_m, 0),
|
|
ditch_bottom_width_m: asFloat(ditch.bottom_width_m, 0),
|
|
ditch_depth_m: asFloat(ditch.depth_m, 0),
|
|
// 짝 파일의 `base.get("width_m", 0.5)`와 같은 최후 기본값.
|
|
l_ditch_width_m: asFloat(lDitch.width_m, 0.5),
|
|
l_ditch_depth_m: asFloat(lDitch.depth_m, 0.1),
|
|
// 횡단경사는 범위(min~max) 중 하한을 기본 채택한다(도면 표기 앞값).
|
|
cross_slope_pct: asFloat(slope.min, 0),
|
|
fill_slope_ratio: asFloat(group.fill_slope_ratio, 0),
|
|
cut_slope_ratio: asFloat(group.cut_slope_ratio, 0),
|
|
pavement_thickness_m: asFloat(group.pavement_thickness_m, 0.2),
|
|
};
|
|
}
|
|
|
|
/** 짝: `_side_role`. 단면유형 → [좌측 역할, 우측 역할]. */
|
|
function sideRole(sectionMode: string): [string, string] {
|
|
if (sectionMode === "left_cut") return ["cut", "fill"];
|
|
if (sectionMode === "right_cut") return ["fill", "cut"];
|
|
if (sectionMode === "both_cut") return ["cut", "cut"];
|
|
if (sectionMode === "both_fill") return ["fill", "fill"];
|
|
throw new Error(`지원하지 않는 단면유형입니다: ${sectionMode}`);
|
|
}
|
|
|
|
/** 짝: `_resolve_ditch_side`. */
|
|
function resolveDitchSide(sectionMode: string, ditchSide: string | null | undefined): string {
|
|
if (sectionMode === "left_cut") return "left";
|
|
if (sectionMode === "right_cut") return "right";
|
|
if (ditchSide && DITCH_SIDES.includes(ditchSide)) return ditchSide;
|
|
return "left";
|
|
}
|
|
|
|
/** 짝: `_ground_interpolator`. 정렬된 (offset, 지반고) 선형 보간(범위 밖 끝값 클램프). */
|
|
function groundInterpolator(valid: Array<[number, number]>): (offsetM: number) => number {
|
|
return (offsetM: number): number => {
|
|
if (offsetM <= valid[0][0]) return valid[0][1];
|
|
if (offsetM >= valid[valid.length - 1][0]) return valid[valid.length - 1][1];
|
|
for (let index = 1; index < valid.length; index += 1) {
|
|
const [x1, z1] = valid[index];
|
|
if (offsetM > x1) continue;
|
|
const [x0, z0] = valid[index - 1];
|
|
const span = x1 - x0;
|
|
if (span <= 0) return z1;
|
|
return z0 + (z1 - z0) * ((offsetM - x0) / span);
|
|
}
|
|
return valid[valid.length - 1][1];
|
|
};
|
|
}
|
|
|
|
/** 짝: `_SectionGeometry`. 노면 → 측구 → 사면 순으로 offset 의 설계고를 계산한다. */
|
|
class SectionGeometry {
|
|
halfRoad: number;
|
|
leftExtent: number;
|
|
rightExtent: number;
|
|
zCenter: number;
|
|
cutRatio: number;
|
|
fillRatio: number;
|
|
leftRole: string;
|
|
rightRole: string;
|
|
ditchSide: string;
|
|
soilCutRatio: number;
|
|
twoStage: boolean;
|
|
ditchType: string;
|
|
slopePerOffset: number;
|
|
hasDitch: boolean;
|
|
ditchPoints: Array<[number, number]> = [];
|
|
private groundAt: ((offsetM: number) => number) | null;
|
|
private rockOffset: number;
|
|
private rockKnee = new Map<string, [number, number] | null>();
|
|
private cutCross = new Map<string, number | null>();
|
|
private fillCross = new Map<string, number | null>();
|
|
|
|
constructor(params: {
|
|
designElevationM: number;
|
|
group: ResolvedGroup;
|
|
sectionMode: string;
|
|
ditchSide: string;
|
|
ditchType: string;
|
|
crossSlopePct: number;
|
|
groundAt: ((offsetM: number) => number) | null;
|
|
soilCutRatio: number | null;
|
|
rockBoundaryOffsetM: number | null;
|
|
twoStageSlope: boolean;
|
|
ditchEnabled: boolean | null;
|
|
}) {
|
|
const { group } = params;
|
|
const halfRoad = group.road_width_m / 2;
|
|
this.halfRoad = halfRoad;
|
|
this.leftExtent = halfRoad + group.shoulder_left_m;
|
|
this.rightExtent = halfRoad + group.shoulder_right_m;
|
|
this.zCenter = params.designElevationM;
|
|
this.cutRatio = Math.max(group.cut_slope_ratio, 1e-6);
|
|
this.fillRatio = Math.max(group.fill_slope_ratio, 1e-6);
|
|
[this.leftRole, this.rightRole] = sideRole(params.sectionMode);
|
|
this.ditchSide = params.ditchSide;
|
|
this.soilCutRatio = Math.max(params.soilCutRatio ?? group.cut_slope_ratio, 1e-6);
|
|
this.twoStage = Boolean(
|
|
params.twoStageSlope && params.groundAt !== null && params.rockBoundaryOffsetM !== null,
|
|
);
|
|
this.groundAt = params.groundAt;
|
|
this.rockOffset = params.rockBoundaryOffsetM ?? 0;
|
|
this.ditchType = params.ditchType;
|
|
// 횡단경사: 측구 방향으로 내려가는 단일 사면 (좌=+offset 규약).
|
|
const slope = params.crossSlopePct / 100;
|
|
this.slopePerOffset = params.ditchSide === "left" ? -slope : slope;
|
|
|
|
// 단면유형 자동 판정(D-2) — 노면 끝 지반이 설계면보다 높으면 절토, 낮으면 성토.
|
|
const groundAt = params.groundAt;
|
|
if (groundAt !== null) {
|
|
this.leftRole =
|
|
groundAt(this.leftExtent) > this.roadZ(this.leftExtent) + 1e-3 ? "cut" : "fill";
|
|
this.rightRole =
|
|
groundAt(-this.rightExtent) > this.roadZ(-this.rightExtent) + 1e-3 ? "cut" : "fill";
|
|
}
|
|
|
|
// 측구 생성 여부(D-1).
|
|
if (params.sectionMode === "both_fill") {
|
|
this.hasDitch = false;
|
|
} else if (params.ditchEnabled !== null && params.ditchEnabled !== undefined) {
|
|
this.hasDitch = params.ditchEnabled;
|
|
} else if (groundAt !== null) {
|
|
const ditchEdge = params.ditchSide === "left" ? this.leftExtent : -this.rightExtent;
|
|
this.hasDitch = groundAt(ditchEdge) > this.roadZ(ditchEdge) + 1e-3;
|
|
} else {
|
|
this.hasDitch = true;
|
|
}
|
|
|
|
// 측구 꼭짓점(측구측 노면 끝 기준, 바깥 방향 부호 적용).
|
|
const edgeOffset = params.ditchSide === "left" ? this.leftExtent : -this.rightExtent;
|
|
const outward = params.ditchSide === "left" ? 1 : -1;
|
|
const edgeZ = this.roadZ(edgeOffset);
|
|
if (this.hasDitch) {
|
|
if (params.ditchType === "l_type") {
|
|
// L형: 노면 끝에서 폭 W 동안 깊이 D로 내려가는 경사 바닥 + 바깥 수직벽.
|
|
const width = group.l_ditch_width_m;
|
|
const depth = group.l_ditch_depth_m;
|
|
this.ditchPoints = [
|
|
[edgeOffset, edgeZ],
|
|
[edgeOffset + outward * width, edgeZ - depth],
|
|
[edgeOffset + outward * width, edgeZ],
|
|
];
|
|
} else {
|
|
// 일반: 상단폭/저폭/깊이 사다리꼴.
|
|
const top = group.ditch_top_width_m;
|
|
const bottom = Math.min(group.ditch_bottom_width_m, top);
|
|
const depth = group.ditch_depth_m;
|
|
const inset = (top - bottom) / 2;
|
|
this.ditchPoints = [
|
|
[edgeOffset, edgeZ],
|
|
[edgeOffset + outward * inset, edgeZ - depth],
|
|
[edgeOffset + outward * (inset + bottom), edgeZ - depth],
|
|
[edgeOffset + outward * top, edgeZ],
|
|
];
|
|
}
|
|
}
|
|
}
|
|
|
|
/** 노면(노견 포함) 설계고 — 중심 계획고에서 횡단경사로 기운 단일 평면. */
|
|
roadZ(offsetM: number): number {
|
|
return this.zCenter + this.slopePerOffset * offsetM;
|
|
}
|
|
|
|
/** 짝: `_slope_start`. 사면 시작점 [오프셋 절대값 거리, 표고]. */
|
|
private slopeStart(side: string): [number, number] {
|
|
const edgeOffset = side === "left" ? this.leftExtent : this.rightExtent;
|
|
const edgeZ = side === "left" ? this.roadZ(this.leftExtent) : this.roadZ(-this.rightExtent);
|
|
if (side === this.ditchSide && this.ditchPoints.length) {
|
|
const outer = this.ditchPoints[this.ditchPoints.length - 1];
|
|
return [Math.abs(outer[0]), outer[1]];
|
|
}
|
|
return [edgeOffset, edgeZ];
|
|
}
|
|
|
|
/** 짝: `_rock_boundary_z`. 암반 경계선 표고 = 지반선 + 오프셋(음수=하향). */
|
|
private rockBoundaryZ(side: string, dist: number): number {
|
|
const signed = side === "left" ? dist : -dist;
|
|
return (this.groundAt as (offsetM: number) => number)(signed) + this.rockOffset;
|
|
}
|
|
|
|
/** 짝: `knee`. 절토 사면이 암반 경계선을 지나는 전환점(무릎). */
|
|
knee(side: string): [number, number] | null {
|
|
if (!this.twoStage) return null;
|
|
const cached = this.rockKnee.get(side);
|
|
if (cached !== undefined) return cached;
|
|
const [startDist, startZ] = this.slopeStart(side);
|
|
let diffPrev = startZ - this.rockBoundaryZ(side, startDist);
|
|
let result: [number, number] | null;
|
|
if (diffPrev >= 0) {
|
|
result = [startDist, startZ]; // 시작부터 토사(경계 위)
|
|
} else {
|
|
result = null;
|
|
let distPrev = startDist;
|
|
let dist = startDist + MARCH_STEP_M;
|
|
while (dist <= startDist + KNEE_MAX_M) {
|
|
const zRock = startZ + (dist - startDist) / this.cutRatio;
|
|
const diff = zRock - this.rockBoundaryZ(side, dist);
|
|
if (diff >= 0) {
|
|
const span = diff - diffPrev;
|
|
const ratio = Math.abs(span) > 1e-9 ? -diffPrev / span : 0;
|
|
const kneeDist = distPrev + (dist - distPrev) * ratio;
|
|
const kneeZ = startZ + (kneeDist - startDist) / this.cutRatio;
|
|
result = [kneeDist, kneeZ];
|
|
break;
|
|
}
|
|
distPrev = dist;
|
|
diffPrev = diff;
|
|
dist += MARCH_STEP_M;
|
|
}
|
|
}
|
|
this.rockKnee.set(side, result);
|
|
return result;
|
|
}
|
|
|
|
/** 짝: `_cut_slope_z`. 절토 사면선 표고(2단계 무릎 반영, 지반 클램프 없음). */
|
|
private cutSlopeZ(side: string, dist: number): number {
|
|
const [startDist, startZ] = this.slopeStart(side);
|
|
const knee = this.twoStage ? this.knee(side) : null;
|
|
if (knee !== null) {
|
|
const [kneeDist, kneeZ] = knee;
|
|
if (dist <= kneeDist) return startZ + (dist - startDist) / this.cutRatio;
|
|
return kneeZ + (dist - kneeDist) / this.soilCutRatio;
|
|
}
|
|
return startZ + (dist - startDist) / this.cutRatio;
|
|
}
|
|
|
|
/** 짝: `cut_cross_dist`. 절토 사면이 지반선과 처음 만나는 거리(N-2-4). */
|
|
cutCrossDist(side: string): number | null {
|
|
const cached = this.cutCross.get(side);
|
|
if (cached !== undefined) return cached;
|
|
let result: number | null = null;
|
|
if (this.groundAt !== null) {
|
|
const [startDist] = this.slopeStart(side);
|
|
let dist = startDist;
|
|
const maxDist = startDist + CROSS_MAX_M;
|
|
while (dist <= maxDist) {
|
|
const signed = side === "left" ? dist : -dist;
|
|
if (this.cutSlopeZ(side, dist) - this.groundAt(signed) >= 0) {
|
|
result = dist;
|
|
break;
|
|
}
|
|
dist += MARCH_STEP_M;
|
|
}
|
|
}
|
|
this.cutCross.set(side, result);
|
|
return result;
|
|
}
|
|
|
|
/** 짝: `fill_cross_dist`. 성토 사면이 지반선과 처음 만나는 거리. */
|
|
fillCrossDist(side: string): number | null {
|
|
const cached = this.fillCross.get(side);
|
|
if (cached !== undefined) return cached;
|
|
let result: number | null = null;
|
|
if (this.groundAt !== null) {
|
|
const [startDist, startZ] = this.slopeStart(side);
|
|
let dist = startDist;
|
|
const maxDist = startDist + CROSS_MAX_M;
|
|
while (dist <= maxDist) {
|
|
const signed = side === "left" ? dist : -dist;
|
|
const fillLine = startZ - (dist - startDist) / this.fillRatio;
|
|
if (fillLine - this.groundAt(signed) <= 0) {
|
|
result = dist;
|
|
break;
|
|
}
|
|
dist += MARCH_STEP_M;
|
|
}
|
|
}
|
|
this.fillCross.set(side, result);
|
|
return result;
|
|
}
|
|
|
|
/** 짝: `fill_ground_slope`. 성토측 자연 지반 평균 경사(rise/run). */
|
|
fillGroundSlope(): number | null {
|
|
if (this.groundAt === null) return null;
|
|
const slopes: number[] = [];
|
|
for (const side of ["left", "right"]) {
|
|
const role = side === "left" ? this.leftRole : this.rightRole;
|
|
if (role !== "fill") continue;
|
|
const [startDist] = this.slopeStart(side);
|
|
const endDist = this.fillCrossDist(side) ?? startDist + 10;
|
|
const run = endDist - startDist;
|
|
if (run <= 1e-6) continue;
|
|
const sign = side === "left" ? 1 : -1;
|
|
const rise = Math.abs(this.groundAt(sign * endDist) - this.groundAt(sign * startDist));
|
|
slopes.push(rise / run);
|
|
}
|
|
return slopes.length ? Math.min(...slopes) : null;
|
|
}
|
|
|
|
/** 짝: `design_z`. offset 하나의 설계 표고(사면은 지반 교차점 이후 지반 추종). */
|
|
designZ(offsetM: number, groundM: number): number {
|
|
const side = offsetM >= 0 ? "left" : "right";
|
|
const extent = side === "left" ? this.leftExtent : this.rightExtent;
|
|
if (Math.abs(offsetM) <= extent + 1e-9) return this.roadZ(offsetM);
|
|
// 측구 구간: 꼭짓점 사이 선형 보간(지반 무관 강제 굴착).
|
|
if (side === this.ditchSide && this.ditchPoints.length) {
|
|
const inner = Math.abs(this.ditchPoints[0][0]);
|
|
const outer = Math.abs(this.ditchPoints[this.ditchPoints.length - 1][0]);
|
|
if (Math.abs(offsetM) >= inner - 1e-9 && Math.abs(offsetM) <= outer + 1e-9) {
|
|
const points = this.ditchPoints;
|
|
for (let index = 1; index < points.length; index += 1) {
|
|
const x0 = Math.abs(points[index - 1][0]);
|
|
const z0 = points[index - 1][1];
|
|
const x1 = Math.abs(points[index][0]);
|
|
const z1 = points[index][1];
|
|
if (Math.abs(offsetM) > x1 + 1e-9) continue;
|
|
const span = x1 - x0;
|
|
if (span <= 1e-9) return z1;
|
|
return z0 + (z1 - z0) * ((Math.abs(offsetM) - x0) / span);
|
|
}
|
|
return points[points.length - 1][1];
|
|
}
|
|
}
|
|
const role = side === "left" ? this.leftRole : this.rightRole;
|
|
const [startDist, startZ] = this.slopeStart(side);
|
|
const dist = Math.abs(offsetM);
|
|
const run = dist - startDist;
|
|
if (role === "cut") {
|
|
const cross = this.cutCrossDist(side);
|
|
if (cross !== null && dist >= cross) return groundM;
|
|
return Math.min(this.cutSlopeZ(side, dist), groundM);
|
|
}
|
|
const cross = this.fillCrossDist(side);
|
|
if (cross !== null && dist >= cross) return groundM;
|
|
return Math.max(startZ - run / this.fillRatio, groundM);
|
|
}
|
|
|
|
/** 짝: `breakpoints`. 적분·설계선에 반드시 넣을 설계 꼭짓점 오프셋. */
|
|
breakpoints(): number[] {
|
|
const points = [0, this.leftExtent, -this.rightExtent];
|
|
for (const [offset] of this.ditchPoints) points.push(offset);
|
|
if (this.twoStage) {
|
|
for (const side of ["left", "right"]) {
|
|
const role = side === "left" ? this.leftRole : this.rightRole;
|
|
const knee = role === "cut" ? this.knee(side) : null;
|
|
if (knee !== null) points.push(side === "left" ? knee[0] : -knee[0]);
|
|
}
|
|
}
|
|
for (const side of ["left", "right"]) {
|
|
const role = side === "left" ? this.leftRole : this.rightRole;
|
|
const cross = role === "cut" ? this.cutCrossDist(side) : this.fillCrossDist(side);
|
|
if (cross !== null) points.push(side === "left" ? cross : -cross);
|
|
}
|
|
return points;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* 짝: `compute_cross_design`. 측점 하나의 표준횡단 설계선과 절·성토 단면적을 낸다.
|
|
*
|
|
* `samples` 는 지반선 원시 샘플, `designElevationM` 은 중심선 계획고(노면고)다.
|
|
* 계산 불가(계획고 없음·샘플 부족·잘못된 유형)면 던진다 — 호출부가 그 측점을 건너뛴다.
|
|
*/
|
|
export function computeCrossDesign(
|
|
samples: CrossGroundSample[],
|
|
designElevationM: number | null | undefined,
|
|
options: CrossDesignOptions,
|
|
): CrossDesignResult {
|
|
const groundType = options.groundType;
|
|
const sectionMode = options.sectionMode;
|
|
const ditchType = options.ditchType ?? "standard";
|
|
const paved = Boolean(options.paved);
|
|
if (!(groundType in GROUND_TYPE_PRESET)) {
|
|
throw new Error(`지원하지 않는 지반유형입니다: ${groundType}`);
|
|
}
|
|
if (!SECTION_MODES.includes(sectionMode)) {
|
|
throw new Error(`지원하지 않는 단면유형입니다: ${sectionMode}`);
|
|
}
|
|
if (!DITCH_TYPES.includes(ditchType)) {
|
|
throw new Error(`지원하지 않는 측구 형식입니다: ${ditchType}`);
|
|
}
|
|
if (designElevationM === null || designElevationM === undefined) {
|
|
throw new Error("계획고(design_elevation_m)가 없어 횡단 설계를 계산할 수 없습니다.");
|
|
}
|
|
const drop = Math.max(options.surfaceDropM ?? 0, 0);
|
|
const centerElevation = designElevationM - drop;
|
|
|
|
const presetKey = GROUND_TYPE_PRESET[groundType];
|
|
if (ditchType === "l_type" && presetKey !== "rock") {
|
|
throw new Error("L형 측구는 암(리핑암/발파암) 구간에서만 선택할 수 있습니다.");
|
|
}
|
|
const group = resolveGroup(presetKey, options.standard);
|
|
const pavedGroup = resolveGroup("paved", options.standard);
|
|
// 포장 중첩: 횡단경사와 포장층 두께만 포장 그룹을 따른다.
|
|
const crossSlopePct = paved ? pavedGroup.cross_slope_pct : group.cross_slope_pct;
|
|
const resolvedDitchSide = resolveDitchSide(sectionMode, options.ditchSide);
|
|
|
|
const valid: Array<[number, number]> = samples
|
|
.filter(
|
|
(sample) =>
|
|
sample.valid !== false &&
|
|
sample.offset_m !== null &&
|
|
sample.offset_m !== undefined &&
|
|
sample.elevation_m !== null &&
|
|
sample.elevation_m !== undefined,
|
|
)
|
|
.map((sample) => [Number(sample.offset_m), Number(sample.elevation_m)] as [number, number])
|
|
.sort((a, b) => a[0] - b[0]);
|
|
if (valid.length < 2) {
|
|
throw new Error("유효한 지반 샘플이 부족해 횡단 설계를 계산할 수 없습니다.");
|
|
}
|
|
|
|
const groundAt = groundInterpolator(valid);
|
|
const rockBoundaryOffsetM = options.rockBoundaryOffsetM ?? null;
|
|
// 2단계 절토는 암 프리셋에서만, 암반 경계 오프셋이 있을 때만 켠다.
|
|
const enableTwoStage =
|
|
presetKey === "rock" && (options.twoStageSlope ?? true) && rockBoundaryOffsetM !== null;
|
|
const geometry = new SectionGeometry({
|
|
designElevationM: centerElevation,
|
|
group,
|
|
sectionMode,
|
|
ditchSide: resolvedDitchSide,
|
|
ditchType,
|
|
crossSlopePct,
|
|
groundAt,
|
|
soilCutRatio: resolveGroup("soil", options.standard).cut_slope_ratio,
|
|
rockBoundaryOffsetM,
|
|
twoStageSlope: enableTwoStage,
|
|
ditchEnabled: options.ditchEnabled ?? null,
|
|
});
|
|
|
|
// 적분 오프셋 = 지반 샘플 ∪ 설계 꼭짓점(샘플 범위 안쪽만).
|
|
const minOffset = valid[0][0];
|
|
const maxOffset = valid[valid.length - 1][0];
|
|
const mergedSet = new Set<number>(valid.map(([offset]) => round6(offset)));
|
|
for (const point of geometry.breakpoints()) {
|
|
if (point >= minOffset && point <= maxOffset) mergedSet.add(round6(point));
|
|
}
|
|
const merged = [...mergedSet].sort((a, b) => a - b);
|
|
|
|
const offsets: number[] = [];
|
|
const diffs: number[] = [];
|
|
const designLine: CrossDesignEdge[] = [];
|
|
for (const offsetM of merged) {
|
|
const groundM = groundAt(offsetM);
|
|
const designZ = geometry.designZ(offsetM, groundM);
|
|
offsets.push(offsetM);
|
|
diffs.push(groundM - designZ);
|
|
designLine.push({ offset_m: round4(offsetM), elevation_m: round4(designZ) });
|
|
}
|
|
|
|
// 측구 굴착은 설계선에 포함돼 절토 면적에 자연 반영된다(별도 가산 없음).
|
|
const [cutArea, fillArea] = trapezoidAreas(offsets, diffs);
|
|
const fillGroundSlope = geometry.fillGroundSlope();
|
|
const slopeUnclosed =
|
|
diffs.length > 0 &&
|
|
(Math.abs(diffs[0]) > SLOPE_CLOSE_TOLERANCE_M ||
|
|
Math.abs(diffs[diffs.length - 1]) > SLOPE_CLOSE_TOLERANCE_M);
|
|
|
|
// 절토면적 토사/암반 분리 — 지표면~암반 경계선이 토사, 그 아래가 암.
|
|
let cutSoilArea: number;
|
|
let cutRockArea: number;
|
|
let cutRockKind: string | null;
|
|
if (presetKey !== "rock") {
|
|
cutSoilArea = cutArea;
|
|
cutRockArea = 0;
|
|
cutRockKind = null;
|
|
} else if (rockBoundaryOffsetM === null) {
|
|
cutSoilArea = 0;
|
|
cutRockArea = cutArea;
|
|
cutRockKind = groundType;
|
|
} else {
|
|
[cutSoilArea, cutRockArea] = splitCutAreas(offsets, diffs, Math.abs(rockBoundaryOffsetM));
|
|
cutRockKind = groundType;
|
|
}
|
|
|
|
// 측구 공칭 단면적(수량 산출 참고용): 일반=사다리꼴, L형=직각삼각형 근사.
|
|
let ditchArea: number;
|
|
let ditchSpec: Record<string, unknown>;
|
|
if (!geometry.hasDitch) {
|
|
ditchArea = 0;
|
|
ditchSpec = { type: "none" };
|
|
} else if (ditchType === "l_type") {
|
|
ditchArea = (group.l_ditch_width_m * group.l_ditch_depth_m) / 2;
|
|
ditchSpec = {
|
|
type: "l_type",
|
|
width_m: group.l_ditch_width_m,
|
|
depth_m: group.l_ditch_depth_m,
|
|
};
|
|
} else {
|
|
ditchArea = ((group.ditch_top_width_m + group.ditch_bottom_width_m) / 2) * group.ditch_depth_m;
|
|
ditchSpec = {
|
|
type: "standard",
|
|
top_width_m: group.ditch_top_width_m,
|
|
bottom_width_m: group.ditch_bottom_width_m,
|
|
depth_m: group.ditch_depth_m,
|
|
};
|
|
}
|
|
|
|
// 자동 판정된 절/성토 역할에서 실제 단면 유형을 도출해 echo 한다(D-2).
|
|
let resolvedMode: string;
|
|
if (geometry.leftRole === "cut" && geometry.rightRole === "cut") resolvedMode = "both_cut";
|
|
else if (geometry.leftRole === "fill" && geometry.rightRole === "fill") resolvedMode = "both_fill";
|
|
else if (geometry.leftRole === "cut") resolvedMode = "left_cut";
|
|
else resolvedMode = "right_cut";
|
|
|
|
const result: CrossDesignResult = {
|
|
ground_type: groundType,
|
|
geometry_preset: presetKey,
|
|
section_mode: resolvedMode,
|
|
ditch_side: resolvedDitchSide,
|
|
ditch_type: geometry.hasDitch ? ditchType : null,
|
|
cut_slope_ratio: round4(geometry.cutRatio),
|
|
soil_cut_slope_ratio: round4(geometry.soilCutRatio),
|
|
two_stage_slope: geometry.twoStage,
|
|
fill_slope_ratio: round4(geometry.fillRatio),
|
|
roadbed_width_m: round4(geometry.leftExtent + geometry.rightExtent),
|
|
carriageway_width_m: round4(group.road_width_m),
|
|
cross_slope_pct: round4(crossSlopePct),
|
|
ditch: ditchSpec,
|
|
ditch_enabled: geometry.hasDitch,
|
|
paved,
|
|
road_edges: {
|
|
left: {
|
|
offset_m: round4(geometry.leftExtent),
|
|
elevation_m: round4(geometry.roadZ(geometry.leftExtent)),
|
|
},
|
|
right: {
|
|
offset_m: round4(-geometry.rightExtent),
|
|
elevation_m: round4(geometry.roadZ(-geometry.rightExtent)),
|
|
},
|
|
},
|
|
carriageway_edges: {
|
|
left: {
|
|
offset_m: round4(geometry.halfRoad),
|
|
elevation_m: round4(geometry.roadZ(geometry.halfRoad)),
|
|
},
|
|
right: {
|
|
offset_m: round4(-geometry.halfRoad),
|
|
elevation_m: round4(geometry.roadZ(-geometry.halfRoad)),
|
|
},
|
|
},
|
|
design_elevation_m: round4(centerElevation),
|
|
cut_area_m2: round4(cutArea),
|
|
cut_soil_area_m2: round4(cutSoilArea),
|
|
cut_rock_area_m2: round4(cutRockArea),
|
|
cut_rock_kind: cutRockKind,
|
|
fill_area_m2: round4(fillArea),
|
|
slope_unclosed: slopeUnclosed,
|
|
fill_ground_slope: fillGroundSlope === null ? null : round4(fillGroundSlope),
|
|
ditch_area_m2: round4(ditchArea),
|
|
design_line: designLine,
|
|
};
|
|
if (drop > 0) result.surface_drop_m = round4(drop);
|
|
if (paved) result.pavement_thickness_m = round4(pavedGroup.pavement_thickness_m);
|
|
if (presetKey === "rock" && rockBoundaryOffsetM !== null) {
|
|
result.rock_boundary_offset_m = round4(rockBoundaryOffsetM);
|
|
}
|
|
return result;
|
|
}
|
|
|
|
/** 짝: 파이썬 `round(offset, 6)` — 병합 격자 중복 제거 기준. */
|
|
function round6(value: number): number {
|
|
return Math.round(value * 1e6) / 1e6;
|
|
}
|