refactor(common_util): 횡단 설계 계산 700줄 초과 분리 — 단면 기하
728줄 한 파일을 둘로 나눔 (계산 불변, 순수 이동). - `common_util_cross_design.ts` 405줄 — 표준단면 해석·설계선 조립·면적 산출 - `common_util_cross_design_geometry.ts` 333줄 — `SectionGeometry`(노면·측구·사면 설계고) 와 그것만 쓰는 상수(행진 간격·최대 거리)·`sideRole`·`ResolvedGroup` 파이썬 짝 파일(`B06_Section_Engine_Design.py`)은 손대지 않음 — 미러 관계 유지. 검증: 공용 브라우저 B06 횡단 카드 실측과 서버 계산값 대조 — 측점 0에서 절토 화면 2.50㎡ / 서버 2.4985㎡, 성토 화면 1.85㎡ / 서버 1.8521㎡ 로 일치 (카드 273장 렌더, 콘솔 오류 0). `tsc --noEmit` 통과, tmp/tests 378 passed Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -24,6 +24,8 @@
|
||||
* ========================================================================== */
|
||||
|
||||
import { splitCutAreas, trapezoidAreas } from "./common_util_cross_design_areas";
|
||||
// 단면 기하(노면·측구·사면 설계고)는 파일이 700줄을 넘어 떼어냈다(2026-09-04).
|
||||
import { SectionGeometry, type ResolvedGroup } from "./common_util_cross_design_geometry";
|
||||
|
||||
/** 지반유형 → 표준단면 프리셋 키. 짝: config `SECTION_GROUND_TYPE_PRESET`. */
|
||||
const GROUND_TYPE_PRESET: Record<string, string> = {
|
||||
@@ -39,10 +41,6 @@ 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;
|
||||
@@ -131,21 +129,6 @@ function asFloat(value: unknown, fallback: number): number {
|
||||
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 수치를 프론트에 복제하지 않기 위함이다.
|
||||
@@ -176,15 +159,6 @@ function resolveGroup(presetKey: string, standard: StandardCrossSectionSpec): Re
|
||||
};
|
||||
}
|
||||
|
||||
/** 짝: `_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";
|
||||
@@ -220,303 +194,6 @@ function groundInterpolator(valid: Array<[number, number]>): (offsetM: number) =
|
||||
};
|
||||
}
|
||||
|
||||
/** 짝: `_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`. 측점 하나의 표준횡단 설계선과 절·성토 단면적을 낸다.
|
||||
*
|
||||
|
||||
@@ -0,0 +1,333 @@
|
||||
/* =============================================================================
|
||||
* common_util_cross_design_geometry.ts
|
||||
* 표준횡단 단면 기하 — 노면·측구·사면 순으로 offset 의 설계고를 계산한다.
|
||||
*
|
||||
* ⚠ 짝 파일 `B06_Section/B06_Section_Engine_Design.py` 의 `_SectionGeometry` 와 한 벌이다.
|
||||
* `common_util_cross_design.ts` 가 700줄을 넘어 떼어냈다(2026-09-04) — 계산은 그대로다.
|
||||
* ========================================================================== */
|
||||
|
||||
/** 사면·경계 교차 탐색 행진 간격(m)과 최대 거리. 짝: 파이썬 `step`/`max_dist`. */
|
||||
const MARCH_STEP_M = 0.05;
|
||||
const KNEE_MAX_M = 200;
|
||||
const CROSS_MAX_M = 500;
|
||||
|
||||
export 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;
|
||||
}
|
||||
|
||||
/** 짝: `_side_role`. 단면유형 → [좌측 역할, 우측 역할]. */
|
||||
export 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}`);
|
||||
}
|
||||
|
||||
/** 짝: `_SectionGeometry`. 노면 → 측구 → 사면 순으로 offset 의 설계고를 계산한다. */
|
||||
export 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;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user