Files
Aislo/common_util/common_util_cross_design_geometry.ts
T
eomsangdonandClaude Opus 5 db4f17fb1c feat(횡단): 성토면 소단 + 옹벽 의무 판정을 「소단 사이 구간별 최대」로 (계획서 3-9 ⑥)
한 묶음으로 처리했음 — 화면에 5m 판정이 틀린 채 서 있는 시간이 없게.

성토 사면 꼭짓점 짝 신설(`fill_profile_points` · `fillProfilePoints`). 절토와 달리
경사가 하나뿐이라 무릎이 없고, 소단 규칙은 같음. 설계선·지반 교차·꼭짓점 목록이 모두
그 선을 봄.

⚠ **옹벽 의무 판정을 함께 고쳤음** — 「성토사면 길이 5m 이내, 넘으면 옹벽·석축 의무」
(`성토_비탈면.md` §2)를 재는 `fillSlopeLengths` 가 **「성토선은 1:n 직선」을 전제로
수평거리 × 기울기**로 재고 있었음(주석에도 그 전제가 적혀 있었음). 소단이 들어가면 그
전제가 깨져 값이 틀림.

고침 — 소단이 있으면 설계선을 걸어가며 **소단으로 끊긴 한 도막**의 최대 길이를 잼.
· 전체를 한 줄로 재면 소단을 넣어도 5m 를 넘어 **의무가 사라지지 않음**
· 실효 경사로 재면 완만해져 **의무가 사라진 것처럼** 보임
둘 다 틀리므로 「끊긴 한 도막」이 맞는 기준임.

**소단이 없는 측점은 종전 식을 그대로 탐** — `design.berm` 이 없으면 예전 계산 그대로라
값이 한 톨도 안 바뀜(구조로 보장). 화면 전후 대조는 다음 단계에서 냄.

자체검증 — 새 시험 2건(성토면에 계단이 서고 같은 거리에서 덜 내려감 · 소단이 없으면
성토선이 곧은 한 줄). 전체 542 passed · 18 skipped. TS 타입 검사·ruff 통과.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-07 16:38:33 +09:00

461 lines
19 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.
/* =============================================================================
* 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,
fillProfilePoints,
} 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 fillPointsCache = 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);
}
/** 짝: `fill_points`. 성토 사면 꼭짓점 — 소단이 들어 있다(무릎은 없다). */
fillPoints(side: string): Array<[number, number]> {
const cached = this.fillPointsCache.get(side);
if (cached !== undefined) return cached;
const [startDist, startZ] = this.slopeStart(side);
const points = fillProfilePoints(startDist, startZ, this.fillRatio, this.berm);
this.fillPointsCache.set(side, points);
return points;
}
/** 짝: `_fill_slope_z`. 성토 사면선 표고(소단 반영, 지반 클램프 없음). */
private fillSlopeZ(side: string, dist: number): number {
return elevationAt(this.fillPoints(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] = this.slopeStart(side);
let dist = startDist;
const maxDist = startDist + CROSS_MAX_M;
while (dist <= maxDist) {
const signed = side === "left" ? dist : -dist;
const fillLine = this.fillSlopeZ(side, dist);
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 dist = Math.abs(offsetM);
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(this.fillSlopeZ(side, dist), 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 && Math.abs(this.soilCutRatio - this.cutRatio) > 1e-9) {
const drawn = run / rise;
material =
Math.abs(drawn - this.soilCutRatio) < Math.abs(drawn - this.cutRatio) ? "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);
}
}
}
// 성토 사면 소단 모서리 — 절토와 같은 까닭으로 설계선에 실어야 계단이 그려진다.
if (this.berm !== null) {
for (const side of ["left", "right"]) {
const role = side === "left" ? this.leftRole : this.rightRole;
if (role !== "fill") continue;
const cross = this.fillCrossDist(side);
for (const [offset] of this.fillPoints(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;
}
}