계단이 값에만 있던 것을 화면까지 연결함. 배선 — 세션 열쇠 `berm`(측점키 → 폭·간격·기울기)을 등록표에 두고, `readBermSession` 으로 읽어 ① 브라우저 재계산(`refreshCrossDesigns`)과 ② 서버 미리보기(`cross-design/preview` 의 `berms`) 양쪽에 실음. 암 경계선 오프셋과 같은 길이라 「계획선을 고치면 계단이 사라지는」 일이 없음. 실화면 확인(8001·5174, `/api/health` `stale:false`) — 측점 4120.0m 에 소단을 놓고 계획고를 한 칸 올렸다 내려 전 구간 재계산을 태움. · 폭 0.5m · 간격 3.0m → 절토 6.84 → 8.10㎡ (토사 2.42→3.24 · 암 4.42→4.86) · 폭 1.0m · 간격 2.0m → 절토 6.84 → 12.96㎡, 횡단도에 **계단이 눈으로 보임** · 소단을 안 놓은 옆 측점(4100.0m)은 3.77㎡ 그대로 — 놓은 곳만 달라짐 · 되돌린 뒤 6.84㎡ 로 복귀. [저장]·[확정] 안 눌렀으므로 정본은 그대로. `cut_slope_segments` 신설 — 절토 사면을 경사 구간별로 쪼갠 목록(파이썬·TS 짝). 법정 경사 검사가 읽을 값임(다른 창 요청). 소단이 서면 사면 전체를 하나로 재는 「실효 경사」가 완만해져 **위반이 사라진 것처럼** 보이므로(폭 1.0·간격 2 이면 설계 1:1 이 실효 1:1.71), 검사는 소단을 뺀 구간 자체를 봐야 함. 실측 — 소단을 놓아도 구간별 경사비는 1.0 그대로 나옴. 평탄부(소단)와 지반 만난 뒤 구간은 싣지 않음. 자체검증 — 거울 시험에 사면 구간 대조를 더해 파이썬·TS 일치 확인. 전체 539 passed · 18 skipped. TS 타입 검사 통과. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
428 lines
17 KiB
TypeScript
428 lines
17 KiB
TypeScript
/* =============================================================================
|
||
* common_util_cross_design_geometry.ts
|
||
* 표준횡단 단면 기하 — 노면·측구·사면 순으로 offset 의 설계고를 계산한다.
|
||
*
|
||
* ⚠ 짝 파일 `B06_Section/B06_Section_Engine_Design.py` 의 `_SectionGeometry` 와 한 벌이다.
|
||
* `common_util_cross_design.ts` 가 700줄을 넘어 떼어냈다(2026-09-04) — 계산은 그대로다.
|
||
* ========================================================================== */
|
||
|
||
import { type BermSpec, cutProfilePoints, elevationAt } from "./common_util_cross_berm";
|
||
|
||
/** 절토 사면 경사 구간 한 칸 — 짝 파이썬 `cut_slope_segments` 와 같은 항목. */
|
||
export interface CutSlopeSegment {
|
||
side: string;
|
||
ratio: number;
|
||
rise_m: number;
|
||
run_m: number;
|
||
start_offset_m: number;
|
||
end_offset_m: number;
|
||
material: string | null;
|
||
}
|
||
|
||
/** 파이썬 `round(x, 4)` 와 같은 자리 맞춤. */
|
||
function round4(value: number): number {
|
||
return Math.round(value * 10000) / 10000;
|
||
}
|
||
|
||
/** 사면·경계 교차 탐색 행진 간격(m)과 최대 거리. 짝: 파이썬 `step`/`max_dist`. */
|
||
const MARCH_STEP_M = 0.05;
|
||
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}`);
|
||
}
|
||
|
||
/**
|
||
* 곡선부 너비 확폭표 — **짝: `config_system_design.CURVE_WIDENING_TABLE_M`**
|
||
* (별표2 Ⅰ.2.나.(4)). `[반경 하한, 반경 상한(미만), 확폭(m)]`이고 45m 이상은 확폭이 없다.
|
||
* 두 벌이 한 세트이므로 값을 고칠 때는 파이썬 쪽도 함께 고친다.
|
||
*/
|
||
const CURVE_WIDENING_TABLE_M: ReadonlyArray<readonly [number, number, number]> = [
|
||
[10, 13, 2.25],
|
||
[13, 14, 2.0],
|
||
[14, 15, 1.75],
|
||
[15, 18, 1.5],
|
||
[18, 20, 1.25],
|
||
[20, 25, 1.0],
|
||
[25, 30, 0.75],
|
||
[30, 40, 0.5],
|
||
[40, 45, 0.25],
|
||
];
|
||
|
||
/** 확폭을 더한 뒤의 유효너비 상한(m) — 짝: `CURVE_WIDENING_MAX_WIDTH_M`. */
|
||
export const CURVE_WIDENING_MAX_WIDTH_M = 5.0;
|
||
|
||
/** 평면 곡선반경으로 확폭량(m)을 정한다. 직선·45m 이상·값 없음은 0. */
|
||
export function curveWideningM(planRadiusM: number | null | undefined): number {
|
||
if (planRadiusM === null || planRadiusM === undefined || !Number.isFinite(planRadiusM)) return 0;
|
||
const found = CURVE_WIDENING_TABLE_M.find(
|
||
([low, high]) => planRadiusM >= low && planRadiusM < high,
|
||
);
|
||
return found ? found[2] : 0;
|
||
}
|
||
|
||
/** 짝: `_SectionGeometry`. 노면 → 측구 → 사면 순으로 offset 의 설계고를 계산한다. */
|
||
export class SectionGeometry {
|
||
/** 규격 차도 반폭(확폭 전) — 수량·표기 기준. */
|
||
halfRoad: number;
|
||
/** 좌(+)·우(−) 차도 반폭 — 곡선부 확폭이 **한쪽에만** 붙어 좌우가 갈린다(2026-09-06). */
|
||
halfRoadLeft: number;
|
||
halfRoadRight: 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;
|
||
/** 소단 제원(없으면 null) — 절토 사면 꼭짓점 셈에 그대로 넘어간다. */
|
||
berm: BermSpec | null = null;
|
||
private cutPointsCache = new Map<string, Array<[number, number]>>();
|
||
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;
|
||
/** 곡선부 확폭(m) — 붙는 쪽만 값이 있고 반대쪽은 0이다. */
|
||
wideningLeftM?: number;
|
||
wideningRightM?: number;
|
||
/** 소단 제원 — 없으면 계단 없이 종전 사면 그대로(계획서 3-9). */
|
||
berm?: BermSpec | null;
|
||
}) {
|
||
const { group } = params;
|
||
const halfRoad = group.road_width_m / 2;
|
||
this.halfRoad = halfRoad;
|
||
this.halfRoadLeft = halfRoad + Math.max(params.wideningLeftM ?? 0, 0);
|
||
this.halfRoadRight = halfRoad + Math.max(params.wideningRightM ?? 0, 0);
|
||
this.leftExtent = this.halfRoadLeft + group.shoulder_left_m;
|
||
this.rightExtent = this.halfRoadRight + 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.berm = params.berm ?? null;
|
||
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;
|
||
}
|
||
|
||
/** 짝: `cut_points`. 절토 사면 꼭짓점 — 무릎과 소단이 모두 여기 들어 있다. */
|
||
cutPoints(side: string): Array<[number, number]> {
|
||
const cached = this.cutPointsCache.get(side);
|
||
if (cached !== undefined) return cached;
|
||
const [startDist, startZ] = this.slopeStart(side);
|
||
const boundary = this.twoStage ? (dist: number) => this.rockBoundaryZ(side, dist) : null;
|
||
const points = cutProfilePoints(
|
||
startDist,
|
||
startZ,
|
||
this.cutRatio,
|
||
this.soilCutRatio,
|
||
boundary,
|
||
this.berm,
|
||
undefined,
|
||
// 소단이 있으면 경계를 오갈 때마다 꺾는다 — 짝 파이썬과 같은 이유(2026-09-07).
|
||
this.berm !== null,
|
||
);
|
||
this.cutPointsCache.set(side, points);
|
||
return points;
|
||
}
|
||
|
||
/** 짝: `_cut_slope_z`. 절토 사면선 표고(무릎·소단 반영, 지반 클램프 없음). */
|
||
private cutSlopeZ(side: string, dist: number): number {
|
||
return elevationAt(this.cutPoints(side), dist);
|
||
}
|
||
|
||
/** 짝: `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);
|
||
}
|
||
|
||
/**
|
||
* 짝: `cut_slope_segments`. 절토 사면을 **경사 구간별로** 쪼갠 목록 — 법정 경사 검사용.
|
||
*
|
||
* 소단이 서면 사면 전체를 하나로 재는 「실효 경사」가 완만해져 위반이 사라진 것처럼
|
||
* 보인다. 검사는 소단을 뺀 **사면 구간 자체**를 봐야 하므로 그 구간을 내보낸다.
|
||
*/
|
||
cutSlopeSegments(): CutSlopeSegment[] {
|
||
const segments: CutSlopeSegment[] = [];
|
||
for (const side of ["left", "right"]) {
|
||
const role = side === "left" ? this.leftRole : this.rightRole;
|
||
if (role !== "cut") continue;
|
||
const cross = this.cutCrossDist(side);
|
||
const points = this.cutPoints(side);
|
||
const sign = side === "left" ? 1 : -1;
|
||
for (let index = 1; index < points.length; index += 1) {
|
||
const [startD, startZ] = points[index - 1];
|
||
let [endD, endZ] = points[index];
|
||
if (cross !== null && startD >= cross - 1e-9) break; // 지반과 만난 뒤는 절토가 없다
|
||
if (cross !== null && endD > cross) {
|
||
endZ = elevationAt(points, cross);
|
||
endD = cross;
|
||
}
|
||
const run = endD - startD;
|
||
const rise = endZ - startZ;
|
||
if (run <= 1e-9 || rise <= 1e-6) continue;
|
||
if (this.berm !== null && Math.abs(run - this.berm.widthM) < 1e-6) {
|
||
const bermRise = Math.tan((this.berm.slopeDeg * Math.PI) / 180) * this.berm.widthM;
|
||
if (Math.abs(rise - bermRise) < 1e-9) continue; // 소단(평탄부)
|
||
}
|
||
let material: string | null = null;
|
||
if (this.twoStage) {
|
||
const middleD = (startD + endD) / 2;
|
||
const middleZ = (startZ + endZ) / 2;
|
||
material = middleZ >= this.rockBoundaryZ(side, middleD) ? "soil" : "rock";
|
||
}
|
||
segments.push({
|
||
side,
|
||
ratio: round4(run / rise),
|
||
rise_m: round4(rise),
|
||
run_m: round4(run),
|
||
start_offset_m: round4(sign * startD),
|
||
end_offset_m: round4(sign * endD),
|
||
material,
|
||
});
|
||
}
|
||
}
|
||
return segments;
|
||
}
|
||
|
||
/** 짝: `breakpoints`. 적분·설계선에 반드시 넣을 설계 꼭짓점 오프셋. */
|
||
breakpoints(): number[] {
|
||
const points = [0, this.leftExtent, -this.rightExtent];
|
||
for (const [offset] of this.ditchPoints) points.push(offset);
|
||
// 절토 사면 꼭짓점(무릎·소단 모서리) — 빠뜨리면 계단이 설계선에 안 실린다.
|
||
if (this.twoStage || this.berm !== null) {
|
||
for (const side of ["left", "right"]) {
|
||
const role = side === "left" ? this.leftRole : this.rightRole;
|
||
if (role !== "cut") continue;
|
||
const cross = this.cutCrossDist(side);
|
||
for (const [offset] of this.cutPoints(side)) {
|
||
if (cross !== null && offset > cross + 1e-9) break; // 지반과 만난 뒤는 절토가 없다
|
||
points.push(side === "left" ? offset : -offset);
|
||
}
|
||
}
|
||
}
|
||
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;
|
||
}
|
||
}
|