Files
Aislo/common_util/common_util_cross_design_geometry.ts
T
eomsangdonandClaude Opus 5 e43ad18f97 feat(횡단): 절토 사면에 소단(계단) 기하를 넣음 — 파이썬·TS 짝 (계획서 3-9)
사용자 확정(2026-09-07)에 따라 소단을 **자동 적용하지 않고 사용자가 놓는 것**으로 만듦.
이 커밋은 그 기하 한 벌이고, 화면 폼은 다음 단계임.

새 짝 모듈 `common_util_cross_berm.py` · `.ts` — 절토 사면 꼭짓점을 만듦.
암 경계 무릎과 소단이 한 목록에 함께 들어가고, `breakpoints` 가 그 꼭짓점을 설계선에
실어 도면·면적·유토곡선·3D 가 계단을 그대로 봄.

기본값 폭 0.5m · 간격(사면길이) 3.0m · 안쪽 기울기 2°.
· 폭·간격은 별표2 범위(사면길이 2~3m마다 · 폭 50~100㎝) 안에서 가장 적게 파는 조합임.
  기본값은 되돌리기 쉬운 쪽이어야 함 — 더 넣는 것은 폼에서 한 번이지만 이미 판 것을
  되돌리면 전 측점을 다시 계산해야 함. 실효 경사로도 그러함(경사 1:1 기준)
  폭 0.5·간격 3 → 1:1.24 / 폭 1.0·간격 3 → 1:1.47 / 폭 1.0·간격 2 → 1:1.71.
· 기울기 2°는 사용자 확정값이고 법령·교본 근거가 없어 지식DB 에 적지 않음(사용자 지시).

무릎은 종전대로 **한 번만** 꺾음. 여러 번 꺾게 풀면 소단이 없는 지금 측점 설계도 같이
바뀌므로 별건으로 미룸(실측: 물결 경계에서 0.0016m 차이, 경계를 다시 만나면 구조가 달라짐).

자체검증
- 거울 시험 10건 `tmp/tests/test_cross_berm_mirror.py` — 소단 켠 5경우 포함해 파이썬·TS
  꼭짓점이 1e-9 안에서 일치. 간격이 수평이 아니라 사면길이 기준인 것, 평탄부가 2° 기운 것,
  소단이 없으면 옛 무릎 방식과 완전히 같은 것까지 확인.
- 면적 시험 6건 `tmp/tests/test_cross_berm_area.py` — 절토 55.115 → 78.785㎡(기본값),
  폭·간격이 물량에 단조로 반영, 소단 모서리가 설계선에 실림, 소단을 안 주면 설계선까지 동일.
- ⚠ 어림식 두 번을 시험이 잡아냄. 「늘어난 면적 = 폭 × 그 위 높이」는 틀림(11.797 vs
  7.770㎡) — 설계선이 밀리면 지반과 만나는 점도 함께 밀려 절토가 더 길어짐. 「폭만큼 밀림」도
  틀림 — 참값은 「그 아래 소단 개수 × 폭」임. 시험은 그 참값으로 잼.
- 기존 거울 시험(`test_b06_cross_design_mirror.py`) 10건 그대로 통과 — 리팩터가 값을
  안 바꿨다는 증거임. 전체 537 passed · 18 skipped.

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

357 lines
14 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 } from "./common_util_cross_berm";
/** 사면·경계 교차 탐색 행진 간격(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;
}) {
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.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,
);
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);
}
/** 짝: `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;
}
}